@vxil/cli 0.20.1 → 0.21.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
@@ -19,7 +19,7 @@ import { pathToFileURL, fileURLToPath } from "node:url";
19
19
  import { resolve, dirname, basename, join } from "node:path";
20
20
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
21
21
 
22
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/guard/value.mjs
22
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/guard/value.mjs
23
23
  var value_exports = {};
24
24
  __export(value_exports, {
25
25
  HasPropertyKey: () => HasPropertyKey,
@@ -88,7 +88,7 @@ function IsUndefined(value) {
88
88
  return value === void 0;
89
89
  }
90
90
 
91
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/clone/value.mjs
91
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/clone/value.mjs
92
92
  function ArrayType(value) {
93
93
  return value.map((value2) => Visit(value2));
94
94
  }
@@ -118,12 +118,12 @@ function Clone(value) {
118
118
  return Visit(value);
119
119
  }
120
120
 
121
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/clone/type.mjs
121
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/clone/type.mjs
122
122
  function CloneType(schema, options) {
123
123
  return options === void 0 ? Clone(schema) : Clone({ ...options, ...schema });
124
124
  }
125
125
 
126
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/value/guard/guard.mjs
126
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/value/guard/guard.mjs
127
127
  function IsObject2(value) {
128
128
  return value !== null && typeof value === "object";
129
129
  }
@@ -137,7 +137,7 @@ function IsNumber2(value) {
137
137
  return typeof value === "number";
138
138
  }
139
139
 
140
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/system/policy.mjs
140
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/system/policy.mjs
141
141
  var TypeSystemPolicy;
142
142
  (function(TypeSystemPolicy2) {
143
143
  TypeSystemPolicy2.InstanceMode = "default";
@@ -169,7 +169,7 @@ var TypeSystemPolicy;
169
169
  TypeSystemPolicy2.IsVoidLike = IsVoidLike;
170
170
  })(TypeSystemPolicy || (TypeSystemPolicy = {}));
171
171
 
172
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/create/immutable.mjs
172
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/create/immutable.mjs
173
173
  function ImmutableArray(value) {
174
174
  return globalThis.Object.freeze(value).map((value2) => Immutable(value2));
175
175
  }
@@ -196,7 +196,7 @@ function Immutable(value) {
196
196
  return IsArray(value) ? ImmutableArray(value) : IsDate(value) ? ImmutableDate(value) : IsUint8Array(value) ? ImmutableUint8Array(value) : IsRegExp(value) ? ImmutableRegExp(value) : IsObject(value) ? ImmutableObject(value) : value;
197
197
  }
198
198
 
199
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/create/type.mjs
199
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/create/type.mjs
200
200
  function CreateType(schema, options) {
201
201
  const result = options !== void 0 ? { ...options, ...schema } : schema;
202
202
  switch (TypeSystemPolicy.InstanceMode) {
@@ -209,21 +209,21 @@ function CreateType(schema, options) {
209
209
  }
210
210
  }
211
211
 
212
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/error/error.mjs
212
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/error/error.mjs
213
213
  var TypeBoxError = class extends Error {
214
214
  constructor(message) {
215
215
  super(message);
216
216
  }
217
217
  };
218
218
 
219
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/symbols/symbols.mjs
219
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/symbols/symbols.mjs
220
220
  var TransformKind = /* @__PURE__ */ Symbol.for("TypeBox.Transform");
221
221
  var ReadonlyKind = /* @__PURE__ */ Symbol.for("TypeBox.Readonly");
222
222
  var OptionalKind = /* @__PURE__ */ Symbol.for("TypeBox.Optional");
223
223
  var Hint = /* @__PURE__ */ Symbol.for("TypeBox.Hint");
224
224
  var Kind = /* @__PURE__ */ Symbol.for("TypeBox.Kind");
225
225
 
226
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/guard/kind.mjs
226
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/guard/kind.mjs
227
227
  function IsReadonly(value) {
228
228
  return IsObject(value) && value[ReadonlyKind] === "Readonly";
229
229
  }
@@ -354,7 +354,7 @@ function IsSchema(value) {
354
354
  return IsAny(value) || IsArgument(value) || IsArray3(value) || IsBoolean2(value) || IsBigInt2(value) || IsAsyncIterator2(value) || IsComputed(value) || IsConstructor(value) || IsDate2(value) || IsFunction2(value) || IsInteger(value) || IsIntersect(value) || IsIterator2(value) || IsLiteral(value) || IsMappedKey(value) || IsMappedResult(value) || IsNever(value) || IsNot(value) || IsNull2(value) || IsNumber3(value) || IsObject3(value) || IsPromise(value) || IsRecord(value) || IsRef(value) || IsRegExp2(value) || IsString2(value) || IsSymbol2(value) || IsTemplateLiteral(value) || IsThis(value) || IsTuple(value) || IsUndefined3(value) || IsUnion(value) || IsUint8Array2(value) || IsUnknown(value) || IsUnsafe(value) || IsVoid(value) || IsKind(value);
355
355
  }
356
356
 
357
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/guard/type.mjs
357
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/guard/type.mjs
358
358
  var type_exports = {};
359
359
  __export(type_exports, {
360
360
  IsAny: () => IsAny2,
@@ -646,7 +646,7 @@ function IsSchema2(value) {
646
646
  return IsObject(value) && (IsAny2(value) || IsArgument2(value) || IsArray4(value) || IsBoolean3(value) || IsBigInt3(value) || IsAsyncIterator3(value) || IsComputed2(value) || IsConstructor2(value) || IsDate3(value) || IsFunction3(value) || IsInteger2(value) || IsIntersect2(value) || IsIterator3(value) || IsLiteral2(value) || IsMappedKey2(value) || IsMappedResult2(value) || IsNever2(value) || IsNot2(value) || IsNull3(value) || IsNumber4(value) || IsObject4(value) || IsPromise2(value) || IsRecord2(value) || IsRef2(value) || IsRegExp3(value) || IsString3(value) || IsSymbol3(value) || IsTemplateLiteral2(value) || IsThis2(value) || IsTuple2(value) || IsUndefined4(value) || IsUnion2(value) || IsUint8Array3(value) || IsUnknown2(value) || IsUnsafe2(value) || IsVoid2(value) || IsKind2(value));
647
647
  }
648
648
 
649
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/patterns/patterns.mjs
649
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/patterns/patterns.mjs
650
650
  var PatternBoolean = "(true|false)";
651
651
  var PatternNumber = "(0|[1-9][0-9]*)";
652
652
  var PatternString = "(.*)";
@@ -656,7 +656,7 @@ var PatternNumberExact = `^${PatternNumber}$`;
656
656
  var PatternStringExact = `^${PatternString}$`;
657
657
  var PatternNeverExact = `^${PatternNever}$`;
658
658
 
659
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/registry/format.mjs
659
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/registry/format.mjs
660
660
  var format_exports = {};
661
661
  __export(format_exports, {
662
662
  Clear: () => Clear,
@@ -686,7 +686,7 @@ function Get(format) {
686
686
  return map.get(format);
687
687
  }
688
688
 
689
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/sets/set.mjs
689
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/sets/set.mjs
690
690
  function SetIncludes(T, S) {
691
691
  return T.includes(S);
692
692
  }
@@ -711,32 +711,32 @@ function SetUnionMany(T) {
711
711
  return Acc;
712
712
  }
713
713
 
714
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/any/any.mjs
714
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/any/any.mjs
715
715
  function Any(options) {
716
716
  return CreateType({ [Kind]: "Any" }, options);
717
717
  }
718
718
 
719
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/array/array.mjs
719
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/array/array.mjs
720
720
  function Array2(items, options) {
721
721
  return CreateType({ [Kind]: "Array", type: "array", items }, options);
722
722
  }
723
723
 
724
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/argument/argument.mjs
724
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/argument/argument.mjs
725
725
  function Argument(index) {
726
726
  return CreateType({ [Kind]: "Argument", index });
727
727
  }
728
728
 
729
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/async-iterator/async-iterator.mjs
729
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/async-iterator/async-iterator.mjs
730
730
  function AsyncIterator(items, options) {
731
731
  return CreateType({ [Kind]: "AsyncIterator", type: "AsyncIterator", items }, options);
732
732
  }
733
733
 
734
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/computed/computed.mjs
734
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/computed/computed.mjs
735
735
  function Computed(target, parameters, options) {
736
736
  return CreateType({ [Kind]: "Computed", target, parameters }, options);
737
737
  }
738
738
 
739
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/discard/discard.mjs
739
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/discard/discard.mjs
740
740
  function DiscardKey(value, key) {
741
741
  const { [key]: _, ...rest2 } = value;
742
742
  return rest2;
@@ -745,12 +745,12 @@ function Discard(value, keys) {
745
745
  return keys.reduce((acc, key) => DiscardKey(acc, key), value);
746
746
  }
747
747
 
748
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/never/never.mjs
748
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/never/never.mjs
749
749
  function Never(options) {
750
750
  return CreateType({ [Kind]: "Never", not: {} }, options);
751
751
  }
752
752
 
753
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/mapped/mapped-result.mjs
753
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/mapped/mapped-result.mjs
754
754
  function MappedResult(properties) {
755
755
  return CreateType({
756
756
  [Kind]: "MappedResult",
@@ -758,22 +758,22 @@ function MappedResult(properties) {
758
758
  });
759
759
  }
760
760
 
761
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/constructor/constructor.mjs
761
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/constructor/constructor.mjs
762
762
  function Constructor(parameters, returns, options) {
763
763
  return CreateType({ [Kind]: "Constructor", type: "Constructor", parameters, returns }, options);
764
764
  }
765
765
 
766
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/function/function.mjs
766
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/function/function.mjs
767
767
  function Function(parameters, returns, options) {
768
768
  return CreateType({ [Kind]: "Function", type: "Function", parameters, returns }, options);
769
769
  }
770
770
 
771
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/union/union-create.mjs
771
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/union/union-create.mjs
772
772
  function UnionCreate(T, options) {
773
773
  return CreateType({ [Kind]: "Union", anyOf: T }, options);
774
774
  }
775
775
 
776
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/union/union-evaluated.mjs
776
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/union/union-evaluated.mjs
777
777
  function IsUnionOptional(types2) {
778
778
  return types2.some((type) => IsOptional(type));
779
779
  }
@@ -791,12 +791,12 @@ function UnionEvaluated(T, options) {
791
791
  return T.length === 1 ? CreateType(T[0], options) : T.length === 0 ? Never(options) : ResolveUnion(T, options);
792
792
  }
793
793
 
794
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/union/union.mjs
794
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/union/union.mjs
795
795
  function Union(types2, options) {
796
796
  return types2.length === 0 ? Never(options) : types2.length === 1 ? CreateType(types2[0], options) : UnionCreate(types2, options);
797
797
  }
798
798
 
799
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/template-literal/parse.mjs
799
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/template-literal/parse.mjs
800
800
  var TemplateLiteralParserError = class extends TypeBoxError {
801
801
  };
802
802
  function Unescape(pattern) {
@@ -920,7 +920,7 @@ function TemplateLiteralParseExact(pattern) {
920
920
  return TemplateLiteralParse(pattern.slice(1, pattern.length - 1));
921
921
  }
922
922
 
923
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/template-literal/finite.mjs
923
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/template-literal/finite.mjs
924
924
  var TemplateLiteralFiniteError = class extends TypeBoxError {
925
925
  };
926
926
  function IsNumberExpression(expression) {
@@ -942,7 +942,7 @@ function IsTemplateLiteralFinite(schema) {
942
942
  return IsTemplateLiteralExpressionFinite(expression);
943
943
  }
944
944
 
945
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/template-literal/generate.mjs
945
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/template-literal/generate.mjs
946
946
  var TemplateLiteralGenerateError = class extends TypeBoxError {
947
947
  };
948
948
  function* GenerateReduce(buffer2) {
@@ -974,7 +974,7 @@ function TemplateLiteralGenerate(schema) {
974
974
  return IsTemplateLiteralExpressionFinite(expression) ? [...TemplateLiteralExpressionGenerate(expression)] : [];
975
975
  }
976
976
 
977
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/literal/literal.mjs
977
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/literal/literal.mjs
978
978
  function Literal(value, options) {
979
979
  return CreateType({
980
980
  [Kind]: "Literal",
@@ -983,27 +983,27 @@ function Literal(value, options) {
983
983
  }, options);
984
984
  }
985
985
 
986
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/boolean/boolean.mjs
986
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/boolean/boolean.mjs
987
987
  function Boolean2(options) {
988
988
  return CreateType({ [Kind]: "Boolean", type: "boolean" }, options);
989
989
  }
990
990
 
991
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/bigint/bigint.mjs
991
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/bigint/bigint.mjs
992
992
  function BigInt2(options) {
993
993
  return CreateType({ [Kind]: "BigInt", type: "bigint" }, options);
994
994
  }
995
995
 
996
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/number/number.mjs
996
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/number/number.mjs
997
997
  function Number2(options) {
998
998
  return CreateType({ [Kind]: "Number", type: "number" }, options);
999
999
  }
1000
1000
 
1001
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/string/string.mjs
1001
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/string/string.mjs
1002
1002
  function String2(options) {
1003
1003
  return CreateType({ [Kind]: "String", type: "string" }, options);
1004
1004
  }
1005
1005
 
1006
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/template-literal/syntax.mjs
1006
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/template-literal/syntax.mjs
1007
1007
  function* FromUnion(syntax) {
1008
1008
  const trim = syntax.trim().replace(/"|'/g, "");
1009
1009
  return trim === "boolean" ? yield Boolean2() : trim === "number" ? yield Number2() : trim === "bigint" ? yield BigInt2() : trim === "string" ? yield String2() : yield (() => {
@@ -1040,7 +1040,7 @@ function TemplateLiteralSyntax(syntax) {
1040
1040
  return [...FromSyntax(syntax)];
1041
1041
  }
1042
1042
 
1043
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/template-literal/pattern.mjs
1043
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/template-literal/pattern.mjs
1044
1044
  var TemplateLiteralPatternError = class extends TypeBoxError {
1045
1045
  };
1046
1046
  function Escape(value) {
@@ -1055,20 +1055,20 @@ function TemplateLiteralPattern(kinds) {
1055
1055
  return `^${kinds.map((schema) => Visit2(schema, "")).join("")}$`;
1056
1056
  }
1057
1057
 
1058
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/template-literal/union.mjs
1058
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/template-literal/union.mjs
1059
1059
  function TemplateLiteralToUnion(schema) {
1060
1060
  const R = TemplateLiteralGenerate(schema);
1061
1061
  const L = R.map((S) => Literal(S));
1062
1062
  return UnionEvaluated(L);
1063
1063
  }
1064
1064
 
1065
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/template-literal/template-literal.mjs
1065
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/template-literal/template-literal.mjs
1066
1066
  function TemplateLiteral(unresolved, options) {
1067
1067
  const pattern = IsString(unresolved) ? TemplateLiteralPattern(TemplateLiteralSyntax(unresolved)) : TemplateLiteralPattern(unresolved);
1068
1068
  return CreateType({ [Kind]: "TemplateLiteral", type: "string", pattern }, options);
1069
1069
  }
1070
1070
 
1071
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/indexed/indexed-property-keys.mjs
1071
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/indexed/indexed-property-keys.mjs
1072
1072
  function FromTemplateLiteral(templateLiteral) {
1073
1073
  const keys = TemplateLiteralGenerate(templateLiteral);
1074
1074
  return keys.map((key) => key.toString());
@@ -1086,7 +1086,7 @@ function IndexPropertyKeys(type) {
1086
1086
  return [...new Set(IsTemplateLiteral(type) ? FromTemplateLiteral(type) : IsUnion(type) ? FromUnion2(type.anyOf) : IsLiteral(type) ? FromLiteral(type.const) : IsNumber3(type) ? ["[number]"] : IsInteger(type) ? ["[number]"] : [])];
1087
1087
  }
1088
1088
 
1089
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/indexed/indexed-from-mapped-result.mjs
1089
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/indexed/indexed-from-mapped-result.mjs
1090
1090
  function FromProperties(type, properties, options) {
1091
1091
  const result = {};
1092
1092
  for (const K2 of Object.getOwnPropertyNames(properties)) {
@@ -1102,7 +1102,7 @@ function IndexFromMappedResult(type, mappedResult, options) {
1102
1102
  return MappedResult(properties);
1103
1103
  }
1104
1104
 
1105
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/indexed/indexed.mjs
1105
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/indexed/indexed.mjs
1106
1106
  function FromRest(types2, key) {
1107
1107
  return types2.map((type) => IndexFromPropertyKey(type, key));
1108
1108
  }
@@ -1150,7 +1150,7 @@ function Index(type, key, options) {
1150
1150
  return CreateType(IsSchema(key) ? FromSchema(type, IndexPropertyKeys(key)) : FromSchema(type, key), options);
1151
1151
  }
1152
1152
 
1153
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/indexed/indexed-from-mapped-key.mjs
1153
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/indexed/indexed-from-mapped-key.mjs
1154
1154
  function MappedIndexPropertyKey(type, key, options) {
1155
1155
  return { [key]: Index(type, [key], Clone(options)) };
1156
1156
  }
@@ -1167,28 +1167,28 @@ function IndexFromMappedKey(type, mappedKey, options) {
1167
1167
  return MappedResult(properties);
1168
1168
  }
1169
1169
 
1170
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/iterator/iterator.mjs
1170
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/iterator/iterator.mjs
1171
1171
  function Iterator(items, options) {
1172
1172
  return CreateType({ [Kind]: "Iterator", type: "Iterator", items }, options);
1173
1173
  }
1174
1174
 
1175
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/object/object.mjs
1175
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/object/object.mjs
1176
1176
  function RequiredArray(properties) {
1177
1177
  return globalThis.Object.keys(properties).filter((key) => !IsOptional(properties[key]));
1178
1178
  }
1179
- function _Object(properties, options) {
1179
+ function _Object_(properties, options) {
1180
1180
  const required = RequiredArray(properties);
1181
1181
  const schema = required.length > 0 ? { [Kind]: "Object", type: "object", required, properties } : { [Kind]: "Object", type: "object", properties };
1182
1182
  return CreateType(schema, options);
1183
1183
  }
1184
- var Object2 = _Object;
1184
+ var Object2 = _Object_;
1185
1185
 
1186
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/promise/promise.mjs
1186
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/promise/promise.mjs
1187
1187
  function Promise2(item, options) {
1188
1188
  return CreateType({ [Kind]: "Promise", type: "Promise", item }, options);
1189
1189
  }
1190
1190
 
1191
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/readonly/readonly.mjs
1191
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/readonly/readonly.mjs
1192
1192
  function RemoveReadonly(schema) {
1193
1193
  return CreateType(Discard(schema, [ReadonlyKind]));
1194
1194
  }
@@ -1203,7 +1203,7 @@ function Readonly(schema, enable) {
1203
1203
  return IsMappedResult(schema) ? ReadonlyFromMappedResult(schema, F) : ReadonlyWithFlag(schema, F);
1204
1204
  }
1205
1205
 
1206
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/readonly/readonly-from-mapped-result.mjs
1206
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/readonly/readonly-from-mapped-result.mjs
1207
1207
  function FromProperties2(K, F) {
1208
1208
  const Acc = {};
1209
1209
  for (const K2 of globalThis.Object.getOwnPropertyNames(K))
@@ -1218,12 +1218,12 @@ function ReadonlyFromMappedResult(R, F) {
1218
1218
  return MappedResult(P);
1219
1219
  }
1220
1220
 
1221
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/tuple/tuple.mjs
1221
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/tuple/tuple.mjs
1222
1222
  function Tuple(types2, options) {
1223
1223
  return CreateType(types2.length > 0 ? { [Kind]: "Tuple", type: "array", items: types2, additionalItems: false, minItems: types2.length, maxItems: types2.length } : { [Kind]: "Tuple", type: "array", minItems: types2.length, maxItems: types2.length }, options);
1224
1224
  }
1225
1225
 
1226
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/mapped/mapped.mjs
1226
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/mapped/mapped.mjs
1227
1227
  function FromMappedResult3(K, P) {
1228
1228
  return K in P ? FromSchemaType(K, P[K]) : MappedResult(P);
1229
1229
  }
@@ -1278,7 +1278,7 @@ function Mapped(key, map2, options) {
1278
1278
  return Object2(R, options);
1279
1279
  }
1280
1280
 
1281
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/optional/optional.mjs
1281
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/optional/optional.mjs
1282
1282
  function RemoveOptional(schema) {
1283
1283
  return CreateType(Discard(schema, [OptionalKind]));
1284
1284
  }
@@ -1293,7 +1293,7 @@ function Optional(schema, enable) {
1293
1293
  return IsMappedResult(schema) ? OptionalFromMappedResult(schema, F) : OptionalWithFlag(schema, F);
1294
1294
  }
1295
1295
 
1296
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/optional/optional-from-mapped-result.mjs
1296
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/optional/optional-from-mapped-result.mjs
1297
1297
  function FromProperties4(P, F) {
1298
1298
  const Acc = {};
1299
1299
  for (const K2 of globalThis.Object.getOwnPropertyNames(P))
@@ -1308,14 +1308,14 @@ function OptionalFromMappedResult(R, F) {
1308
1308
  return MappedResult(P);
1309
1309
  }
1310
1310
 
1311
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/intersect/intersect-create.mjs
1311
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/intersect/intersect-create.mjs
1312
1312
  function IntersectCreate(T, options = {}) {
1313
1313
  const allObjects = T.every((schema) => IsObject3(schema));
1314
1314
  const clonedUnevaluatedProperties = IsSchema(options.unevaluatedProperties) ? { unevaluatedProperties: options.unevaluatedProperties } : {};
1315
1315
  return CreateType(options.unevaluatedProperties === false || IsSchema(options.unevaluatedProperties) || allObjects ? { ...clonedUnevaluatedProperties, [Kind]: "Intersect", type: "object", allOf: T } : { ...clonedUnevaluatedProperties, [Kind]: "Intersect", allOf: T }, options);
1316
1316
  }
1317
1317
 
1318
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/intersect/intersect-evaluated.mjs
1318
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/intersect/intersect-evaluated.mjs
1319
1319
  function IsIntersectOptional(types2) {
1320
1320
  return types2.every((left) => IsOptional(left));
1321
1321
  }
@@ -1338,7 +1338,7 @@ function IntersectEvaluated(types2, options = {}) {
1338
1338
  return ResolveIntersect(types2, options);
1339
1339
  }
1340
1340
 
1341
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/intersect/intersect.mjs
1341
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/intersect/intersect.mjs
1342
1342
  function Intersect(types2, options) {
1343
1343
  if (types2.length === 1)
1344
1344
  return CreateType(types2[0], options);
@@ -1349,7 +1349,7 @@ function Intersect(types2, options) {
1349
1349
  return IntersectCreate(types2, options);
1350
1350
  }
1351
1351
 
1352
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/ref/ref.mjs
1352
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/ref/ref.mjs
1353
1353
  function Ref(...args) {
1354
1354
  const [$ref, options] = typeof args[0] === "string" ? [args[0], args[1]] : [args[0].$id, args[1]];
1355
1355
  if (typeof $ref !== "string")
@@ -1357,7 +1357,7 @@ function Ref(...args) {
1357
1357
  return CreateType({ [Kind]: "Ref", $ref }, options);
1358
1358
  }
1359
1359
 
1360
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/awaited/awaited.mjs
1360
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/awaited/awaited.mjs
1361
1361
  function FromComputed(target, parameters) {
1362
1362
  return Computed("Awaited", [Computed(target, parameters)]);
1363
1363
  }
@@ -1380,7 +1380,7 @@ function Awaited(type, options) {
1380
1380
  return CreateType(IsComputed(type) ? FromComputed(type.target, type.parameters) : IsIntersect(type) ? FromIntersect2(type.allOf) : IsUnion(type) ? FromUnion4(type.anyOf) : IsPromise(type) ? FromPromise(type.item) : IsRef(type) ? FromRef(type.$ref) : type, options);
1381
1381
  }
1382
1382
 
1383
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/keyof/keyof-property-keys.mjs
1383
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/keyof/keyof-property-keys.mjs
1384
1384
  function FromRest4(types2) {
1385
1385
  const result = [];
1386
1386
  for (const L of types2)
@@ -1419,7 +1419,7 @@ function KeyOfPropertyKeys(type) {
1419
1419
  }
1420
1420
  var includePatternProperties = false;
1421
1421
 
1422
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/keyof/keyof.mjs
1422
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/keyof/keyof.mjs
1423
1423
  function FromComputed2(target, parameters) {
1424
1424
  return Computed("KeyOf", [Computed(target, parameters)]);
1425
1425
  }
@@ -1439,7 +1439,7 @@ function KeyOf(type, options) {
1439
1439
  return IsComputed(type) ? FromComputed2(type.target, type.parameters) : IsRef(type) ? FromRef2(type.$ref) : IsMappedResult(type) ? KeyOfFromMappedResult(type, options) : KeyOfFromType(type, options);
1440
1440
  }
1441
1441
 
1442
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/keyof/keyof-from-mapped-result.mjs
1442
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/keyof/keyof-from-mapped-result.mjs
1443
1443
  function FromProperties6(properties, options) {
1444
1444
  const result = {};
1445
1445
  for (const K2 of globalThis.Object.getOwnPropertyNames(properties))
@@ -1454,7 +1454,7 @@ function KeyOfFromMappedResult(mappedResult, options) {
1454
1454
  return MappedResult(properties);
1455
1455
  }
1456
1456
 
1457
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/composite/composite.mjs
1457
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/composite/composite.mjs
1458
1458
  function CompositeKeys(T) {
1459
1459
  const Acc = [];
1460
1460
  for (const L of T)
@@ -1484,37 +1484,37 @@ function Composite(T, options) {
1484
1484
  return R;
1485
1485
  }
1486
1486
 
1487
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/date/date.mjs
1487
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/date/date.mjs
1488
1488
  function Date2(options) {
1489
1489
  return CreateType({ [Kind]: "Date", type: "Date" }, options);
1490
1490
  }
1491
1491
 
1492
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/null/null.mjs
1492
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/null/null.mjs
1493
1493
  function Null(options) {
1494
1494
  return CreateType({ [Kind]: "Null", type: "null" }, options);
1495
1495
  }
1496
1496
 
1497
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/symbol/symbol.mjs
1497
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/symbol/symbol.mjs
1498
1498
  function Symbol2(options) {
1499
1499
  return CreateType({ [Kind]: "Symbol", type: "symbol" }, options);
1500
1500
  }
1501
1501
 
1502
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/undefined/undefined.mjs
1502
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/undefined/undefined.mjs
1503
1503
  function Undefined(options) {
1504
1504
  return CreateType({ [Kind]: "Undefined", type: "undefined" }, options);
1505
1505
  }
1506
1506
 
1507
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/uint8array/uint8array.mjs
1507
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/uint8array/uint8array.mjs
1508
1508
  function Uint8Array2(options) {
1509
1509
  return CreateType({ [Kind]: "Uint8Array", type: "Uint8Array" }, options);
1510
1510
  }
1511
1511
 
1512
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/unknown/unknown.mjs
1512
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/unknown/unknown.mjs
1513
1513
  function Unknown(options) {
1514
1514
  return CreateType({ [Kind]: "Unknown" }, options);
1515
1515
  }
1516
1516
 
1517
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/const/const.mjs
1517
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/const/const.mjs
1518
1518
  function FromArray3(T) {
1519
1519
  return T.map((L) => FromValue(L, false));
1520
1520
  }
@@ -1534,12 +1534,12 @@ function Const(T, options) {
1534
1534
  return CreateType(FromValue(T, true), options);
1535
1535
  }
1536
1536
 
1537
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/constructor-parameters/constructor-parameters.mjs
1537
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/constructor-parameters/constructor-parameters.mjs
1538
1538
  function ConstructorParameters(schema, options) {
1539
1539
  return IsConstructor(schema) ? Tuple(schema.parameters, options) : Never(options);
1540
1540
  }
1541
1541
 
1542
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/enum/enum.mjs
1542
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/enum/enum.mjs
1543
1543
  function Enum(item, options) {
1544
1544
  if (IsUndefined(item))
1545
1545
  throw new Error("Enum undefined or empty");
@@ -1549,7 +1549,7 @@ function Enum(item, options) {
1549
1549
  return Union(anyOf, { ...options, [Hint]: "Enum" });
1550
1550
  }
1551
1551
 
1552
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/extends/extends-check.mjs
1552
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/extends/extends-check.mjs
1553
1553
  var ExtendsResolverError = class extends TypeBoxError {
1554
1554
  };
1555
1555
  var ExtendsResult;
@@ -1800,7 +1800,7 @@ function ExtendsCheck(left, right) {
1800
1800
  return Visit3(left, right);
1801
1801
  }
1802
1802
 
1803
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/extends/extends-from-mapped-result.mjs
1803
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/extends/extends-from-mapped-result.mjs
1804
1804
  function FromProperties8(P, Right, True, False, options) {
1805
1805
  const Acc = {};
1806
1806
  for (const K2 of globalThis.Object.getOwnPropertyNames(P))
@@ -1815,7 +1815,7 @@ function ExtendsFromMappedResult(Left, Right, True, False, options) {
1815
1815
  return MappedResult(P);
1816
1816
  }
1817
1817
 
1818
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/extends/extends.mjs
1818
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/extends/extends.mjs
1819
1819
  function ExtendsResolve(left, right, trueType, falseType) {
1820
1820
  const R = ExtendsCheck(left, right);
1821
1821
  return R === ExtendsResult.Union ? Union([trueType, falseType]) : R === ExtendsResult.True ? trueType : falseType;
@@ -1824,7 +1824,7 @@ function Extends(L, R, T, F, options) {
1824
1824
  return IsMappedResult(L) ? ExtendsFromMappedResult(L, R, T, F, options) : IsMappedKey(L) ? CreateType(ExtendsFromMappedKey(L, R, T, F, options)) : CreateType(ExtendsResolve(L, R, T, F), options);
1825
1825
  }
1826
1826
 
1827
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/extends/extends-from-mapped-key.mjs
1827
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/extends/extends-from-mapped-key.mjs
1828
1828
  function FromPropertyKey(K, U, L, R, options) {
1829
1829
  return {
1830
1830
  [K]: Extends(Literal(K), U, L, R, Clone(options))
@@ -1843,12 +1843,12 @@ function ExtendsFromMappedKey(T, U, L, R, options) {
1843
1843
  return MappedResult(P);
1844
1844
  }
1845
1845
 
1846
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/exclude/exclude-from-template-literal.mjs
1846
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/exclude/exclude-from-template-literal.mjs
1847
1847
  function ExcludeFromTemplateLiteral(L, R) {
1848
1848
  return Exclude(TemplateLiteralToUnion(L), R);
1849
1849
  }
1850
1850
 
1851
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/exclude/exclude.mjs
1851
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/exclude/exclude.mjs
1852
1852
  function ExcludeRest(L, R) {
1853
1853
  const excluded = L.filter((inner) => ExtendsCheck(inner, R) === ExtendsResult.False);
1854
1854
  return excluded.length === 1 ? excluded[0] : Union(excluded);
@@ -1861,7 +1861,7 @@ function Exclude(L, R, options = {}) {
1861
1861
  return CreateType(IsUnion(L) ? ExcludeRest(L.anyOf, R) : ExtendsCheck(L, R) !== ExtendsResult.False ? Never() : L, options);
1862
1862
  }
1863
1863
 
1864
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/exclude/exclude-from-mapped-result.mjs
1864
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/exclude/exclude-from-mapped-result.mjs
1865
1865
  function FromProperties9(P, U) {
1866
1866
  const Acc = {};
1867
1867
  for (const K2 of globalThis.Object.getOwnPropertyNames(P))
@@ -1876,12 +1876,12 @@ function ExcludeFromMappedResult(R, T) {
1876
1876
  return MappedResult(P);
1877
1877
  }
1878
1878
 
1879
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/extract/extract-from-template-literal.mjs
1879
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/extract/extract-from-template-literal.mjs
1880
1880
  function ExtractFromTemplateLiteral(L, R) {
1881
1881
  return Extract(TemplateLiteralToUnion(L), R);
1882
1882
  }
1883
1883
 
1884
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/extract/extract.mjs
1884
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/extract/extract.mjs
1885
1885
  function ExtractRest(L, R) {
1886
1886
  const extracted = L.filter((inner) => ExtendsCheck(inner, R) !== ExtendsResult.False);
1887
1887
  return extracted.length === 1 ? extracted[0] : Union(extracted);
@@ -1894,7 +1894,7 @@ function Extract(L, R, options) {
1894
1894
  return CreateType(IsUnion(L) ? ExtractRest(L.anyOf, R) : ExtendsCheck(L, R) !== ExtendsResult.False ? L : Never(), options);
1895
1895
  }
1896
1896
 
1897
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/extract/extract-from-mapped-result.mjs
1897
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/extract/extract-from-mapped-result.mjs
1898
1898
  function FromProperties10(P, T) {
1899
1899
  const Acc = {};
1900
1900
  for (const K2 of globalThis.Object.getOwnPropertyNames(P))
@@ -1909,17 +1909,17 @@ function ExtractFromMappedResult(R, T) {
1909
1909
  return MappedResult(P);
1910
1910
  }
1911
1911
 
1912
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/instance-type/instance-type.mjs
1912
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/instance-type/instance-type.mjs
1913
1913
  function InstanceType(schema, options) {
1914
1914
  return IsConstructor(schema) ? CreateType(schema.returns, options) : Never(options);
1915
1915
  }
1916
1916
 
1917
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/readonly-optional/readonly-optional.mjs
1917
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/readonly-optional/readonly-optional.mjs
1918
1918
  function ReadonlyOptional(schema) {
1919
1919
  return Readonly(Optional(schema));
1920
1920
  }
1921
1921
 
1922
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/record/record.mjs
1922
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/record/record.mjs
1923
1923
  function RecordCreateFromPattern(pattern, T, options) {
1924
1924
  return CreateType({ [Kind]: "Record", type: "object", patternProperties: { [pattern]: T } }, options);
1925
1925
  }
@@ -1974,7 +1974,7 @@ function RecordValue2(type) {
1974
1974
  return type.patternProperties[RecordPattern(type)];
1975
1975
  }
1976
1976
 
1977
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/instantiate/instantiate.mjs
1977
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/instantiate/instantiate.mjs
1978
1978
  function FromConstructor2(args, type) {
1979
1979
  type.parameters = FromTypes(args, type.parameters);
1980
1980
  type.returns = FromType(args, type.returns);
@@ -2049,12 +2049,12 @@ function Instantiate(type, args) {
2049
2049
  return FromType(args, CloneType(type));
2050
2050
  }
2051
2051
 
2052
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/integer/integer.mjs
2052
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/integer/integer.mjs
2053
2053
  function Integer(options) {
2054
2054
  return CreateType({ [Kind]: "Integer", type: "integer" }, options);
2055
2055
  }
2056
2056
 
2057
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/intrinsic/intrinsic-from-mapped-key.mjs
2057
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/intrinsic/intrinsic-from-mapped-key.mjs
2058
2058
  function MappedIntrinsicPropertyKey(K, M, options) {
2059
2059
  return {
2060
2060
  [K]: Intrinsic(Literal(K), M, Clone(options))
@@ -2074,7 +2074,7 @@ function IntrinsicFromMappedKey(T, M, options) {
2074
2074
  return MappedResult(P);
2075
2075
  }
2076
2076
 
2077
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/intrinsic/intrinsic.mjs
2077
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/intrinsic/intrinsic.mjs
2078
2078
  function ApplyUncapitalize(value) {
2079
2079
  const [first, rest2] = [value.slice(0, 1), value.slice(1)];
2080
2080
  return [first.toLowerCase(), rest2].join("");
@@ -2119,27 +2119,27 @@ function Intrinsic(schema, mode, options = {}) {
2119
2119
  );
2120
2120
  }
2121
2121
 
2122
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/intrinsic/capitalize.mjs
2122
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/intrinsic/capitalize.mjs
2123
2123
  function Capitalize(T, options = {}) {
2124
2124
  return Intrinsic(T, "Capitalize", options);
2125
2125
  }
2126
2126
 
2127
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/intrinsic/lowercase.mjs
2127
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/intrinsic/lowercase.mjs
2128
2128
  function Lowercase(T, options = {}) {
2129
2129
  return Intrinsic(T, "Lowercase", options);
2130
2130
  }
2131
2131
 
2132
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/intrinsic/uncapitalize.mjs
2132
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/intrinsic/uncapitalize.mjs
2133
2133
  function Uncapitalize(T, options = {}) {
2134
2134
  return Intrinsic(T, "Uncapitalize", options);
2135
2135
  }
2136
2136
 
2137
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/intrinsic/uppercase.mjs
2137
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/intrinsic/uppercase.mjs
2138
2138
  function Uppercase(T, options = {}) {
2139
2139
  return Intrinsic(T, "Uppercase", options);
2140
2140
  }
2141
2141
 
2142
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/omit/omit-from-mapped-result.mjs
2142
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/omit/omit-from-mapped-result.mjs
2143
2143
  function FromProperties12(properties, propertyKeys, options) {
2144
2144
  const result = {};
2145
2145
  for (const K2 of globalThis.Object.getOwnPropertyNames(properties))
@@ -2154,7 +2154,7 @@ function OmitFromMappedResult(mappedResult, propertyKeys, options) {
2154
2154
  return MappedResult(properties);
2155
2155
  }
2156
2156
 
2157
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/omit/omit.mjs
2157
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/omit/omit.mjs
2158
2158
  function FromIntersect6(types2, propertyKeys) {
2159
2159
  return types2.map((type) => OmitResolve(type, propertyKeys));
2160
2160
  }
@@ -2188,7 +2188,7 @@ function Omit(type, key, options) {
2188
2188
  return IsMappedResult(type) ? OmitFromMappedResult(type, propertyKeys, options) : IsMappedKey(key) ? OmitFromMappedKey(type, key, options) : isTypeRef && isKeyRef ? Computed("Omit", [type, typeKey], options) : !isTypeRef && isKeyRef ? Computed("Omit", [type, typeKey], options) : isTypeRef && !isKeyRef ? Computed("Omit", [type, typeKey], options) : CreateType({ ...OmitResolve(type, propertyKeys), ...options });
2189
2189
  }
2190
2190
 
2191
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/omit/omit-from-mapped-key.mjs
2191
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/omit/omit-from-mapped-key.mjs
2192
2192
  function FromPropertyKey2(type, key, options) {
2193
2193
  return { [key]: Omit(type, [key], Clone(options)) };
2194
2194
  }
@@ -2205,7 +2205,7 @@ function OmitFromMappedKey(type, mappedKey, options) {
2205
2205
  return MappedResult(properties);
2206
2206
  }
2207
2207
 
2208
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/pick/pick-from-mapped-result.mjs
2208
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/pick/pick-from-mapped-result.mjs
2209
2209
  function FromProperties14(properties, propertyKeys, options) {
2210
2210
  const result = {};
2211
2211
  for (const K2 of globalThis.Object.getOwnPropertyNames(properties))
@@ -2220,7 +2220,7 @@ function PickFromMappedResult(mappedResult, propertyKeys, options) {
2220
2220
  return MappedResult(properties);
2221
2221
  }
2222
2222
 
2223
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/pick/pick.mjs
2223
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/pick/pick.mjs
2224
2224
  function FromIntersect7(types2, propertyKeys) {
2225
2225
  return types2.map((type) => PickResolve(type, propertyKeys));
2226
2226
  }
@@ -2254,7 +2254,7 @@ function Pick(type, key, options) {
2254
2254
  return IsMappedResult(type) ? PickFromMappedResult(type, propertyKeys, options) : IsMappedKey(key) ? PickFromMappedKey(type, key, options) : isTypeRef && isKeyRef ? Computed("Pick", [type, typeKey], options) : !isTypeRef && isKeyRef ? Computed("Pick", [type, typeKey], options) : isTypeRef && !isKeyRef ? Computed("Pick", [type, typeKey], options) : CreateType({ ...PickResolve(type, propertyKeys), ...options });
2255
2255
  }
2256
2256
 
2257
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/pick/pick-from-mapped-key.mjs
2257
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/pick/pick-from-mapped-key.mjs
2258
2258
  function FromPropertyKey3(type, key, options) {
2259
2259
  return {
2260
2260
  [key]: Pick(type, [key], Clone(options))
@@ -2273,7 +2273,7 @@ function PickFromMappedKey(type, mappedKey, options) {
2273
2273
  return MappedResult(properties);
2274
2274
  }
2275
2275
 
2276
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/partial/partial.mjs
2276
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/partial/partial.mjs
2277
2277
  function FromComputed3(target, parameters) {
2278
2278
  return Computed("Partial", [Computed(target, parameters)]);
2279
2279
  }
@@ -2314,7 +2314,7 @@ function Partial(type, options) {
2314
2314
  }
2315
2315
  }
2316
2316
 
2317
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/partial/partial-from-mapped-result.mjs
2317
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/partial/partial-from-mapped-result.mjs
2318
2318
  function FromProperties17(K, options) {
2319
2319
  const Acc = {};
2320
2320
  for (const K2 of globalThis.Object.getOwnPropertyNames(K))
@@ -2329,7 +2329,7 @@ function PartialFromMappedResult(R, options) {
2329
2329
  return MappedResult(P);
2330
2330
  }
2331
2331
 
2332
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/required/required.mjs
2332
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/required/required.mjs
2333
2333
  function FromComputed4(target, parameters) {
2334
2334
  return Computed("Required", [Computed(target, parameters)]);
2335
2335
  }
@@ -2370,7 +2370,7 @@ function Required(type, options) {
2370
2370
  }
2371
2371
  }
2372
2372
 
2373
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/required/required-from-mapped-result.mjs
2373
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/required/required-from-mapped-result.mjs
2374
2374
  function FromProperties19(P, options) {
2375
2375
  const Acc = {};
2376
2376
  for (const K2 of globalThis.Object.getOwnPropertyNames(P))
@@ -2385,7 +2385,7 @@ function RequiredFromMappedResult(R, options) {
2385
2385
  return MappedResult(P);
2386
2386
  }
2387
2387
 
2388
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/module/compute.mjs
2388
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/module/compute.mjs
2389
2389
  function DereferenceParameters(moduleProperties, types2) {
2390
2390
  return types2.map((type) => {
2391
2391
  return IsRef(type) ? Dereference(moduleProperties, type.$ref) : FromType2(moduleProperties, type);
@@ -2481,7 +2481,7 @@ function ComputeModuleProperties(moduleProperties) {
2481
2481
  }, {});
2482
2482
  }
2483
2483
 
2484
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/module/module.mjs
2484
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/module/module.mjs
2485
2485
  var TModule = class {
2486
2486
  constructor($defs) {
2487
2487
  const computed = ComputeModuleProperties($defs);
@@ -2504,17 +2504,17 @@ function Module(properties) {
2504
2504
  return new TModule(properties);
2505
2505
  }
2506
2506
 
2507
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/not/not.mjs
2507
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/not/not.mjs
2508
2508
  function Not(type, options) {
2509
2509
  return CreateType({ [Kind]: "Not", not: type }, options);
2510
2510
  }
2511
2511
 
2512
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/parameters/parameters.mjs
2512
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/parameters/parameters.mjs
2513
2513
  function Parameters(schema, options) {
2514
2514
  return IsFunction2(schema) ? Tuple(schema.parameters, options) : Never();
2515
2515
  }
2516
2516
 
2517
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/recursive/recursive.mjs
2517
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/recursive/recursive.mjs
2518
2518
  var Ordinal = 0;
2519
2519
  function Recursive(callback, options = {}) {
2520
2520
  if (IsUndefined(options.$id))
@@ -2524,13 +2524,13 @@ function Recursive(callback, options = {}) {
2524
2524
  return CreateType({ [Hint]: "Recursive", ...thisType }, options);
2525
2525
  }
2526
2526
 
2527
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/regexp/regexp.mjs
2527
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/regexp/regexp.mjs
2528
2528
  function RegExp2(unresolved, options) {
2529
2529
  const expr = IsString(unresolved) ? new globalThis.RegExp(unresolved) : unresolved;
2530
2530
  return CreateType({ [Kind]: "RegExp", type: "RegExp", source: expr.source, flags: expr.flags }, options);
2531
2531
  }
2532
2532
 
2533
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/rest/rest.mjs
2533
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/rest/rest.mjs
2534
2534
  function RestResolve(T) {
2535
2535
  return IsIntersect(T) ? T.allOf : IsUnion(T) ? T.anyOf : IsTuple(T) ? T.items ?? [] : [];
2536
2536
  }
@@ -2538,12 +2538,12 @@ function Rest(T) {
2538
2538
  return RestResolve(T);
2539
2539
  }
2540
2540
 
2541
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/return-type/return-type.mjs
2541
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/return-type/return-type.mjs
2542
2542
  function ReturnType(schema, options) {
2543
2543
  return IsFunction2(schema) ? CreateType(schema.returns, options) : Never(options);
2544
2544
  }
2545
2545
 
2546
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/transform/transform.mjs
2546
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/transform/transform.mjs
2547
2547
  var TransformDecodeBuilder = class {
2548
2548
  constructor(schema) {
2549
2549
  this.schema = schema;
@@ -2575,17 +2575,17 @@ function Transform(schema) {
2575
2575
  return new TransformDecodeBuilder(schema);
2576
2576
  }
2577
2577
 
2578
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/unsafe/unsafe.mjs
2578
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/unsafe/unsafe.mjs
2579
2579
  function Unsafe(options = {}) {
2580
2580
  return CreateType({ [Kind]: options[Kind] ?? "Unsafe" }, options);
2581
2581
  }
2582
2582
 
2583
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/void/void.mjs
2583
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/void/void.mjs
2584
2584
  function Void(options) {
2585
2585
  return CreateType({ [Kind]: "Void", type: "void" }, options);
2586
2586
  }
2587
2587
 
2588
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/type/type.mjs
2588
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/type/type.mjs
2589
2589
  var type_exports3 = {};
2590
2590
  __export(type_exports3, {
2591
2591
  Any: () => Any,
@@ -2652,7 +2652,7 @@ __export(type_exports3, {
2652
2652
  Void: () => Void
2653
2653
  });
2654
2654
 
2655
- // ../../node_modules/.pnpm/@sinclair+typebox@0.34.49/node_modules/@sinclair/typebox/build/esm/type/type/index.mjs
2655
+ // ../../node_modules/.pnpm/@sinclair+typebox@0.34.52/node_modules/@sinclair/typebox/build/esm/type/type/index.mjs
2656
2656
  var Type = type_exports3;
2657
2657
 
2658
2658
  // ../feature-configs/src/hooks.ts
@@ -3383,6 +3383,9 @@ var PublicAssetsConfigSchema = Type.Object({
3383
3383
  ))
3384
3384
  });
3385
3385
 
3386
+ // ../feature-configs/src/geminiThinking.ts
3387
+ var AI_THINKING_LEVELS = ["minimal", "low", "medium", "high"];
3388
+
3386
3389
  // ../feature-configs/src/canonicalJson.ts
3387
3390
  function canonicalJson(v) {
3388
3391
  if (Array.isArray(v)) return `[${v.map((x) => canonicalJson(x) ?? "null").join(",")}]`;
@@ -3963,8 +3966,8 @@ var FilesConfigSchema = Type.Object({
3963
3966
  // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute.
3964
3967
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
3965
3968
  // NO default (2026-10-04): UNSET means "the plan's storage ceiling"
3966
- // (control-plane plans.ts FILES_MAX_TOTAL_BYTES_BY_TIER — Free/Developer
3967
- // 10 GiB, Team 50 GiB, Business 256 GiB, Enterprise 1 TiB), resolved by
3969
+ // (control-plane plans.ts FILES_MAX_TOTAL_BYTES_BY_TIER — Free 10 GiB,
3970
+ // Developer 25 GiB, Team 150 GiB, Business 256 GiB, Enterprise 1 TiB), resolved by
3968
3971
  // files-v1 where the quota is enforced, so a plan change applies at once.
3969
3972
  // A value is the project's own LOWER cap (above the plan → 422 at config
3970
3973
  // write; a downgrade clamps it). It used to default to 10 GiB on every
@@ -4469,11 +4472,18 @@ var AiConfigSchema = Type.Object({
4469
4472
  openaiBaseUrl: Type.Optional(Type.String({ maxLength: 512 }))
4470
4473
  }))
4471
4474
  }, { default: {} }),
4472
- defaults: Type.Object({
4475
+ // `defaults` became an OPTIONAL bag (3 leaves → 1, the `streaming` trick) on
4476
+ // 2026-10-10 to carry `thinkingLevel` (AI-THINKING-LEVEL). It KEEPS
4477
+ // `default: {}`, so Value.Default still materializes
4478
+ // defaults.{model,maxTokens,temperature} exactly as before; `thinkingLevel`
4479
+ // has NO default (absent = the model's own default), so an existing manifest
4480
+ // stays byte-identical. Sent to Gemini only; validated per model below.
4481
+ defaults: Type.Optional(Type.Object({
4473
4482
  model: Type.String({ default: "mock-1" }),
4474
4483
  maxTokens: Type.Integer({ default: 1024, minimum: 1, maximum: 2e5 }),
4475
- temperature: Type.Number({ default: 0.7, minimum: 0, maximum: 2 })
4476
- }, { default: {} }),
4484
+ temperature: Type.Number({ default: 0.7, minimum: 0, maximum: 2 }),
4485
+ thinkingLevel: Type.Optional(Type.Union(AI_THINKING_LEVELS.map((l) => Type.Literal(l))))
4486
+ }, { default: {} })),
4477
4487
  cache: Type.Object({ ttlSeconds: Type.Integer({ default: 0, minimum: 0 }) }, { default: {} }),
4478
4488
  // 0 = off
4479
4489
  limits: Type.Object({
@@ -5065,14 +5075,16 @@ function lowerAllTriggerBindings(def, name) {
5065
5075
  return bindings;
5066
5076
  }
5067
5077
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
5068
- var VXIL_CONFIG_PKG_VERSION = "0.13.0";
5069
- var VXIL_SDK_PKG_VERSION = "0.20.1";
5070
- var VXIL_CLI_PKG_VERSION = "0.20.1";
5078
+ var VXIL_CONFIG_PKG_VERSION = "0.14.0";
5079
+ var VXIL_SDK_PKG_VERSION = "0.21.0";
5080
+ var VXIL_CLI_PKG_VERSION = "0.21.0";
5071
5081
  function ensureScaffoldPackageJson(cwd, opts = {}) {
5072
5082
  const file = resolve(cwd, "package.json");
5073
5083
  const wanted = {
5074
5084
  "@vxil/config": `^${VXIL_CONFIG_PKG_VERSION}`,
5075
- ...opts.sdkTypes ? { "@vxil/sdk": `^${VXIL_SDK_PKG_VERSION}` } : {}
5085
+ ...opts.sdkTypes ? { "@vxil/sdk": `^${VXIL_SDK_PKG_VERSION}` } : {},
5086
+ // the scaffolded tsconfig.json names `types: ["node"]` (CLI-TS-SCAFFOLD)
5087
+ ...opts.nodeTypes ? { "@types/node": SCAFFOLD_NODE_TYPES_RANGE } : {}
5076
5088
  };
5077
5089
  if (!existsSync(file)) {
5078
5090
  writeFileSync(file, JSON.stringify({
@@ -5102,6 +5114,24 @@ function ensureScaffoldPackageJson(cwd, opts = {}) {
5102
5114
  writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n");
5103
5115
  return { action: "added" };
5104
5116
  }
5117
+ var SCAFFOLD_NODE_TYPES_RANGE = "^24.0.0";
5118
+ var SCAFFOLD_TSCONFIG = {
5119
+ compilerOptions: {
5120
+ target: "ES2022",
5121
+ module: "ESNext",
5122
+ moduleResolution: "bundler",
5123
+ strict: true,
5124
+ skipLibCheck: true,
5125
+ noEmit: true,
5126
+ types: ["node"]
5127
+ }
5128
+ };
5129
+ function ensureScaffoldTsconfig(cwd) {
5130
+ const file = resolve(cwd, "tsconfig.json");
5131
+ if (existsSync(file)) return "exists";
5132
+ writeFileSync(file, JSON.stringify(SCAFFOLD_TSCONFIG, null, 2) + "\n");
5133
+ return "created";
5134
+ }
5105
5135
  function apiErrText(r) {
5106
5136
  const e = r.body.error;
5107
5137
  if (!e) return `${r.status}`;
@@ -5353,6 +5383,49 @@ async function serverApply({ api, bundle, resume, allowDestructive, onPlan }) {
5353
5383
  );
5354
5384
  }
5355
5385
  var PUSH_READ_CONCURRENCY = 5;
5386
+ var CMS_ENABLE_MANIFEST = { enabled: true };
5387
+ function cmsEnableManifest(declared) {
5388
+ return declared.limits !== void 0 ? { ...CMS_ENABLE_MANIFEST, limits: declared.limits } : { ...CMS_ENABLE_MANIFEST };
5389
+ }
5390
+ async function enableCmsBeforeSchema({ api, cfg }) {
5391
+ const declared = cfg.features?.cms;
5392
+ if (!Object.keys(cfg.cms?.collections ?? {}).length || !declared || declared.enabled === false) return null;
5393
+ const cur = await api("GET", "/v1/config/cms");
5394
+ if (classifyRead(cur.status, cur.body?.error?.code) !== "empty") return null;
5395
+ const dry = await api("PUT", "/v1/config/cms?dry_run=true", declared);
5396
+ if (dry.status !== 200) {
5397
+ const e = dry.body.error ?? {};
5398
+ throw new Error(`invalid config for cms: ${e.code ?? dry.status} ${e.message ?? ""} ${e.hint ?? ""}`.trim() + "\n(nothing was pushed)");
5399
+ }
5400
+ const put = await api("PUT", "/v1/config/cms", cmsEnableManifest(declared));
5401
+ if (put.status !== 200) {
5402
+ const e = put.body.error ?? {};
5403
+ throw new Error(`cms: could not enable cms before its schema: ${e.code ?? put.status} ${e.message ?? ""} ${e.hint ?? ""}`.trim());
5404
+ }
5405
+ return put.body.data?.version ?? 0;
5406
+ }
5407
+ var CMS_PROPAGATION_ATTEMPTS = 4;
5408
+ var CMS_PROPAGATION_DELAY_MS = 1e4;
5409
+ function retryWhileCmsPropagates(api, opts = {}) {
5410
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
5411
+ const attempts = opts.attempts ?? CMS_PROPAGATION_ATTEMPTS;
5412
+ const delayMs = opts.delayMs ?? CMS_PROPAGATION_DELAY_MS;
5413
+ let waited = 0;
5414
+ return async (method, path, body) => {
5415
+ for (; ; ) {
5416
+ const res = await api(method, path, body);
5417
+ const propagating = path.startsWith("/v1/cms/") && res.status === 501 && (res.body?.error?.code ?? "capability_not_enabled") === "capability_not_enabled";
5418
+ if (!propagating || waited >= attempts) return res;
5419
+ waited++;
5420
+ opts.onWait?.(` \u2026 cms config propagating (just enabled) \u2014 retrying in ${Math.round(delayMs / 1e3)} s (${waited}/${attempts})`);
5421
+ await sleep(delayMs);
5422
+ }
5423
+ };
5424
+ }
5425
+ function onlyPropagationDeferred(steps) {
5426
+ const errored = steps.filter((st) => st.status === "pending" && st.error);
5427
+ return errored.length > 0 && errored.every((st) => (st.kind === "api-state" || st.kind === "cms.collection" || st.kind === "cms.field") && (st.error ?? "").startsWith("deferred:"));
5428
+ }
5356
5429
  async function planOrPush({ api, features, apply, allowDestructive }) {
5357
5430
  const results = [];
5358
5431
  const destructiveQs = allowDestructive ? "&allow_destructive=true" : "";
@@ -9561,7 +9634,7 @@ var API_CHEATSHEET = [
9561
9634
  { 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>` },
9562
9635
  { 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>` },
9563
9636
  // feeds
9564
- { 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"}'` },
9637
+ { feature: "activity-feed", what: "block or mute a feed for a user", cmd: `vxil api POST /v1/feeds/blocks '{"owner":"timeline:<reader_id>","blocked":"user:<author_id>","mode":"block"}'` },
9565
9638
  { feature: "activity-feed", what: "remove an activity", cmd: "vxil api DELETE /v1/feeds/<group>/<feed_id>/activities/<ref>" }
9566
9639
  ];
9567
9640
  function apiCheatSheet() {
@@ -11876,7 +11949,9 @@ function genInputFromConfig(cfg) {
11876
11949
  }
11877
11950
  function typesCheckBody(src) {
11878
11951
  const i = src.indexOf("export interface VxilSchema");
11879
- return i >= 0 ? src.slice(i) : src;
11952
+ const body = i >= 0 ? src.slice(i) : src;
11953
+ const usage = body.indexOf("\n// Usage \u2014 ");
11954
+ return usage >= 0 ? body.slice(0, usage) : body;
11880
11955
  }
11881
11956
  function checkGeneratedTypes(existing, generated, opts) {
11882
11957
  let have = typesCheckBody(withCurrentApiVersionsHead(existing));
@@ -11947,7 +12022,7 @@ export type VxilFilterJson = string | number | boolean | { $eq?: string | number
11947
12022
  // Usage \u2014 narrow the client to THIS tenant's enabled features (a disabled feature
11948
12023
  // becomes a compile error):
11949
12024
  // import { Vxil } from '@vxil/sdk';
11950
- // import type { VxilSchema } from './vxil.types';
12025
+ // import type { VxilSchema } from './vxil.types.js';
11951
12026
  // const vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });
11952
12027
  // await vx.from('${colls[0]?.collection ?? "collection"}').create({ /* field-typed */ });
11953
12028
  `;
@@ -13552,7 +13627,7 @@ var TOOLS = [
13552
13627
  {
13553
13628
  name: "search_query",
13554
13629
  feature: "vector-search",
13555
- description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object of equality constraints over chunk metadata. Returns ranked results with document/chunk attribution: score is the fused rank value (orders the hits, not a similarity); similarity is the cosine similarity to the query (-1..1, null in keyword mode) \u2014 threshold on similarity.",
13630
+ description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object of equality constraints over chunk metadata. Returns ranked results with document/chunk attribution: score is the fused rank value (orders the hits, not a similarity); similarity is the cosine similarity to the query (-1..1, null in keyword mode) \u2014 threshold on similarity. embed_provider names the collection's embedder; 'mock' is the deterministic test embedder (ranks by a hash, not meaning), so tell the user search is not semantic until a real provider is set.",
13556
13631
  inputSchema: {
13557
13632
  type: "object",
13558
13633
  properties: {
@@ -13582,6 +13657,11 @@ var TOOLS = [
13582
13657
  model: { type: "string" },
13583
13658
  max_tokens: { type: "number", minimum: 1, maximum: 2e5 },
13584
13659
  temperature: { type: "number", minimum: 0, maximum: 2 },
13660
+ thinking_level: {
13661
+ type: "string",
13662
+ enum: ["minimal", "low", "medium", "high"],
13663
+ description: "Gemini thinking depth (ignored by other providers). Omitted: the tenant's defaults.thinkingLevel, then the model default. 'minimal' on a model that rejects it is a 422 thinking_level_unsupported. Thinking tokens bill as output."
13664
+ },
13585
13665
  json_mode: { type: "boolean", description: "Constrain the output to JSON (no schema \u2014 use response_schema for a guaranteed shape)." },
13586
13666
  response_schema: {
13587
13667
  type: "object",
@@ -13712,7 +13792,7 @@ var TOOLS = [
13712
13792
  {
13713
13793
  name: "rag_answer",
13714
13794
  feature: "rag",
13715
- description: "Answer a question grounded in a vector-search collection: retrieve \u2192 budget context \u2192 generate \u2192 cite. Defaults the collection/template/top_k/mode from the rag config; override per call. Returns the answer plus citations to the source chunks that grounded it.",
13795
+ description: "Answer a question grounded in a vector-search collection: retrieve \u2192 budget context \u2192 generate \u2192 cite. Defaults the collection/template/top_k/mode from the rag config; override per call. Returns the answer plus citations to the source chunks that grounded it, and embed_provider (the collection's embedder; 'mock' = the deterministic test embedder, so retrieval is not semantic yet). The collection must exist first (POST /v1/search/collections, SDK vx.search.createCollection); a missing one fails retrieval with upstream collection_not_found.",
13716
13796
  inputSchema: {
13717
13797
  type: "object",
13718
13798
  properties: {
@@ -13736,7 +13816,7 @@ var TOOLS = [
13736
13816
  {
13737
13817
  name: "rag_search",
13738
13818
  feature: "rag",
13739
- description: "Retrieval-only grounding preview: the exact chunks rag_answer would ground on, with rerank + metadata boosts applied \u2014 no generation, no token spend. Use it to inspect/tune retrieval (boost weights, rerank, min_similarity) before paying for an answer.",
13819
+ description: "Retrieval-only grounding preview: the exact chunks rag_answer would ground on, with rerank + metadata boosts applied \u2014 no generation, no token spend. Use it to inspect/tune retrieval (boost weights, rerank, min_similarity) before paying for an answer. embed_provider names the collection's embedder (mock = the deterministic test embedder, not semantic).",
13740
13820
  inputSchema: {
13741
13821
  type: "object",
13742
13822
  properties: {
@@ -13882,7 +13962,7 @@ var TOOLS = [
13882
13962
  // Substrate tool (no `feature` — always-on like users_upsert): usage is a
13883
13963
  // platform property, not a feature you enable.
13884
13964
  name: "usage_current",
13885
- description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers are never cut off mid-month. Needs usage:read or features:read.",
13965
+ description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only), `files` (live private objects and bytes vs the storage `limit` uploads are held to \u2014 422 quota_exceeded at it) and `functions` (today's UTC call count vs the plan's `limit_per_day`; a snapshot that can trail by a minute or two, null when there is no snapshot for today). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers are never cut off mid-month. Needs usage:read or features:read.",
13886
13966
  inputSchema: { type: "object", properties: {} },
13887
13967
  method: "GET",
13888
13968
  path: "/v1/usage"
@@ -14440,6 +14520,21 @@ function notificationsProviderChecks(manifest) {
14440
14520
  }
14441
14521
  return [{ name: "notifications provider", ok: true, warn: true, detail: MOCK_PROVIDER_NOTE }];
14442
14522
  }
14523
+ var EMBED_MOCK_NOTE = "vector-search embed provider is 'mock': the deterministic TEST embedder ranks by a hash of the text, not by meaning, so search and rag results look real but are not semantic (responses carry embed_provider: 'mock'). Store a provider key (`vxil secrets set vector-search/<name>`) and set embed: { provider: 'openai' | 'cohere', apiKeyRef } in your vector-search config (or 'byov' to supply your own vectors) before you ship";
14524
+ function embedProviderChecks(manifest, collections) {
14525
+ if (!manifest || manifest.enabled === false) return [];
14526
+ const def = manifest.embed?.provider ?? "mock";
14527
+ const mockCols = (collections ?? []).filter((c) => (c.provider ?? def) === "mock").map((c) => c.collection);
14528
+ if (def !== "mock" && !mockCols.length) {
14529
+ return [{ name: "vector-search embed provider", ok: true, detail: def }];
14530
+ }
14531
+ return [{
14532
+ name: "vector-search embed provider",
14533
+ ok: true,
14534
+ warn: true,
14535
+ detail: EMBED_MOCK_NOTE + (mockCols.length ? `. Collections on 'mock': ${mockCols.join(", ")} \u2014 a collection keeps the provider it was created with: create it again with the real embed provider (same name and dimensions) and re-ingest its documents` : "")
14536
+ }];
14537
+ }
14443
14538
  var PAYMENTS_WEBHOOK_SECRET_NOTE = "payments provider is real but no per-tenant webhook secret is declared: every inbound provider delivery will be rejected 401 bad_signature and recorded as a sig_failed row (those rows can never be reprocessed, so the money events behind them are lost). There is NO platform-wide fallback. Declare <provider>.webhookSecretRef in your payments config and store the value with `vxil secrets set payments/<name>`";
14444
14539
  function paymentsWebhookSecretChecks(manifest) {
14445
14540
  if (!manifest || manifest.enabled === false) return [];
@@ -15219,10 +15314,10 @@ function renderTryReport(result, opts) {
15219
15314
  if (genOk) {
15220
15315
  lines.push("\n try it (types were generated in ./vxil.types.ts):");
15221
15316
  lines.push(" import { Vxil } from '@vxil/sdk';");
15222
- lines.push(" import type { VxilSchema } from './vxil.types';");
15317
+ lines.push(" import type { VxilSchema } from './vxil.types.js';");
15223
15318
  lines.push(` const vx = Vxil.connect<VxilSchema>({ apiKey: '${result.api_key}', baseUrl: '${opts.base}' });`);
15224
15319
  } else {
15225
- lines.push("\n try it (typed client skipped \u2014 `npm i -D vxil && npm i @vxil/sdk`, then `npx vxil gen`):");
15320
+ lines.push("\n try it (typed client skipped \u2014 `npm i -D @vxil/cli && npm i @vxil/sdk`, then `npx vxil gen`):");
15226
15321
  lines.push(" import { Vxil } from '@vxil/sdk';");
15227
15322
  lines.push(` const vx = new Vxil({ apiKey: '${result.api_key}', baseUrl: '${opts.base}' });`);
15228
15323
  }
@@ -18609,8 +18704,8 @@ var TEMPLATE_CATALOG = [
18609
18704
  ],
18610
18705
  "hasFunctions": false,
18611
18706
  "byoKeys": [],
18612
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Blog\" \u2014 a publishing / headless-CMS backend (authors, categories, posts with\n// a draft\u2192publish lifecycle), declared end-to-end in ONE typed file. A BLUEPRINT\n// composing shipped building blocks \u2014\n// \u2022 cms \u2192 authors \u2192 categories \u2192 posts (resolved by relation)\n// \u2022 comments \u2192 threaded reader comments on posts\n// Everything here is DATA the tenant owns and edits after `vxil init`. The\n// editorial workflow migrates ~100%; the public reader tier is SHIPPED \u2014 `posts`\n// is `public: true`, served keyless over the cms public-delivery lane (guide ch. 4, public delivery).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // the editorial lifecycle: write as draft, publish live\n hooks: {\n // Every post needs a title \u2014 a pure function of the row (Lane-A validate).\n post_title: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'a post needs a title',\n },\n },\n },\n comments: {}, // threaded reader comments on published posts\n notifications: { provider: 'mock', fromEmail: 'noreply@blog.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n authors: {\n singular: 'author',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n bio: { type: 'text' },\n },\n },\n categories: {\n singular: 'category',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n posts: {\n singular: 'post',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): published posts are readable with NO API\n // key over GET /v1/cms/public/:tenantId/posts \u2014 edge-cached, drafts never\n // served. This is the reader tier of a blog: a static/JAMstack front-end\n // (or the served `listCmsPublic()` SDK helper) fetches the feed anonymously.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n excerpt: { type: 'text' },\n body: { type: 'text' },\n author: { type: 'relation', relationTo: 'authors', indexSlot: 's3' },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's4' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n reading_minutes: { type: 'int', indexSlot: 'n1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'authors',\n items: [{ name: 'Ada Lovelace', slug: 'ada', bio: 'Writes about computing.' }],\n },\n ],\n },\n});\n",
18613
- "readme": "# Blog / Publishing template\n\nA publishing / headless-CMS backend \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `authors` \u2014 name, unique slug, bio.\n- `categories` \u2014 name, unique slug.\n- `posts` \u2014 title, unique slug, excerpt, body, `author` + `category` relations (slot-bound for filtering),\n `published_at`, `reading_minutes`. A Lane-A hook requires a title, and `draftPublish` gives you the\n write-as-draft \u2192 publish-live editorial lifecycle. **`public: true`** \u2014 published posts are served over\n the keyless public-delivery lane (see below).\n- `comments` \u2014 threaded reader comments on published posts.\n\n**Use it:**\n\n```bash\nvxil init --template blog # (the CLI resolves @vxil/config itself \u2014 no install needed for the push)\nvxil quickstart --env staging --invite <code> # only when the email is new\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The editorial lifecycle is config** \u2014 `draftPublish: true` gives write-as-draft \u2192 `publish`; readers filter\n with the reserved `$status` key (vxil.com/docs/guide/04-data-with-cms).\n- **Slot-bound relations are the join declaration** \u2014 `posts.author`/`posts.category` ride `s3`/`s4`, so a feed\n filters by author directly, or reaches one hop into the target: `?filter={\"category.slug\":\"news\"}` is the\n single-hop dotted-key join (guide ch. 4, relational depth).\n- **Invariants ride as tenant-owned Lane-A hooks** \u2014 the \"posts need a title\" rule is data in YOUR config, not\n platform code (guide ch. 7).\n\n```ts\n// the reader feed with a server key: published posts, newest first\nconst { items, next_cursor } = await vx.from('posts').query({\n filter: { $status: 'published' }, sort: '-published_at', limit: 25,\n});\n```\n\n**The public reader tier \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` is `public: true`, a\nstatic/JAMstack front-end reads the feed with **no API key**: the edge mints a restricted read-only token,\nforces `status = 'published'` (drafts are never served), and edge-caches the response\n(`s-maxage=60`, `stale-while-revalidate`).\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public feed \u2014 NO api key, no Vxil client, no auth\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-published_at', limit: 25 });\n// one post by slug (a draft slug 404s to the reader)\nconst { items: [post] } = await listCmsPublic('ten_your_tenant_id', 'posts', { filter: { slug: 'hello' }, limit: 1 });\n```\n\n**Reader comments UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a post\npage for the threaded `comments` feature \u2014 a pure client-side component over the shipped comments API.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (comments) \xB7 `templates/docs-site/` (a pure public-content site) \xB7\n`templates/catalog/` (the same shape for products) \xB7 `examples/feedback-board/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
18707
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Blog\" \u2014 a publishing / headless-CMS backend (authors, categories, posts with\n// a draft\u2192publish lifecycle), declared end-to-end in ONE typed file. A BLUEPRINT\n// composing shipped building blocks \u2014\n// \u2022 cms \u2192 authors \u2192 categories \u2192 posts (resolved by relation)\n// \u2022 comments \u2192 threaded reader comments on posts\n// Everything here is DATA the tenant owns and edits after `vxil init`. The\n// editorial workflow migrates ~100%; the public reader tier is SHIPPED \u2014 `posts`\n// is `public: true`, served keyless over the cms public-delivery lane (guide ch. 4, public delivery).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // the editorial lifecycle: write as draft, publish live\n hooks: {\n // Every post needs a title \u2014 a pure function of the row (Lane-A validate).\n post_title: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'a post needs a title',\n },\n },\n },\n comments: {}, // threaded reader comments on published posts\n notifications: { provider: 'mock', fromEmail: 'noreply@blog.example' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n authors: {\n singular: 'author',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n bio: { type: 'text' },\n },\n },\n categories: {\n singular: 'category',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n posts: {\n singular: 'post',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): published posts are readable with NO API\n // key over GET /v1/cms/public/:tenantId/posts \u2014 edge-cached, drafts never\n // served. This is the reader tier of a blog: a static/JAMstack front-end\n // (or the served `listCmsPublic()` SDK helper) fetches the feed anonymously.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n excerpt: { type: 'text' },\n body: { type: 'text' },\n author: { type: 'relation', relationTo: 'authors', indexSlot: 's3' },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's4' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n reading_minutes: { type: 'int', indexSlot: 'n1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'authors',\n items: [{ name: 'Ada Lovelace', slug: 'ada', bio: 'Writes about computing.' }],\n },\n ],\n },\n});\n",
18708
+ "readme": '# Blog / Publishing template\n\nA publishing / headless-CMS backend \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `authors` \u2014 name, unique slug, bio.\n- `categories` \u2014 name, unique slug.\n- `posts` \u2014 title, unique slug, excerpt, body, `author` + `category` relations (slot-bound for filtering),\n `published_at`, `reading_minutes`. A Lane-A hook requires a title, and `draftPublish` gives you the\n write-as-draft \u2192 publish-live editorial lifecycle. **`public: true`** \u2014 published posts are served over\n the keyless public-delivery lane (see below).\n- The **`comments` feature** (a feature, not a collection: the pushed schema has three collections) \u2014\n threaded reader comments on any post, keyed by the post\'s `item_id` through `/v1/comments`.\n\n**Use it:**\n\n```bash\nmkdir my-blog && cd my-blog\nvxil init --template blog # (the CLI resolves @vxil/config itself \u2014 no install needed for the push)\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\nvxil seed # one author, Ada (as a draft)\n```\n\n**See it work.** Write one post straight to `published`, then read it with no key at all:\n\n```bash\nvxil api POST /v1/cms/items/posts --data \'{"data":{"title":"Hello","slug":"hello","excerpt":"My first post.","published_at":"2026-10-01T09:00:00Z"},"status":"published"}\'\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_TENANT_ID (and your key) into the shell\ncurl "https://api.vxil.com/v1/cms/public/$VXIL_TENANT_ID/posts?sort=-published_at"\n# \u2192 { "data": { "items": [ { "item_id": "\u2026", "data": { "title": "Hello", \u2026 } } ], "next_cursor": null } }\n```\n\nA post written without `"status":"published"` is a draft: publish it with\n`vxil api POST /v1/cms/items/posts/<item_id>/publish` or the dashboard\'s Publish button. The public lane\ncaches for up to 60 seconds. After you edit `vxil.config.ts`, `vxil push` applies it and regenerates the types.\n\n**What to learn from this:**\n- **The editorial lifecycle is config** \u2014 `draftPublish: true` gives write-as-draft \u2192 `publish`; readers filter\n with the reserved `$status` key (vxil.com/docs/guide/04-data-with-cms).\n- **Slot-bound relations are the join declaration** \u2014 `posts.author`/`posts.category` ride `s3`/`s4`, so a feed\n filters by author directly, or reaches one hop into the target: `?filter={"category.slug":"news"}` is the\n single-hop dotted-key join on the keyed list (guide ch. 4, relational depth). The public lane does not take\n dotted keys; there, look the category up by slug and filter posts by its `item_id`.\n- **Invariants ride as tenant-owned Lane-A hooks** \u2014 the "posts need a title" rule is data in YOUR config, not\n platform code (guide ch. 7).\n\n```ts\n// the reader feed with a server key (`vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! })):\n// published posts, newest first\nconst { items, next_cursor } = await vx.from(\'posts\').query({\n filter: { $status: \'published\' }, sort: \'-published_at\', limit: 25,\n});\n```\n\n**The public reader tier \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` is `public: true`, a\nstatic/JAMstack front-end reads the feed with **no API key**: readers get published rows only (drafts are\nnever served), read-only, and the response is edge-cached (`s-maxage=60`, `stale-while-revalidate`).\n\n```ts\nimport { listCmsPublic } from \'https://vxil.com/sdk.mjs\';\n// the public feed \u2014 NO api key, no Vxil client, no auth\nconst TENANT_ID = \'00000000-0000-0000-0000-000000000000\'; // yours: the `tenant:` line quickstart printed\nconst { items } = await listCmsPublic(TENANT_ID, \'posts\', { sort: \'-published_at\', limit: 25 });\n// one post by slug (a draft slug is simply not there: an empty `items`)\nconst { items: [post] } = await listCmsPublic(TENANT_ID, \'posts\', { filter: { slug: \'hello\' }, limit: 1 });\n```\n\n**Reader comments UI (optional):** drop **`@vxil/react/comments`** (`npm i @vxil/react @vxil/sdk`) onto a post\npage for the threaded `comments` feature \u2014 a pure client-side component over the shipped comments API.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (comments) \xB7 the **docs-site** blueprint (a pure public-content site) \xB7\nthe **catalog** blueprint (the same shape for products) \xB7 the **directory** blueprint (a public listing with a Lane-A hook).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
18614
18709
  "functions": {}
18615
18710
  },
18616
18711
  {
@@ -18631,8 +18726,8 @@ var TEMPLATE_CATALOG = [
18631
18726
  ],
18632
18727
  "hasFunctions": false,
18633
18728
  "byoKeys": [],
18634
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Microblog\" \u2014 a Twitter-style micro-blogging backend (profiles, 280-char\n// posts, follows, likes, a realtime live feed), declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 profiles \u2192 posts (by relation) + follows/likes edge rows\n// \u2022 auth \u2192 accounts, so a poster is a VERIFIED end-user\n// \u2022 realtime \u2192 the live feed channel (posts fan out via the cms `cdc` bridge)\n// Every collection declares an end-user OWNER field, so from a thin client a\n// signed-in user can only write their OWN rows; tenant-wide reads (the global\n// timeline, follower counts) are served by YOUR backend with a server key \u2014\n// owner-scoping is a no-op for server callers (vxil.com/docs/guide/04-data-with-cms).\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // create posts with `status: 'published'` \u2014 no editorial step\n // Fail-safe (guide ch. 4, ownerField): a verified end-user key may only touch\n // collections that declare an ownerField. Every collection below does;\n // any collection you ADD later without one is denied to end-user keys\n // instead of silently shared tenant-wide. Server keys are unaffected.\n strictEndUserScope: true,\n // Lane-A safe-expression hooks \u2014 AST-validated at push time, run inside\n // the write transaction (guide ch. 7). NOTE: hooks do NOT run on `$inc`\n // (guide ch. 4, uniqueness) \u2014 likes_count is guarded by its own validation.min instead.\n hooks: {\n post_body_nonempty: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a post cannot be empty',\n },\n post_body_280: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.body) <= 280',\n message: 'a post is at most 280 characters',\n },\n // Field-vs-field comparison is grammar-legal (guide ch. 7 operators over item.*).\n follow_not_self: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.follower != item.followee',\n message: 'you cannot follow yourself',\n },\n // COMPOSED-KEY integrity: `pair` is written by the client/SDK as\n // follower + ':' + followee (see the field comment). This hook makes\n // that convention server-enforced, so the `unique: true` claim on\n // `pair` really means \"at most one follow edge per (follower,\n // followee)\" \u2014 a double-follow is a clean 409 unique_violation.\n follow_pair: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.follower, ':', item.followee)\",\n message: \"pair must be follower + ':' + followee\",\n },\n like_pair: {\n collection: 'likes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.post, ':', item.user_id)\",\n message: \"pair must be post + ':' + user_id\",\n },\n },\n // Realtime CDC bridge (guide ch. 4, keeping clients in sync): every NEW post auto-publishes a\n // `cms.item.created` / `.published` frame (this rule's two events \u2014\n // updates/deletes don't fire it) \u2014 full item data, \u226432KB \u2014 onto the\n // realtime channel below. The config-only live feed: at-most-once,\n // fire-and-forget (guaranteed delivery would use webhooks or functions).\n cdc: {\n feed_live: {\n collection: 'posts',\n channel: 'feed:global',\n events: ['created', 'published'],\n payload: 'full',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // posters sign in as end-users\n realtime: {}, // defaults are fine; a channel exists as soon as someone uses it\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n profiles: {\n singular: 'profile',\n // End-user owner-scope (guide ch. 4, ownerField): a signed-in user edits only their\n // OWN profile. `user_id` must be a real string field (declared below).\n ownerField: 'user_id',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): public profile pages read with NO API key\n // over GET /v1/cms/public/:tenantId/profiles \u2014 edge-cached, and the\n // `user_id` owner field is STRIPPED from every served row (an anonymous\n // reader never sees the end-user id). Owner-scoping (above) still governs\n // the authed WRITE lane; public delivery is a read-only, owner-unscoped tier.\n public: true,\n fields: {\n // Declarative uniqueness (guide ch. 4, uniqueness), carried by `vxil push`: N\n // racing claims of a handle yield exactly one 201, the rest a clean\n // 409 unique_violation \u2014 the insert IS the claim. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n bio: { type: 'text' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n },\n },\n posts: {\n singular: 'post',\n ownerField: 'user_id',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the GLOBAL TIMELINE served with NO API key\n // over GET /v1/cms/public/:tenantId/posts?sort=-posted_at \u2014 edge-cached,\n // published-only, `user_id` stripped from every row. This is exactly the\n // \"tenant-wide reads served by YOUR backend\" note above, but now keyless:\n // an anonymous visitor reads the public feed without your server key.\n public: true,\n fields: {\n body: { type: 'text', required: true }, // \u2264280 chars \u2014 enforced by the hooks above\n author: { type: 'relation', relationTo: 'profiles', indexSlot: 's1' },\n user_id: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n posted_at: { type: 'datetime', indexSlot: 't1' }, // slot t1 \u21D2 sort=-posted_at is index-served\n // Bumped atomically via PATCH {\"$inc\":{\"likes_count\":1}} (guide ch. 4, uniqueness);\n // min:0 turns a decrement below zero into a clean 409, never a race.\n likes_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n },\n },\n follows: {\n singular: 'follow',\n // Owner = the follower: an end-user creates/removes only their OWN edges.\n ownerField: 'follower',\n fields: {\n follower: { type: 'string', required: true, indexSlot: 's1' }, // end-user id\n followee: { type: 'string', required: true, indexSlot: 's2' }, // end-user id\n // COMPOSED KEY \u2014 cms `unique` is single-field, so composite uniqueness\n // is modeled by having the client/SDK write follower + ':' + followee\n // here; the `follow_pair` hook rejects a mismatched composition, and\n // `unique: true` (guide ch. 4, uniqueness) makes a double-follow a clean 409\n // unique_violation on a plain create \u2014 no lock/guard needed for\n // pair dedup. (lock+guard, guide ch. 4, stays the tool for count-invariants\n // BEYOND uniqueness \u2014 e.g. \"at most N\".)\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n likes: {\n singular: 'like',\n ownerField: 'user_id',\n fields: {\n post: { type: 'relation', relationTo: 'posts', required: true, indexSlot: 's1' },\n user_id: { type: 'string', required: true, indexSlot: 's2' }, // the owner (end-user) id\n // COMPOSED KEY \u2014 post + ':' + user_id, declared unique: one like per\n // user per post; a double-like is a clean 409 unique_violation.\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'profiles',\n items: [\n { handle: 'ada', display_name: 'Ada Lovelace', bio: 'Notes on engines, in 280 chars.', user_id: 'usr_demo_ada' },\n { handle: 'grace', display_name: 'Grace Hopper', bio: 'Compilers, ships, short posts.', user_id: 'usr_demo_grace' },\n ],\n },\n {\n // The `author` relation is omitted here: seed items are plain creates and\n // cannot reference the server-generated item_id of the profiles above \u2014\n // set it on posts your app creates at runtime. Seeded items land as\n // drafts; publish them from the dashboard, or create real posts with\n // `status: 'published'` (see README).\n collection: 'posts',\n items: [\n { body: 'Hello, world \u2014 first post on my own backend.', user_id: 'usr_demo_ada', posted_at: '2026-07-01T09:00:00Z', likes_count: 0 },\n { body: 'A microblog is just cms + auth + realtime in one config file.', user_id: 'usr_demo_grace', posted_at: '2026-07-01T09:05:00Z', likes_count: 0 },\n ],\n },\n ],\n },\n});\n",
18635
- "readme": "# Microblog / Social Feed template\n\nA Twitter-style micro-blogging backend \u2014 profiles, 280-character posts, follows, likes, and a realtime\nlive feed \u2014 declared end-to-end in one typed `vxil.config.ts`. Backend building blocks you enable in one\nline: `cms` holds the data (Lane-A hooks enforce the 280-char rule inside the write transaction), `auth`\nmakes every poster a verified end-user, and `realtime` streams new posts to every open client.\n\n**What it provisions:**\n- `profiles` \u2014 unique `handle`, display name, bio; owner-scoped by `user_id`. **`public: true`** \u2014 public\n profile pages read keyless (the `user_id` owner field is stripped from every served row).\n- `posts` \u2014 `body` (two validate hooks: non-empty, \u2264280), `author` relation \u2192 profiles, `posted_at`,\n atomic `likes_count`; owner-scoped by `user_id`. **`public: true`** \u2014 the global timeline reads keyless.\n- `follows` \u2014 `follower`/`followee` + a composed unique `pair` key; hooks block self-follows and a malformed pair.\n- `likes` \u2014 `post` relation + `user_id` + a composed unique `pair` key (one like per user per post).\n- Features: `cms` (with `strictEndUserScope` + a `cdc` live-feed rule), `auth` (email/password), `realtime`.\n\n**Apply it:**\n\n```bash\nvxil init --template microblog\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n1. **End-user owner scoping** (vxil.com/docs/guide/04-data-with-cms) \u2014 every collection names an `ownerField`, so a\n signed-in user can only write their OWN rows from a thin client; `strictEndUserScope: true` fail-closes\n any collection you later forget to scope. Server keys are unaffected \u2014 the global timeline and\n follower counts are served by your backend, where owner-scoping is a no-op.\n2. **Composed-key uniqueness** \u2014 cms `unique` is single-field, so \"unique (follower, followee)\" is modeled\n as a `pair` field the client writes as `follower + ':' + followee`; a validate hook enforces the\n composition, and the declarative `unique: true` on `pair` (in the config, carried by `vxil push` \u2014\n guide ch. 4, uniqueness) turns a double-follow/double-like into a clean `409 unique_violation` on a plain\n create \u2014 no lock needed. (`lock` + `guard` (guide ch. 4, preconditions and concurrency) remains the general tool for\n count-invariants BEYOND uniqueness, e.g. \"at most N seats/redemptions\".)\n3. **Atomic counters** (guide ch. 4, uniqueness) \u2014 `likes_count` bumps via `$inc`: ONE conditional UPDATE, no\n read-modify-write race; `validation.min: 0` refuses a decrement below zero.\n4. **Realtime live feed** (guide ch. 4, keeping clients in sync) \u2014 the `cdc` rule auto-publishes every new post (its\n `created`/`published` events) onto the `feed:global` channel; browsers subscribe with\n `@vxil/realtime` (vxil.com/docs/guide/06-feature-catalog: realtime).\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nimport type { VxilSchema } from './vxil.types';\nconst vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });\n\n// post \u2014 the hooks reject empty or >280 bodies inside the write tx\nconst { item_id: post } = await vx.from('posts').create(\n { body: 'hello from my own backend', user_id: 'usr_demo_ada',\n posted_at: new Date().toISOString(), likes_count: 0 },\n { status: 'published' }, // also fires the cdc frame onto feed:global\n);\n// like it \u2014 a plain create: the unique claim on `pair` makes a double-like a clean 409 unique_violation \u2026\nconst pair = `${post}:usr_demo_grace`;\nawait vx.from('likes').create({ post, user_id: 'usr_demo_grace', pair });\n// \u2026 and the counter bumps atomically, no read-modify-write (guide ch. 4, uniqueness)\nawait vx.from('posts').inc(post, { likes_count: 1 });\n```\n\n**The public timeline \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` and `profiles` are\n`public: true`, an anonymous visitor reads the global feed and public profile pages with **no API key** \u2014\nthe edge forces `status = 'published'`, edge-caches the page, and **strips the `user_id` owner field** from\nevery row (an anonymous reader never sees an end-user id). Owner-scoping still governs every authed WRITE:\nthe two lanes are independent. (`follows`/`likes` stay private \u2014 no `public` flag.)\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public global timeline \u2014 NO api key, user_id stripped from every row\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-posted_at', limit: 25 });\n// a public profile by handle\nconst { items: [p] } = await listCmsPublic('ten_your_tenant_id', 'profiles', { filter: { handle: 'ada' }, limit: 1 });\n```\n\n**Drop-in UI (optional):** [`@vxil/realtime`](../../packages/realtime) is the browser client for the live\n`feed:global` channel; [`@vxil/react/feed`](../../packages/react) is a React timeline +\nnotification-bell surface over the shipped `activity-feed` API for a follow-graph home feed. (The React\ncomponents ship via npm into a bundled app \u2014 there is no served `.mjs` for them, unlike `@vxil/realtime`.)\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping, **public delivery**), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (lock/guard on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime + the `@vxil/realtime` browser client,\nauth), and `examples/ecommerce/` for the same patterns composed with functions.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
18729
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Microblog\" \u2014 a Twitter-style micro-blogging backend (profiles, 280-char\n// posts, follows, likes, a realtime live feed), declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 profiles \u2192 posts (by relation) + follows/likes edge rows\n// \u2022 auth \u2192 accounts, so a poster is a VERIFIED end-user\n// \u2022 realtime \u2192 the live feed channel (posts fan out via the cms `cdc` bridge)\n// Every collection declares an end-user OWNER field, so from a thin client a\n// signed-in user can only write their OWN rows; tenant-wide reads (the global\n// timeline, follower counts) are served by YOUR backend with a server key \u2014\n// owner-scoping is a no-op for server callers (vxil.com/docs/guide/04-data-with-cms).\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // create posts with `status: 'published'` \u2014 no editorial step\n // Fail-safe (guide ch. 4, ownerField): a verified end-user key may only touch\n // collections that declare an ownerField. Every collection below does;\n // any collection you ADD later without one is denied to end-user keys\n // instead of silently shared tenant-wide. Server keys are unaffected.\n strictEndUserScope: true,\n // Lane-A safe-expression hooks \u2014 AST-validated at push time, run inside\n // the write transaction (guide ch. 7). NOTE: hooks do NOT run on `$inc`\n // (guide ch. 4, uniqueness) \u2014 likes_count is guarded by its own validation.min instead.\n hooks: {\n post_body_nonempty: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a post cannot be empty',\n },\n post_body_280: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.body) <= 280',\n message: 'a post is at most 280 characters',\n },\n // Field-vs-field comparison is grammar-legal (guide ch. 7 operators over item.*).\n follow_not_self: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.follower != item.followee',\n message: 'you cannot follow yourself',\n },\n // COMPOSED-KEY integrity: `pair` is written by the client/SDK as\n // follower + ':' + followee (see the field comment). This hook makes\n // that convention server-enforced, so the `unique: true` claim on\n // `pair` really means \"at most one follow edge per (follower,\n // followee)\" \u2014 a double-follow is a clean 409 unique_violation.\n follow_pair: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.follower, ':', item.followee)\",\n message: \"pair must be follower + ':' + followee\",\n },\n like_pair: {\n collection: 'likes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.post, ':', item.user_id)\",\n message: \"pair must be post + ':' + user_id\",\n },\n },\n // Realtime CDC bridge (guide ch. 4, keeping clients in sync): every NEW post auto-publishes a\n // `cms.item.created` / `.published` frame (this rule's two events \u2014\n // updates/deletes don't fire it) \u2014 ids only ({ collection, item_id, status }) \u2014\n // onto the realtime channel below; the client re-reads the post (e.g. over\n // public delivery). `payload: 'full'` is refused here: `posts` is owner-scoped,\n // and a full frame would hand every user's row to every subscriber. The\n // config-only live feed: at-most-once, fire-and-forget (guaranteed delivery\n // would use webhooks or functions).\n cdc: {\n feed_live: {\n collection: 'posts',\n channel: 'feed:global',\n events: ['created', 'published'],\n payload: 'ids',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // posters sign in as end-users\n realtime: {}, // defaults are fine; a channel exists as soon as someone uses it\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n profiles: {\n singular: 'profile',\n // End-user owner-scope (guide ch. 4, ownerField): a signed-in user edits only their\n // OWN profile. `user_id` must be a real string field (declared below).\n ownerField: 'user_id',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): public profile pages read with NO API key\n // over GET /v1/cms/public/:tenantId/profiles \u2014 edge-cached, and the\n // `user_id` owner field is STRIPPED from every served row (an anonymous\n // reader never sees the end-user id). Owner-scoping (above) still governs\n // the authed WRITE lane; public delivery is a read-only, owner-unscoped tier.\n public: true,\n fields: {\n // Declarative uniqueness (guide ch. 4, uniqueness), carried by `vxil push`: N\n // racing claims of a handle yield exactly one 201, the rest a clean\n // 409 unique_violation \u2014 the insert IS the claim. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n bio: { type: 'text' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n },\n },\n posts: {\n singular: 'post',\n ownerField: 'user_id',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the GLOBAL TIMELINE served with NO API key\n // over GET /v1/cms/public/:tenantId/posts?sort=-posted_at \u2014 edge-cached,\n // published-only, `user_id` stripped from every row. This is exactly the\n // \"tenant-wide reads served by YOUR backend\" note above, but now keyless:\n // an anonymous visitor reads the public feed without your server key.\n public: true,\n fields: {\n body: { type: 'text', required: true }, // \u2264280 chars \u2014 enforced by the hooks above\n author: { type: 'relation', relationTo: 'profiles', indexSlot: 's1' },\n user_id: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n posted_at: { type: 'datetime', indexSlot: 't1' }, // slot t1 \u21D2 sort=-posted_at is index-served\n // Bumped atomically via PATCH {\"$inc\":{\"likes_count\":1}} (guide ch. 4, uniqueness);\n // min:0 turns a decrement below zero into a clean 409, never a race.\n likes_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n },\n },\n follows: {\n singular: 'follow',\n // Owner = the follower: an end-user creates/removes only their OWN edges.\n ownerField: 'follower',\n fields: {\n follower: { type: 'string', required: true, indexSlot: 's1' }, // end-user id\n followee: { type: 'string', required: true, indexSlot: 's2' }, // end-user id\n // COMPOSED KEY \u2014 cms `unique` is single-field, so composite uniqueness\n // is modeled by having the client/SDK write follower + ':' + followee\n // here; the `follow_pair` hook rejects a mismatched composition, and\n // `unique: true` (guide ch. 4, uniqueness) makes a double-follow a clean 409\n // unique_violation on a plain create \u2014 no lock/guard needed for\n // pair dedup. (lock+guard, guide ch. 4, stays the tool for count-invariants\n // BEYOND uniqueness \u2014 e.g. \"at most N\".)\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n likes: {\n singular: 'like',\n ownerField: 'user_id',\n fields: {\n post: { type: 'relation', relationTo: 'posts', required: true, indexSlot: 's1' },\n user_id: { type: 'string', required: true, indexSlot: 's2' }, // the owner (end-user) id\n // COMPOSED KEY \u2014 post + ':' + user_id, declared unique: one like per\n // user per post; a double-like is a clean 409 unique_violation.\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'profiles',\n items: [\n { handle: 'ada', display_name: 'Ada Lovelace', bio: 'Notes on engines, in 280 chars.', user_id: 'usr_demo_ada' },\n { handle: 'grace', display_name: 'Grace Hopper', bio: 'Compilers, ships, short posts.', user_id: 'usr_demo_grace' },\n ],\n },\n {\n // The `author` relation is omitted here: seed items are plain creates and\n // cannot reference the server-generated item_id of the profiles above \u2014\n // set it on posts your app creates at runtime. Seeded items land as\n // drafts; publish them from the dashboard, or create real posts with\n // `status: 'published'` (see README).\n collection: 'posts',\n items: [\n { body: 'Hello, world \u2014 first post on my own backend.', user_id: 'usr_demo_ada', posted_at: '2026-07-01T09:00:00Z', likes_count: 0 },\n { body: 'A microblog is just cms + auth + realtime in one config file.', user_id: 'usr_demo_grace', posted_at: '2026-07-01T09:05:00Z', likes_count: 0 },\n ],\n },\n ],\n },\n});\n",
18730
+ "readme": "# Microblog / Social Feed template\n\nA Twitter-style micro-blogging backend \u2014 profiles, 280-character posts, follows, likes, and a realtime\nlive feed \u2014 declared end-to-end in one typed `vxil.config.ts`. Backend building blocks you enable in one\nline: `cms` holds the data (Lane-A hooks enforce the 280-char rule inside the write transaction), `auth`\nmakes every poster a verified end-user, and `realtime` streams new posts to every open client.\n\n**What it provisions:**\n- `profiles` \u2014 unique `handle`, display name, bio; owner-scoped by `user_id`. **`public: true`** \u2014 public\n profile pages read keyless (the `user_id` owner field is stripped from every served row).\n- `posts` \u2014 `body` (two validate hooks: non-empty, \u2264280), `author` relation \u2192 profiles, `posted_at`,\n atomic `likes_count`; owner-scoped by `user_id`. **`public: true`** \u2014 the global timeline reads keyless.\n- `follows` \u2014 `follower`/`followee` + a composed unique `pair` key; hooks block self-follows and a malformed pair.\n- `likes` \u2014 `post` relation + `user_id` + a composed unique `pair` key (one like per user per post).\n- Features: `cms` (with `strictEndUserScope` + a `cdc` live-feed rule), `auth` (email/password), `realtime`.\n\n**Apply it:**\n\n```bash\nmkdir my-microblog && cd my-microblog\nvxil init --template microblog\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\n # (or `vxil link <slug> --env staging` + `vxil push` for an existing backend)\nvxil seed # two profiles (ada, grace) + two posts \u2014 all drafts\n```\n\n**See it work.** Seeded rows are drafts, and the public timeline serves published rows only. Publish the\nseeded posts, then read the timeline with no key:\n\n```bash\nfor ID in $(vxil api GET /v1/cms/items/posts | jq -r '.items[].item_id'); do\n vxil api POST /v1/cms/items/posts/$ID/publish\ndone\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_TENANT_ID + VXIL_API_KEY into the shell\ncurl \"https://api.vxil.com/v1/cms/public/$VXIL_TENANT_ID/posts?sort=-posted_at\" # user_id is stripped from every row\n```\n\nThe public lane caches for up to 60 seconds. After you edit `vxil.config.ts`, `vxil push` applies it and\nregenerates the types.\n\n**What to learn from this:**\n1. **End-user owner scoping** (vxil.com/docs/guide/04-data-with-cms) \u2014 every collection names an `ownerField`, so a\n signed-in user can only write their OWN rows from a thin client; `strictEndUserScope: true` fail-closes\n any collection you later forget to scope. Server keys are unaffected \u2014 the global timeline and\n follower counts are served by your backend, where owner-scoping is a no-op.\n2. **Composed-key uniqueness** \u2014 cms `unique` is single-field, so \"unique (follower, followee)\" is modeled\n as a `pair` field the client writes as `follower + ':' + followee`; a validate hook enforces the\n composition, and the declarative `unique: true` on `pair` (in the config, carried by `vxil push` \u2014\n guide ch. 4, uniqueness) turns a double-follow/double-like into a clean `409 unique_violation` on a plain\n create \u2014 no lock needed. (`lock` + `guard` (guide ch. 4, preconditions and concurrency) remains the general tool for\n count-invariants BEYOND uniqueness, e.g. \"at most N seats/redemptions\".)\n3. **Atomic counters** (guide ch. 4, preconditions and concurrency) \u2014 `likes_count` bumps via `$inc`: ONE conditional UPDATE, no\n read-modify-write race; `validation.min: 0` refuses a decrement below zero.\n4. **Realtime live feed** (guide ch. 4, keeping clients in sync) \u2014 the `cdc` rule auto-publishes every new post (its\n `created`/`published` events) onto the `feed:global` channel as an ids-only frame (`posts` is owner-scoped,\n so a full-row frame is refused; re-read the post over public delivery); browsers subscribe with\n `@vxil/realtime` (vxil.com/docs/guide/06-feature-catalog: realtime). Limits you will meet: 100 open\n sockets per project on Free (1,000 Developer \xB7 5,000 Team \xB7 10,000 Business; a connect over the cap is\n refused with `429`), and a socket that sends more than 20 frames a second, or one frame over 1,024\n characters, is closed with code `1008`.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nimport type { VxilSchema } from './vxil.types.js';\nconst vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });\n\n// post \u2014 the hooks reject empty or >280 bodies inside the write tx\nconst { item_id: post } = await vx.from('posts').create(\n { body: 'hello from my own backend', user_id: 'usr_demo_ada',\n posted_at: new Date().toISOString(), likes_count: 0 },\n { status: 'published' }, // also fires the cdc frame onto feed:global\n);\n// like it \u2014 a plain create: the unique claim on `pair` makes a double-like a clean 409 unique_violation \u2026\nconst pair = `${post}:usr_demo_grace`;\nawait vx.from('likes').create({ post, user_id: 'usr_demo_grace', pair });\n// \u2026 and the counter bumps atomically, no read-modify-write (guide ch. 4, preconditions and concurrency)\nawait vx.from('posts').inc(post, { likes_count: 1 });\n```\n\n**The public timeline \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` and `profiles` are\n`public: true`, an anonymous visitor reads the global feed and public profile pages with **no API key** \u2014\nthe edge forces `status = 'published'`, edge-caches the page, and **strips the `user_id` owner field** from\nevery row (an anonymous reader never sees an end-user id). Owner-scoping still governs every authed WRITE:\nthe two lanes are independent. (`follows`/`likes` stay private \u2014 no `public` flag.)\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\nconst TENANT_ID = '00000000-0000-0000-0000-000000000000'; // yours: the `tenant:` line quickstart printed\n// the public global timeline \u2014 NO api key, user_id stripped from every row\nconst { items } = await listCmsPublic(TENANT_ID, 'posts', { sort: '-posted_at', limit: 25 });\n// a public profile by handle (publish the profile first \u2014 seeded rows are drafts)\nconst { items: [p] } = await listCmsPublic(TENANT_ID, 'profiles', { filter: { handle: 'ada' }, limit: 1 });\n```\n\n**Drop-in UI (optional):** **`@vxil/realtime`** is the browser client for the live\n`feed:global` channel; **`@vxil/react/feed`** (`npm i @vxil/react @vxil/sdk`) is a React timeline +\nnotification-bell surface over the shipped `activity-feed` API for a follow-graph home feed. (The React\ncomponents ship via npm into a bundled app \u2014 there is no served `.mjs` for them, unlike `@vxil/realtime`.)\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping, **public delivery**), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (lock/guard on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime + the `@vxil/realtime` browser client,\nauth), and the **storefront** blueprint for the same patterns composed with functions.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
18636
18731
  "functions": {}
18637
18732
  },
18638
18733
  {
@@ -18653,7 +18748,7 @@ var TEMPLATE_CATALOG = [
18653
18748
  "hasFunctions": false,
18654
18749
  "byoKeys": [],
18655
18750
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Community\" \u2014 a forum backend (threads \u2192 replies, one-vote-per-user voting,\n// live updates), declared end-to-end in ONE typed file. A BLUEPRINT composing\n// shipped building blocks \u2014\n// \u2022 cms \u2192 threads \u2192 replies (resolved by relation) + votes\n// \u2022 auth \u2192 accounts, so an author is a verified end-user\n// \u2022 realtime \u2192 live thread updates (wired config-only via the cms CDC bridge)\n// The load-bearing tricks: `ownerField: 'author'` (a member edits only their\n// OWN posts), `$inc` on `replies_count` (atomic counter, no read-modify-write),\n// and the COMPOSED-KEY vote \u2014 `pair` = voter + ':' + thread, derived by a hook\n// and declared `unique: true` (carried by `vxil push`, guide ch. 4, uniqueness), so N\n// racing votes yield exactly one 201. Everything here is DATA the tenant owns\n// and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Posts go live when created with `status: 'published'` (the README\n // curls do); keep drafts as a moderation hold state if you want a\n // review queue. (`vxil seed` sends no status, so seed items land as drafts.)\n draftPublish: true,\n hooks: {\n // A thread needs a real title \u2014 whitespace-only is rejected (Lane-A).\n thread_title: {\n collection: 'threads',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.title)) > 0',\n message: 'a thread needs a title',\n },\n // A reply needs a non-empty body.\n reply_body: {\n collection: 'replies',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a reply needs a body',\n },\n // ONE VOTE PER USER, half 1: both inputs must be present\u2026\n vote_inputs: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.voter)) > 0 && len(trim(item.thread)) > 0',\n message: 'a vote needs a voter and a thread',\n },\n // \u2026half 2: derive the composed key `pair` = voter + ':' + thread\n // SERVER-SIDE (a client can never mis-compose it). Half 3 is the\n // `unique: true` claim on votes.pair below \u2014 the insert IS the guard.\n vote_pair: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'pair',\n expr: \"concat(item.voter, ':', item.thread)\",\n },\n },\n // Realtime CDC bridge: thread + reply writes\n // auto-publish `cms.item.created/updated/published` frames onto the\n // 'threads:live' realtime channel \u2014 config-only live updates. At-most-once\n // (a live-view convenience; guaranteed delivery stays webhooks/functions).\n cdc: {\n threads_live: {\n collection: 'threads',\n channel: 'threads:live',\n events: ['created', 'updated', 'published'],\n payload: 'ids',\n },\n replies_live: {\n collection: 'replies',\n channel: 'threads:live',\n events: ['created', 'published'],\n payload: 'ids',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // members sign in as end-users\n realtime: {}, // defaults are fine \u2014 channels for the CDC frames above\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n threads: {\n singular: 'thread',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified member\n // may only edit their OWN threads. Server callers are unaffected.\n ownerField: 'author',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // Declarative uniqueness (guide ch. 4, uniqueness), carried by `vxil push`: a\n // duplicate slug is a clean 409 unique_violation. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n slug: { type: 'string', indexSlot: 's2', unique: true },\n author: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n category: { type: 'string', indexSlot: 's4' },\n // validation.min: 0 makes `$inc: { replies_count: -1 }` a conditional\n // decrement \u2014 the counter can never go negative under races.\n replies_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_activity: { type: 'datetime', indexSlot: 't1' }, // \"hot threads\" sort key\n body: { type: 'text' },\n },\n },\n replies: {\n singular: 'reply',\n ownerField: 'author',\n fields: {\n // Slot-bound relation = the JOIN declaration: dotted filter keys like\n // {\"thread.category\": \"announcements\"} reach the parent thread\n // (vxil.com/docs/guide/04-data-with-cms).\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' },\n body: { type: 'text', required: true },\n posted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n votes: {\n singular: 'vote',\n fields: {\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n // The voting member's id \u2014 asserted by YOUR backend (server key) in\n // this blueprint's flow. For direct end-user voting, declare\n // `ownerField: 'voter'` so the verified session id is enforced\n // (guide ch. 4, ownerField) \u2014 at the cost of end-user keys then listing/counting\n // only their OWN votes.\n voter: { type: 'string', indexSlot: 's2' },\n // THE COMPOSED KEY \u2014 'voter:thread', derived by the vote_pair hook\n // above and declared unique: one vote per member per thread. N\n // racing votes \u2192 exactly one 201, the rest 409 unique_violation \u2014\n // no lock, no read-check-write.\n pair: { type: 'string', indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'threads',\n items: [\n {\n title: 'Welcome to the community',\n slug: 'welcome',\n author: 'admin',\n category: 'announcements',\n replies_count: 0,\n last_activity: '2026-01-01T00:00:00Z',\n body: 'Introduce yourself below.',\n },\n ],\n },\n {\n // Seed items are POSTed verbatim (no cross-item ref resolution), so this\n // reply carries no `thread` id \u2014 attach replies at runtime with the real\n // item_id (see the README snippet).\n collection: 'replies',\n items: [\n {\n author: 'admin',\n body: 'Say hi and tell us what you are building.',\n posted_at: '2026-01-01T00:00:00Z',\n },\n ],\n },\n ],\n },\n});\n",
18656
- "readme": '# Forum / Community template\n\nA forum backend \u2014 threads, replies, and votes \u2014 declared end-to-end in one typed `vxil.config.ts`.\nAuthors are verified end-users (`auth`), and thread activity streams live over `realtime`\nvia the config-only cms CDC bridge.\n\n**Provisions:**\n- `threads` \u2014 title (non-empty, hook-enforced), unique slug, `author` (owner-scoped), category,\n `replies_count`, `last_activity`, body.\n- `replies` \u2014 `thread` relation (slot-bound = join-able), `author` (owner-scoped), body, `posted_at`.\n- `votes` \u2014 `thread` relation, voter, and the unique composed key `pair` = `voter + \':\' + thread`.\n- `realtime` \u2014 thread/reply writes auto-publish onto the `threads:live` channel (`cdc` config bag).\n\n**Use it:**\n\n```bash\nvxil init --template community\nvxil quickstart\nvxil push # carries the `unique: true` claims on threads.slug + votes.pair (guide ch. 4, uniqueness)\nvxil seed # the demo seed (1 thread + 1 reply) \u2014 push does not apply it\nvxil gen\n```\n\n**What to learn from this:**\n- **One vote per user, race-safe.** A `derive` hook composes `pair` server-side; the declarative\n `unique: true` on `votes.pair` (in the config, carried by `vxil push` \u2014 guide ch. 4, uniqueness) makes the\n insert itself the guard \u2014 N racing votes yield exactly one 201, the rest `409 unique_violation`.\n No lock, no read-check-write. `voter` is asserted by your backend here; for direct end-user voting,\n declare `ownerField: \'voter\'` so the verified session id is enforced (guide ch. 4, ownerField).\n- **Atomic counters with `$inc`.** Bump `replies_count` in one conditional statement\n (`validation.min: 0` means a decrement can never go negative). Note: Lane-A hooks do NOT\n run on `$inc` \u2014 keep hook-guarded invariants off `$inc` fields.\n- **Join-filter reads (dotted keys).** `replies.thread` is a slot-bound relation, so a filter\n can reach the parent: `?filter={"thread.category":"announcements"}` lists replies whose\n thread is in a category \u2014 ops `$eq $ne $gt $gte $lt $lte $in` only (vxil.com/docs/guide/04-data-with-cms).\n- **Live threads.** Subscribe a browser to `threads:live` with `@vxil/realtime` (token from your\n backend via `POST /v1/realtime/tokens`) and render `cms.item.created`/`updated` frames as they land.\n\n```bash\n# reply to a thread, then bump its counter atomically ($inc never mixes with data)\ncurl -X POST "https://api.vxil.com/v1/cms/items/replies" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","author":"u_42","body":"Hi!","posted_at":"2026-07-11T12:00:00Z"},"status":"published"}\'\ncurl -X PATCH "https://api.vxil.com/v1/cms/items/threads/itm_THREAD" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"$inc":{"replies_count":1}}\'\n# vote \u2014 the pair "u_42:itm_THREAD" is derived server-side; voting twice \u2192 409\ncurl -X POST "https://api.vxil.com/v1/cms/items/votes" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","voter":"u_42"},"status":"published"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (join filters \xB7 `ownerField`), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (`$inc`/`unique` on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime, auth),\nand `examples/ecommerce/` for the same patterns under a checkout saga.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
18751
+ "readme": '# Forum / Community template\n\nA forum backend \u2014 threads, replies, and votes \u2014 declared end-to-end in one typed `vxil.config.ts`.\nAuthors are verified end-users (`auth`), and thread activity streams live over `realtime`\nvia the config-only cms CDC bridge.\n\n**Provisions:**\n- `threads` \u2014 title (non-empty, hook-enforced), unique slug, `author` (owner-scoped), category,\n `replies_count`, `last_activity`, body.\n- `replies` \u2014 `thread` relation (slot-bound = join-able), `author` (owner-scoped), body, `posted_at`.\n- `votes` \u2014 `thread` relation, voter, and the unique composed key `pair` = `voter + \':\' + thread`.\n- `realtime` \u2014 thread/reply writes auto-publish onto the `threads:live` channel (`cdc` config bag).\n\n**Use it:**\n\n```bash\nmkdir my-forum && cd my-forum\nvxil init --template community\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config \u2014 including the\n # `unique: true` claims on threads.slug + votes.pair (guide ch. 4, uniqueness) \u2014 and writes vxil.types.ts\nvxil seed # the demo seed (1 thread + 1 reply) \u2014 push does not apply it\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_API_KEY for the curl examples below\nTHREAD=$(vxil api GET /v1/cms/items/threads | jq -r \'.items[0].item_id\') # the seeded thread\'s id\nREPLY=$(vxil api GET /v1/cms/items/replies | jq -r \'.items[0].item_id\') # the seeded reply\'s id\nvxil api PATCH /v1/cms/items/replies/$REPLY --data \'{"data":{"thread":"\'$THREAD\'"}}\' # attach it\n```\n\nThe seed cannot link the reply to the thread (seed items are posted as written, and the thread\'s id does\nnot exist yet), so the seeded reply starts unattached; the `PATCH` above attaches it, after which the\njoin-filter read below finds it.\n\nAfter you edit `vxil.config.ts`, `vxil push` applies it and regenerates the types.\n\n**What to learn from this:**\n- **One vote per user, race-safe.** A `derive` hook composes `pair` server-side; the declarative\n `unique: true` on `votes.pair` (in the config, carried by `vxil push` \u2014 guide ch. 4, uniqueness) makes the\n insert itself the guard \u2014 N racing votes yield exactly one 201, the rest `409 unique_violation`.\n No lock, no read-check-write. `voter` is asserted by your backend here; for direct end-user voting,\n declare `ownerField: \'voter\'` so the verified session id is enforced (guide ch. 4, ownerField).\n- **Atomic counters with `$inc`.** Bump `replies_count` in one conditional statement\n (`validation.min: 0` means a decrement can never go negative). Note: Lane-A hooks do NOT\n run on `$inc` \u2014 keep hook-guarded invariants off `$inc` fields.\n- **Join-filter reads (dotted keys).** `replies.thread` is a slot-bound relation, so a filter\n can reach the parent: `?filter={"thread.category":"announcements"}` lists replies whose\n thread is in a category \u2014 ops `$eq $ne $gt $gte $lt $lte $in` only (vxil.com/docs/guide/04-data-with-cms).\n- **Live threads.** Subscribe a browser to `threads:live` with `@vxil/realtime` (token from your\n backend via `POST /v1/realtime/tokens`) and react to `cms.item.created`/`updated` frames as they land.\n Both rules use `payload: \'ids\'`, so a frame carries the collection and item id, not the row: fetch the\n row to render it (`payload: \'full\'` is refused here: both collections are owner-scoped, so a full\n frame would reach every subscriber).\n Limits you will meet: 100 open sockets per project on Free (1,000 Developer \xB7 5,000 Team \xB7 10,000\n Business; a connect over the cap is refused with `429`), and a socket that sends more than 20 frames a\n second, or one frame over 1,024 characters, is closed with code `1008`. Debounce anything a client sends\n per keystroke.\n\n```bash\n# reply to a thread, then bump its counter atomically ($inc never mixes with data)\ncurl -X POST "https://api.vxil.com/v1/cms/items/replies" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"\'$THREAD\'","author":"u_42","body":"Hi!","posted_at":"2026-07-11T12:00:00Z"},"status":"published"}\'\ncurl -X PATCH "https://api.vxil.com/v1/cms/items/threads/$THREAD" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"$inc":{"replies_count":1}}\'\n# vote \u2014 the pair "u_42:<thread id>" is derived server-side; run this twice and the second answers 409 unique_violation\ncurl -X POST "https://api.vxil.com/v1/cms/items/votes" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"\'$THREAD\'","voter":"u_42"},"status":"published"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (join filters \xB7 `ownerField`), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (`$inc`/`unique` on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime, auth),\nand the **storefront** blueprint for the same patterns under a checkout saga.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
18657
18752
  "functions": {}
18658
18753
  },
18659
18754
  {
@@ -18677,7 +18772,7 @@ var TEMPLATE_CATALOG = [
18677
18772
  "hasFunctions": false,
18678
18773
  "byoKeys": [],
18679
18774
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Live Room\" \u2014 the REAL-TIME blueprint: every live-collaboration primitive\n// wired together on ONE small config, so you can see where each one's job\n// begins and ends.\n//\n// \u2022 realtime \u2192 the channel a browser holds open\n// \u2022 presence \u2192 who is in the room, and who is typing (socket-derived)\n// \u2022 activity-feed \u2192 follow a room, fan out its posts, ring the bell\n// \u2022 comments \u2192 the threads themselves (root + replies + reactions)\n// \u2022 files \u2192 attachments, uploaded straight to storage, plus a\n// hand-this-to-exactly-one-person link\n// \u2022 rate-limits \u2192 a posting budget, with one member exempted\n// \u2022 cms + cdc \u2192 the room records, streamed into a channel on write\n//\n// The division of labour worth internalising: realtime moves FRAMES, presence\n// answers WHO, the feed answers WHAT HAPPENED WHILE YOU WERE AWAY, comments\n// owns the durable thread, and cdc is the one-line bridge from a database write\n// to a live frame. None of them is a substitute for another.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // Members sign in so presence, feeds and comments all speak the same ids.\n auth: { methods: { emailPassword: true, magicLink: true } },\n\n // Live channels. A browser never holds an API key: your server mints a\n // short-lived channel token and the browser upgrades a socket with it.\n realtime: { enabled: true, maxConnectionsPerChannel: 200 },\n\n // Presence rides the SAME socket \u2014 join/leave are derived from the\n // connection, and `typing` is a message the client sends on the socket. It\n // is a second gate on top of realtime, not a separate transport.\n presence: { enabled: true },\n\n // Threads. `topic` is an opaque string YOU choose \u2014 this blueprint uses\n // `room:<slug>` so a whole room's thread is one prefix query.\n comments: {\n enabled: true,\n maxBodyLength: 4000,\n reactionsEnabled: true,\n editWindowMinutes: 15, // authors may fix a typo for 15 minutes\n },\n\n // Activity streams + the in-app notification feed.\n 'activity-feed': {\n enabled: true,\n feedGroups: {\n // a member's own authored activity\n user: { type: 'flat' },\n // what a member sees: everything they follow, newest first\n timeline: { type: 'flat', ranking: 'chronological' },\n // the bell: rolled up per verb+object+day, with seen/read state\n notification: { type: 'notification', aggregation: '{{ verb }}:{{ object }}:{{ time|date }}' },\n },\n fanout: { celebrityThreshold: 5000, maxFanoutPerJob: 500 },\n follow: { copyLimit: 50 }, // backfill this many posts when someone follows\n realtime: { enabled: true }, // push new activities + badge counts live\n },\n\n // Attachments. Bytes go from the browser straight to storage on a\n // pre-signed URL \u2014 they never transit the API.\n files: {\n enabled: true,\n uploadUrlTtl: 900,\n allowedContentTypes: ['image/png', 'image/jpeg', 'image/gif', 'application/pdf'],\n quotas: { maxObjectBytes: 25 * 1024 * 1024 },\n sharedLinks: { enabled: true, maxTtl: 24 * 3600 },\n },\n\n // The posting budget. The POLICY itself is not config \u2014 it is a row you\n // create over the API (one writer per datum), and so is the per-member\n // override. What lives here is the tenant-wide default envelope.\n 'rate-limits': { enabled: true, defaults: { perTenantSec: 50, burstSize: 100 } },\n\n cms: {\n // \u2500\u2500 THE ONE-LINE BRIDGE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Every write to `rooms` is auto-published to the `lobby` channel as\n // `cms.item.<created|updated|deleted|published>`. No function, no\n // webhook, no polling. It is fire-and-forget and AT-MOST-ONCE: a live\n // view is exactly what it is for. When you need guaranteed delivery, use\n // an outbound webhook subscription or a function on the write instead.\n //\n // The channel name is LITERAL \u2014 it is not templated per item. One rule =\n // one channel; declare a second rule if you want a second channel.\n cdc: {\n lobby: {\n collection: 'rooms',\n channel: 'lobby',\n events: ['created', 'updated', 'deleted'],\n payload: 'full', // 'ids' to send only { collection, item_id, status }\n },\n },\n },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n rooms: {\n singular: 'room',\n // Deliberately NOT owner-scoped: a room is shared by everyone in it.\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // one room per slug \u2014 a duplicate is a clean 409, not a second room\n slug: { type: 'string', unique: true, indexSlot: 's2' },\n host: { type: 'string', indexSlot: 's3' }, // the member who opened it\n state: { type: 'string', indexSlot: 's4' }, // open | closed\n created_at: { type: 'datetime', indexSlot: 't1' },\n topic: { type: 'text' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'rooms',\n items: [\n {\n name: 'Design review',\n slug: 'design-review',\n host: 'user_demo',\n state: 'open',\n created_at: '2026-03-02T10:00:00Z',\n topic: 'Walk through the new onboarding flow and collect objections.',\n },\n ],\n },\n ],\n },\n});\n",
18680
- "readme": '# Live Room (social)\n\nEvery live-collaboration primitive, wired together on one small config \u2014 so you can see where\neach one\'s job begins and ends instead of guessing which to reach for.\n\n```bash\nvxil init --template live-room\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # the `rooms` collection + the change-data rule\n```\n\nNo functions. Nothing here needs code on the server: the whole blueprint is configuration plus\ncalls your app already makes.\n\n## Who does what\n\n| Primitive | Its one job | What it is NOT |\n|---|---|---|\n| `realtime` | move frames to everyone holding a channel open | not durable \u2014 a frame nobody was listening for is gone |\n| `presence` | answer *who is here, and who is typing* | not a member list \u2014 it is derived from live connections |\n| `activity-feed` | answer *what happened while I was away* | not a chat log \u2014 it is a ranked, followable stream |\n| `comments` | the durable thread: root, replies, reactions | not attachments \u2014 see below |\n| `files` | bytes, straight from the browser to storage | not a CDN in front of your database |\n| `rate-limits` | a budget per user, with exceptions | not authorization |\n| `cms` + `cdc` | turn a database write into a live frame, in one config line | not delivery you can rely on \u2014 see below |\n\nThe division that matters: **realtime is transport, the feed is memory.** A member who was offline\nlearns what happened from the feed, never from the channel. Build both or people will keep asking\n"did you see my message?".\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `realtime:write presence:read comments:read comments:write files:read\nfiles:write activity-feed:read activity-feed:write ratelimits:read ratelimits:write ratelimits:check\ncms:read cms:write`; `$API` is `https://api.vxil.com`.\n\n**1. A browser gets a token, not a key.** Your server mints a short-lived channel token; the browser\nupgrades a socket with it. The API key never leaves your backend.\n\n```bash\nvxil api POST /v1/realtime/tokens --data \'{"channel":"room-design-review","user_id":"user_ana","ttl_seconds":120}\'\n# 200 { "data": { "token": "\u2026", "connect_path": "\u2026", "expires_at": "\u2026" } }\n```\n\nOpen the socket at `connect_path` (it is a `wss://` upgrade on the same host). The TTL is clamped\nto 10\u2013300 seconds on purpose: a token is a door, not a password. In end-user mode the token\'s\nsubject is forced to the verified user \u2014 a client cannot mint a token as somebody else.\n\nThe moment the socket opens it receives `presence.state` with the current roster; everyone else\nreceives `presence.join`. Send `{"type":"typing"}` on the socket and the room sees\n`presence.typing` (once a second, per socket). Close it and the last connection for that user\nemits `presence.leave`. None of that is a REST call \u2014 **presence is derived from the connection**,\nwhich is why there is no join, leave, or heartbeat endpoint to get wrong.\n\n**2. Your server publishes.** Only your server:\n\n```bash\nvxil api POST /v1/realtime/channels/room-design-review/publish \\\n --data \'{"event":"room.note","data":{"text":"Starting in 5"}}\'\n# 200 { "data": { "channel": "room-design-review", "event": "room.note", "delivered": 2 } }\n```\n\n`delivered` counts frames actually sent \u2014 a socket that is closed or too far behind is skipped and\nnot counted, so the number is honest. Publishing is refused for an end-user principal\n(`403 server_only`): a browser cannot broadcast to a room over REST. And the `presence.*` event\nnamespace is reserved:\n\n```bash\nvxil api POST /v1/realtime/channels/room-design-review/publish --data \'{"event":"presence.fake"}\'\n# 422 \u2026 the \'presence.*\' event namespace is reserved\n```\n\n**3. Who is in the room.**\n\n```bash\nvxil api GET /v1/realtime/channels/room-design-review/presence\n# 200 { "data": { "channel": "room-design-review",\n# "users": [ { "user_id": "user_ana", "connections": 2 } ],\n# "total_connections": 2 } }\n```\n\n`connections` is per user, not per person-shaped guess \u2014 two tabs are two connections and one\nmember.\n\n**4. The durable thread.** A comment hangs off a `topic`, which is **an opaque string you choose**.\nThis blueprint uses `room:<slug>`, so a whole room\'s thread is one prefix:\n\n```bash\nvxil api POST /v1/comments --data \'{"topic":"room:design-review","author_id":"user_ana","body":"Objection: the empty state is confusing."}\'\n# 201 { "data": { "comment_id": "cmt_\u2026", "topic": "room:design-review", "author_id": "user_ana", \u2026 } }\n\nvxil api POST /v1/comments --data \'{"topic":"room:design-review","author_id":"user_bo","parent_id":"cmt_\u2026","body":"Agreed \u2014 screenshot below."}\'\nvxil api POST /v1/comments/cmt_\u2026/reactions --data \'{"emoji":"\u{1F389}","author_id":"user_bo"}\'\n# 200 \u2014 a toggle: the same emoji from the same author removes it again\n```\n\nA thread is the root comment plus its `parent_id` children; there is no separate thread object to\ncreate and no `resolved` flag \u2014 resolution is a decision your app makes, not one vxil imposes.\n\n**5. Attachments \u2014 the honest version.** There is **no typed attachment field on a comment**. Files\nare their own feature, and the link between them is a convention *you* own. The upload is three\nsteps, and the bytes never pass through the API. (The `user_id` must be a user this backend knows \u2014\n`POST /v1/users` first, or sign them in through `auth`; an unknown id is a clean\n`user_not_found`.)\n\n```bash\nvxil api POST /v1/files/upload-url --data \'{"user_id":"user_bo","filename":"empty-state.png","content_type":"image/png","size_bytes":48210}\'\n# 201 { "data": { "object_id": "obj_\u2026", "upload_url": "\u2026", "upload_method": "PUT", "expires_in": 900 } }\n\ncurl -s -X PUT "<upload_url>" -H \'content-type: image/png\' --data-binary @empty-state.png\n\nvxil api POST /v1/files/obj_\u2026/complete\n# 200 { "data": { "object_id": "obj_\u2026", "status": "available", "checksum_sha256": "\u2026" } }\n```\n\nStep 2 goes straight to storage on a pre-signed URL whose content type **and** length are part of\nthe signature, so a client cannot quietly upload something else. Step 3 verifies the bytes landed\nat the size you declared \u2014 until it runs, the object is `pending` and undownloadable.\n\nThen put the id in the comment body with whatever marker your renderer understands\n(`![empty state](vxil-file:obj_\u2026)` works fine). Your client reads the marker and calls\n`GET /v1/files/{object_id}/download-url` for a short-lived link.\n\n**6. A link you can hand to exactly one person.** For the case where a *non-member* needs one file\n\u2014 an export, a signed page \u2014 mint a shared link that dies on first use:\n\n```bash\nvxil api POST /v1/files/obj_\u2026/shared-links --data \'{"max_downloads":1,"ttl_seconds":3600}\'\n# 201 { "data": { "link_id": "shl_\u2026", "url": "\u2026", "expires_in": 3600,\n# "expires_at": "\u2026", "max_downloads": 1, "downloads": 0 } }\n```\n\nOpen the returned `url` with no key at all and the file downloads. Open it a second time:\n\n```\n410 { "error": { "code": "link_exhausted", \u2026 } }\n```\n\nThe counter is burned inside the same conditional write that hands out the file, so two people\nclicking at the same instant cannot both win. Leave `max_downloads` off for an unlimited link;\n`ttl_seconds` is clamped to the `sharedLinks.maxTtl` you set in config.\n\n**7. The feed: follow, fan out, and the bell.** An activity is `actor` / `verb` / `object`\n(plus an optional `target`) \u2014 the shape every activity-stream system settled on.\n\n```bash\n# bo follows ana\'s personal feed\nvxil api POST /v1/feeds/timeline/user_bo/follows --data \'{"target":"user:user_ana"}\'\n# 201 { "data": { "follower": "timeline:user_bo", "followed": [ "user:user_ana" ] } }\n\n# ana posts \u2014 it fans out to her followers\' timelines, and pings bo\'s bell directly\nvxil api POST /v1/feeds/user/user_ana/activities \\\n --data \'{"actor":"user_ana","verb":"post","object":"room:design-review","foreign_id":"post-1","to":["notification:user_bo"]}\'\n# 201 { "data": { "activities": [ { "id": "\u2026", "actor": "user_ana", "verb": "post", \u2026 } ] } }\n\nvxil api GET /v1/feeds/timeline/user_bo\n# 200 { "data": { "feed": "timeline:user_bo", "type": "flat", "activities": [ \u2026 ], "next_cursor": \u2026 } }\n\nvxil api GET /v1/feeds/notification/user_bo/count\n# 200 { "data": { "unseen": 1, "unread": 1, "total": 1 } }\n\nvxil api POST /v1/feeds/notification/user_bo/read --data \'{}\'\n# 200 { "data": { "user_id": "user_bo", "action": "read", "unseen": 0, "unread": 0, "total": 1 } }\n```\n\n`foreign_id` makes the write idempotent \u2014 the same `(foreign_id, time)` pair upserts instead of\nduplicating, which is exactly what you want when a retry replays a post. The notification group is\naggregated (`{{ verb }}:{{ object }}:{{ time|date }}`), so twelve likes on one post are one badge\nline, not twelve.\n\n**8. A posting budget, with one exception.** The budget is a *row*, not config \u2014 so is the\nexception:\n\n```bash\nvxil api POST /v1/rate-limits/policies --data \'{"name":"room-post","key_template":"post:{user_id}","limit":5,"window_seconds":60}\'\n# 201 { "data": { "policy_id": "rl_\u2026", "name": "room-post", "key_template": "post:{user_id}",\n# "limit": 5, "window_seconds": 60, "behavior": "block", "algorithm": "sliding_window", \u2026 } }\n\n# the host gets more headroom than everyone else\nvxil api POST /v1/rate-limits/policies/rl_\u2026/overrides --data \'{"pattern":"post:user_ana","limit":100,"note":"room host"}\'\n# 201 { "data": { "override_id": "rlo_\u2026", "pattern": "post:user_ana", "limit": 100, \u2026 } }\n\nvxil api POST /v1/rate-limits/check --data \'{"policy_id":"rl_\u2026","key_values":{"user_id":"user_bo"}}\'\n# 200 { "data": { "allowed": true, "remaining": 4, "reset_seconds": 60, "behavior": "block" } }\n# \u2026and as user_ana the same call answers `"remaining": 99` with the `override_id` that granted it.\n```\n\nCall it six times as `user_bo` and the sixth is `429 rate_limited` with `Retry-After`; as\n`user_ana` it keeps going. A `pattern` may be a literal or a glob (`post:staff-*`); an exact match\nalways beats a glob, and the longest literal prefix wins between globs. Give an override up to\nabout half a minute to take effect \u2014 the lookup is cached, which is the price of it costing nothing\non the hot path.\n\n**9. One config line, and a database write becomes a live frame.** The `cdc` rule in\n`vxil.config.ts` is already pushed. Write a room:\n\n```bash\nvxil api PATCH /v1/cms/items/rooms/<room item_id> --data \'{"data":{"state":"closed"}}\'\n```\n\nAnyone holding the `lobby` channel receives:\n\n```json\n{ "event": "cms.item.updated",\n "data": { "collection": "rooms", "item_id": "itm_\u2026", "status": "draft",\n "at": "\u2026", "request_id": "req_\u2026", "data": { "name": "Design review", "state": "closed", \u2026 } },\n "ts": 1772000000000 }\n```\n\nNo function, no polling, no second write path. Two things to know before you rely on it: the\nchannel name is **literal**, not templated \u2014 one rule is one channel, so declare a second rule for\na second channel \u2014 and delivery is **at-most-once**, fire-and-forget after the write commits. It is\na live view. When a missed event would be a bug, use a durable subscription or a function on the\nwrite instead, both of which ride the at-least-once spine.\n\n## The client half\n\nThe browser side of this is published, so you do not write it twice:\n\n- [`@vxil/realtime`](../../packages/realtime) \u2014 the reconnecting channel client (`RealtimeClient`,\n `createRealtimeClient`), including the presence events.\n- [`@vxil/react/feed`](../../packages/react) \u2014 `FeedProvider`, `FeedTimeline` and `NotificationBell`\n over the feed API, with the badge count kept live.\n- [`@vxil/react/comments`](../../packages/react) \u2014 `CommentThread` plus `UploadWidget` / `Dropzone`,\n which drive exactly the three-step upload above.\n\nThe uploader and the thread are separate components for the same reason the API has no attachment\nfield: the link between a file and a comment is your product\'s decision.\n\n## What to learn from this\n\n- **A browser holds a token, never a key.** Every live surface here is reachable from a client\n because the client is handed a short-lived, subject-bound credential your server minted.\n- **Presence is not a table.** It is whatever sockets are open right now. Treating it as durable\n state is the classic mistake; the roster endpoint exists for the first paint, not as a source of\n truth.\n- **Transport and memory are different features.** The channel is for people who are here. The feed\n is for people who were not. Every "unread" bug is one of those two doing the other\'s job.\n- **Idempotency is a field, not a promise.** `foreign_id` on an activity, `unique` on a cms field,\n `max_downloads` on a link \u2014 each one turns "please don\'t run twice" into something the database\n enforces.\n- **A change-data rule is a live view, not a delivery guarantee.** One line gets you a stream. Two\n words \u2014 *at most once* \u2014 tell you when not to use it.\n\n**Pairs with:** `templates/community/` (threads and votes without the live layer) and\n`templates/microblog/` (follows and a public timeline).\n',
18775
+ "readme": '# Live Room (social)\n\nEvery live-collaboration primitive, wired together on one small config \u2014 so you can see where\neach one\'s job begins and ends instead of guessing which to reach for.\n\n```bash\nmkdir my-room && cd my-room\nvxil init --template live-room\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the `rooms` collection\n # + the change-data rule and writes vxil.types.ts\n # (or `vxil link <slug> --env staging` + `vxil push` for an existing backend)\nvxil seed # the demo room\n```\n\nNo functions. Nothing here needs code on the server: the whole blueprint is configuration plus\ncalls your app already makes.\n\n## Who does what\n\n| Primitive | Its one job | What it is NOT |\n|---|---|---|\n| `realtime` | move frames to everyone holding a channel open | not durable \u2014 a frame nobody was listening for is gone |\n| `presence` | answer *who is here, and who is typing* | not a member list \u2014 it is derived from live connections |\n| `activity-feed` | answer *what happened while I was away* | not a chat log \u2014 it is a ranked, followable stream |\n| `comments` | the durable thread: root, replies, reactions | not attachments \u2014 see below |\n| `files` | bytes, straight from the browser to storage | not a CDN in front of your database |\n| `rate-limits` | a budget per user, with exceptions | not authorization |\n| `cms` + `cdc` | turn a database write into a live frame, in one config line | not delivery you can rely on \u2014 see below |\n\nThe division that matters: **realtime is transport, the feed is memory.** A member who was offline\nlearns what happened from the feed, never from the channel. Build both or people will keep asking\n"did you see my message?".\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `realtime:write presence:read comments:read comments:write files:read\nfiles:write activity-feed:read activity-feed:write ratelimits:read ratelimits:write ratelimits:check\ncms:read cms:write`; `$API` is `https://api.vxil.com`. The `vxil api` lines below need neither: they\nuse the bound project\'s key. For the raw HTTP calls, `vxil env pull && set -a && . ./.env.local && set +a`\ngives you `VXIL_API_KEY` (your project key, which holds those scopes) and `VXIL_BASE_URL`; set\n`KEY=$VXIL_API_KEY API=$VXIL_BASE_URL`, or mint a narrower key with\n`vxil keys mint --name room-server --scopes <the list above, comma-separated> --json`.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .field`, not `jq .data.field`.\n\n**1. A browser gets a token, not a key.** Your server mints a short-lived channel token; the browser\nupgrades a socket with it. The API key never leaves your backend.\n\n```bash\nvxil api POST /v1/realtime/tokens --data \'{"channel":"room-design-review","user_id":"user_ana","ttl_seconds":120}\'\n# 200 { "data": { "token": "\u2026", "connect_path": "\u2026", "expires_at": "\u2026" } }\n```\n\nOpen the socket at `connect_path` (it is a `wss://` upgrade on the same host). The TTL is clamped\nto 10\u2013300 seconds on purpose: a token is a door, not a password. In end-user mode the token\'s\nsubject is forced to the verified user \u2014 a client cannot mint a token as somebody else.\n\nThe moment the socket opens it receives `presence.state` with the current roster; everyone else\nreceives `presence.join`. Send `{"type":"typing"}` on the socket and the room sees\n`presence.typing` (once a second, per socket). Close it and the last connection for that user\nemits `presence.leave`. None of that is a REST call \u2014 **presence is derived from the connection**,\nwhich is why there is no join, leave, or heartbeat endpoint to get wrong.\n\n> **Limits you will meet.** A project holds at most 100 open sockets on Free (1,000 Developer \xB7 5,000\n> Team \xB7 10,000 Business); a connect over the cap is refused with `429`, and sockets already open stay\n> open. A socket that sends more than **20 frames a second**, or one frame over **1,024 characters**, is\n> closed with code **`1008`**. Send `typing` on a debounce (the room only sees it once a second per\n> socket anyway), never on every keystroke, and send messages through your server, not the socket.\n\n**2. Your server publishes.** Only your server:\n\n```bash\nvxil api POST /v1/realtime/channels/room-design-review/publish \\\n --data \'{"event":"room.note","data":{"text":"Starting in 5"}}\'\n# 200 { "data": { "channel": "room-design-review", "event": "room.note", "delivered": 2 } }\n```\n\n`delivered` counts frames actually sent \u2014 a socket that is closed or too far behind is skipped and\nnot counted, so the number is honest. Publishing is refused for an end-user principal\n(`403 server_only`): a browser cannot broadcast to a room over REST. And the `presence.*` event\nnamespace is reserved:\n\n```bash\nvxil api POST /v1/realtime/channels/room-design-review/publish --data \'{"event":"presence.fake"}\'\n# 422 \u2026 the \'presence.*\' event namespace is reserved\n```\n\n**3. Who is in the room.**\n\n```bash\nvxil api GET /v1/realtime/channels/room-design-review/presence\n# 200 { "data": { "channel": "room-design-review",\n# "users": [ { "user_id": "user_ana", "connections": 2 } ],\n# "total_connections": 2 } }\n```\n\n`connections` is per user, not per person-shaped guess \u2014 two tabs are two connections and one\nmember.\n\n**4. The durable thread.** A comment hangs off a `topic`, which is **an opaque string you choose**.\nThis blueprint uses `room:<slug>`, so a whole room\'s thread is one prefix:\n\n```bash\nvxil api POST /v1/comments --data \'{"topic":"room:design-review","author_id":"user_ana","body":"Objection: the empty state is confusing."}\'\n# 201 { "data": { "comment_id": "cmt_\u2026", "topic": "room:design-review", "author_id": "user_ana", \u2026 } }\n\nvxil api POST /v1/comments --data \'{"topic":"room:design-review","author_id":"user_bo","parent_id":"cmt_\u2026","body":"Agreed \u2014 screenshot below."}\'\nvxil api POST /v1/comments/cmt_\u2026/reactions --data \'{"emoji":"\u{1F389}","author_id":"user_bo"}\'\n# 200 \u2014 a toggle: the same emoji from the same author removes it again\n```\n\nA thread is the root comment plus its `parent_id` children; there is no separate thread object to\ncreate and no `resolved` flag \u2014 resolution is a decision your app makes, not one vxil imposes.\n\n**5. Attachments \u2014 the honest version.** There is **no typed attachment field on a comment**. Files\nare their own feature, and the link between them is a convention *you* own. The upload is three\nsteps, and the bytes never pass through the API. (The `user_id` must be a user this backend knows \u2014\n`POST /v1/users` first, or sign them in through `auth`; an unknown id is a clean\n`user_not_found`.)\n\n```bash\nvxil api POST /v1/files/upload-url --data \'{"user_id":"user_bo","filename":"empty-state.png","content_type":"image/png","size_bytes":48210}\'\n# 201 { "data": { "object_id": "obj_\u2026", "upload_url": "\u2026", "upload_method": "PUT", "expires_in": 900 } }\n\ncurl -s -X PUT "<upload_url>" -H \'content-type: image/png\' --data-binary @empty-state.png\n\nvxil api POST /v1/files/obj_\u2026/complete\n# 200 { "data": { "object_id": "obj_\u2026", "status": "available", "checksum_sha256": "\u2026" } }\n```\n\nStep 2 goes straight to storage on a pre-signed URL whose content type **and** length are part of\nthe signature, so a client cannot quietly upload something else. Step 3 verifies the bytes landed\nat the size you declared \u2014 until it runs, the object is `pending` and undownloadable.\n\nThen put the id in the comment body with whatever marker your renderer understands\n(`![empty state](vxil-file:obj_\u2026)` works fine). Your client reads the marker and calls\n`GET /v1/files/{object_id}/download-url` for a short-lived link.\n\n**6. A link you can hand to exactly one person.** For the case where a *non-member* needs one file\n\u2014 an export, a signed page \u2014 mint a shared link that dies on first use:\n\n```bash\nvxil api POST /v1/files/obj_\u2026/shared-links --data \'{"max_downloads":1,"ttl_seconds":3600}\'\n# 201 { "data": { "link_id": "shl_\u2026", "url": "\u2026", "expires_in": 3600,\n# "expires_at": "\u2026", "max_downloads": 1, "downloads": 0 } }\n```\n\nOpen the returned `url` with no key at all and the file downloads. Open it a second time:\n\n```\n410 { "error": { "code": "link_exhausted", \u2026 } }\n```\n\nThe counter is burned inside the same conditional write that hands out the file, so two people\nclicking at the same instant cannot both win. Leave `max_downloads` off for an unlimited link;\n`ttl_seconds` is clamped to the `sharedLinks.maxTtl` you set in config.\n\n**7. The feed: follow, fan out, and the bell.** An activity is `actor` / `verb` / `object`\n(plus an optional `target`) \u2014 the shape every activity-stream system settled on.\n\n```bash\n# bo follows ana\'s personal feed\nvxil api POST /v1/feeds/timeline/user_bo/follows --data \'{"target":"user:user_ana"}\'\n# 201 { "data": { "follower": "timeline:user_bo", "followed": [ "user:user_ana" ] } }\n\n# ana posts \u2014 it fans out to her followers\' timelines, and pings bo\'s bell directly\nvxil api POST /v1/feeds/user/user_ana/activities \\\n --data \'{"actor":"user_ana","verb":"post","object":"room:design-review","foreign_id":"post-1","to":["notification:user_bo"]}\'\n# 201 { "data": { "activities": [ { "id": "\u2026", "actor": "user_ana", "verb": "post", \u2026 } ] } }\n\nvxil api GET /v1/feeds/timeline/user_bo\n# 200 { "data": { "feed": "timeline:user_bo", "type": "flat", "activities": [ \u2026 ], "next_cursor": \u2026 } }\n\nvxil api GET /v1/feeds/notification/user_bo/count\n# 200 { "data": { "unseen": 1, "unread": 1, "total": 1 } }\n\nvxil api POST /v1/feeds/notification/user_bo/read --data \'{}\'\n# 200 { "data": { "user_id": "user_bo", "action": "read", "unseen": 0, "unread": 0, "total": 1 } }\n```\n\n`foreign_id` makes the write idempotent \u2014 the same `(foreign_id, time)` pair upserts instead of\nduplicating, which is exactly what you want when a retry replays a post. The notification group is\naggregated (`{{ verb }}:{{ object }}:{{ time|date }}`), so twelve likes on one post are one badge\nline, not twelve.\n\n**8. A posting budget, with one exception.** The budget can be **declared** in `vxil.config.ts`\n(`features[\'rate-limits\'].policies: [{ name: \'room-post\', key_template: \'post:{user_id}\', limit: 5, window_seconds: 60 }]`,\nconverged by `vxil push`; the funnel-saas blueprint shows the pattern), or created as a row, as here.\nThe per-user exception is always a row:\n\n```bash\nvxil api POST /v1/rate-limits/policies --data \'{"name":"room-post","key_template":"post:{user_id}","limit":5,"window_seconds":60}\'\n# 201 { "data": { "policy_id": "rl_\u2026", "name": "room-post", "key_template": "post:{user_id}",\n# "limit": 5, "window_seconds": 60, "behavior": "block", "algorithm": "sliding_window", \u2026 } }\n\n# the host gets more headroom than everyone else\nvxil api POST /v1/rate-limits/policies/rl_\u2026/overrides --data \'{"pattern":"post:user_ana","limit":100,"note":"room host"}\'\n# 201 { "data": { "override_id": "rlo_\u2026", "pattern": "post:user_ana", "limit": 100, \u2026 } }\n\nvxil api POST /v1/rate-limits/check --data \'{"policy":"room-post","key_values":{"user_id":"user_bo"}}\'\n# 200 { "data": { "allowed": true, "remaining": 4, "reset_seconds": 60, "behavior": "block" } }\n# \u2026and as user_ana the same call answers `"remaining": 99` with the `override_id` that granted it.\n```\n\nThe check names the policy (`"policy":"room-post"`) instead of carrying its id; a name created or\nrenamed in the last minute can briefly answer `404`, so in a script that creates the policy and checks\nat once, use `"policy_id"` from the create. Call it six times as `user_bo` and the sixth is\n`429 rate_limited` with `Retry-After`; as\n`user_ana` it keeps going. A `pattern` may be a literal or a glob (`post:staff-*`); an exact match\nalways beats a glob, and the longest literal prefix wins between globs. Give an override up to\nabout half a minute to take effect \u2014 the lookup is cached, which is the price of it costing nothing\non the hot path.\n\n**9. One config line, and a database write becomes a live frame.** The `cdc` rule in\n`vxil.config.ts` is already pushed. Write a room:\n\n```bash\nvxil api PATCH /v1/cms/items/rooms/<room item_id> --data \'{"data":{"state":"closed"}}\'\n```\n\nAnyone holding the `lobby` channel receives:\n\n```json\n{ "event": "cms.item.updated",\n "data": { "collection": "rooms", "item_id": "itm_\u2026", "status": "draft",\n "at": "\u2026", "request_id": "req_\u2026", "data": { "name": "Design review", "state": "closed", \u2026 } },\n "ts": 1772000000000 }\n```\n\nNo function, no polling, no second write path. Two things to know before you rely on it: the\nchannel name is **literal**, not templated \u2014 one rule is one channel, so declare a second rule for\na second channel \u2014 and delivery is **at-most-once**, fire-and-forget after the write commits. It is\na live view. When a missed event would be a bug, use a durable subscription or a function on the\nwrite instead, both of which ride the at-least-once spine.\n\n## The client half\n\nThe browser side of this is published, so you do not write it twice:\n\n- **`@vxil/realtime`** \u2014 the reconnecting channel client (`RealtimeClient`,\n `createRealtimeClient`), including the presence events.\n- **`@vxil/react/feed`** \u2014 `FeedProvider`, `FeedTimeline` and `NotificationBell`\n over the feed API, with the badge count kept live.\n- **`@vxil/react/comments`** \u2014 `CommentThread` plus `UploadWidget` / `Dropzone`,\n which drive exactly the three-step upload above.\n\nThe uploader and the thread are separate components for the same reason the API has no attachment\nfield: the link between a file and a comment is your product\'s decision.\n\n## What to learn from this\n\n- **A browser holds a token, never a key.** Every live surface here is reachable from a client\n because the client is handed a short-lived, subject-bound credential your server minted.\n- **Presence is not a table.** It is whatever sockets are open right now. Treating it as durable\n state is the classic mistake; the roster endpoint exists for the first paint, not as a source of\n truth.\n- **Transport and memory are different features.** The channel is for people who are here. The feed\n is for people who were not. Every "unread" bug is one of those two doing the other\'s job.\n- **Idempotency is a field, not a promise.** `foreign_id` on an activity, `unique` on a cms field,\n `max_downloads` on a link \u2014 each one turns "please don\'t run twice" into something the database\n enforces.\n- **A change-data rule is a live view, not a delivery guarantee.** One line gets you a stream. Two\n words \u2014 *at most once* \u2014 tell you when not to use it.\n\n**Pairs with:** the **community** blueprint (threads and votes without the live layer) and\nthe **microblog** blueprint (follows and a public timeline).\n',
18681
18776
  "functions": {}
18682
18777
  },
18683
18778
  {
@@ -18824,7 +18919,7 @@ export default defineConfig({
18824
18919
  },
18825
18920
  });
18826
18921
  `,
18827
- "readme": "# Docs Site / Changelog template\n\nA **public content site** \u2014 documentation sections and pages plus a changelog feed \u2014 declared end-to-end\nin one typed `vxil.config.ts`. This is the canonical showcase of **cms public delivery**\n(vxil.com/docs/guide/04-data-with-cms): every reader-facing collection is `public: true`, so a static/JAMstack\nfront-end serves the whole site over the **keyless, edge-cached** `GET /v1/cms/public/:tenantId/:collection`\nlane \u2014 **no API key on the read path at all**. Authors write drafts behind an API key; readers see only\n**published** rows, served from the edge cache.\n\n**What it provisions (all `cms` collections you own and can edit):**\n- `sections` \u2014 the doc nav tree (title, unique `slug`, `order` for the sidebar, summary). **Public.**\n- `pages` \u2014 the documentation pages (title, unique `slug`, a `section` relation slot-bound for the\n single-hop join, `order`, markdown `body`, `updated_at`, JSON `tags`). **Public.** A Lane-A hook\n requires a title and a lowercase slug.\n- `changelog` \u2014 a release feed (`version`, `title`, `kind`, `released_at`, markdown `body`). **Public.**\n A Lane-A hook requires a version.\n- Feature: `cms` with `draftPublish` \u2014 write as draft, publish live.\n\n**Apply it:**\n\n```bash\nvxil init --template docs-site\nvxil quickstart # a fresh backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks (the `public: true` flags ride push \u2014 guide ch. 4, public delivery)\nvxil gen # typed SDK + per-tenant MCP catalog\n```\n\n## The whole point: a keyless public read path\n\nBecause `sections`, `pages`, and `changelog` are `public: true`, your front-end reads them with **no API\nkey** \u2014 the edge mints a restricted read-only token bound to your tenant, forces `status = 'published'`,\nand edge-caches the response (`s-maxage=60`, `stale-while-revalidate`). Drafts are never served.\n\n```ts\n// Reader front-end \u2014 NO api key, no Vxil client, no auth. The served SDK's\n// keyless helper (vxil.com/docs/guide/04-data-with-cms); or hit the URL with plain fetch.\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n\nconst TENANT = 'ten_your_tenant_id';\n\n// the sidebar: published sections, in order\nconst { items: sections } = await listCmsPublic(TENANT, 'sections', { sort: 'order', limit: 100 });\n\n// one page by slug (published only \u2014 a draft slug 404s to the reader)\nconst { items: [page] } = await listCmsPublic(TENANT, 'pages', { filter: { slug: 'introduction' }, limit: 1 });\n\n// every page in the \"guides\" section \u2014 the single-hop dotted-key join (guide ch. 4, relational depth)\nconst guides = await listCmsPublic(TENANT, 'pages', { filter: { 'section.slug': 'guides' }, sort: 'order' });\n\n// the changelog, newest first (released_at is slot-bound \u21D2 index-served)\nconst { items: releases } = await listCmsPublic(TENANT, 'changelog', { sort: '-released_at', limit: 25 });\n```\n\nOr with plain `fetch` (any language, any runtime):\n\n```bash\ncurl \"https://api.vxil.com/v1/cms/public/$TENANT/pages?sort=order&limit=100\"\n```\n\n## What to learn from this\n\n1. **Public delivery is one collection flag** (vxil.com/docs/guide/04-data-with-cms) \u2014 `public: true` opts a\n collection into the keyless lane; it is layered **on top of** the tenant isolation boundary, never replacing it.\n Carried by **both** push paths (the `vxil push` reconciler AND control-plane `/v1/apply`), so the flag\n is not read-only inert.\n2. **Published-only, default-deny** \u2014 the lane FORCES `status = 'published'`; a `$status` filter is\n rejected, so no query param can ever surface a draft. Keep a page draft while writing and it stays\n private until you publish it \u2014 you can stage a whole release behind an API key, then publish atomically.\n3. **Owner ids never leak** \u2014 this lane is owner-**unscoped** by design (public content is not per-user);\n if a public collection also declares an `ownerField`, it is stripped from every served row. (The doc\n collections here declare none \u2014 they are shared content.)\n4. **Editorial invariants ride as tenant-owned Lane-A hooks** \u2014 \"a page needs a title\", \"slug must be\n lowercase\", \"a release needs a version\" are AST-checked safe expressions in **your** config, run inside\n the write transaction (guide ch. 7), not platform code.\n5. **Honest limits** (guide ch. 4, public delivery) \u2014 the public lane is **read-only** (no keyless writes), serves the safe query\n subset (`filter`/`sort`/`limit`/`cursor`) only \u2014 not the relational read-models \u2014 and never more\n than **100 rows/page**. Freshness is eventually-consistent within `s-maxage=60` of a change.\n\n## Add reader comments (optional)\n\nTo let readers comment on a page, enable the `comments` feature and drop the\n[`@vxil/react/comments`](../../packages/react) widget onto your page (a pure client-side component\nover the shipped comments API). The public docs stay keyless; comments authenticate as end-users.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\n`templates/blog/` (an editorial variant with reader comments) \xB7 `templates/catalog/` (a public product grid).\n\n**Own the shape.** The config is yours after `init` \u2014 add a `docs`-vs-`api` section type, an `authors`\nrelation, a search-index collection. Nothing is locked.\n",
18922
+ "readme": "# Docs Site / Changelog template\n\nA **public content site** \u2014 documentation sections and pages plus a changelog feed \u2014 declared end-to-end\nin one typed `vxil.config.ts`. This is the canonical showcase of **cms public delivery**\n(vxil.com/docs/guide/04-data-with-cms): every reader-facing collection is `public: true`, so a static/JAMstack\nfront-end serves the whole site over the **keyless, edge-cached** `GET /v1/cms/public/:tenantId/:collection`\nlane \u2014 **no API key on the read path at all**. Authors write drafts behind an API key; readers see only\n**published** rows, served from the edge cache.\n\n**What it provisions (all `cms` collections you own and can edit):**\n- `sections` \u2014 the doc nav tree (title, unique `slug`, `order` for the sidebar, summary). **Public.**\n- `pages` \u2014 the documentation pages (title, unique `slug`, a `section` relation slot-bound for the\n single-hop join, `order`, markdown `body`, `updated_at`, JSON `tags`). **Public.** A Lane-A hook\n requires a title and a lowercase slug.\n- `changelog` \u2014 a release feed (`version`, `title`, `kind`, `released_at`, markdown `body`). **Public.**\n A Lane-A hook requires a version.\n- Feature: `cms` with `draftPublish` \u2014 write as draft, publish live.\n\n**Apply it:**\n\n```bash\nmkdir my-docs && cd my-docs\nvxil init --template docs-site\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the collections + hooks\n # (the `public: true` flags ride push) and writes vxil.types.ts\n # (or `vxil link <slug> --env staging` + `vxil push` for an existing backend)\nvxil seed # two sections + the Introduction page (all drafts)\n```\n\n**See it work.** Seeded rows are drafts, and the public lane serves published rows only. Publish the\nIntroduction page, then read it with no key:\n\n```bash\nID=$(vxil api GET /v1/cms/items/pages | jq -r '.items[0].item_id')\nvxil api POST /v1/cms/items/pages/$ID/publish\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_TENANT_ID (and your key) into the shell\ncurl \"https://api.vxil.com/v1/cms/public/$VXIL_TENANT_ID/pages?sort=order\"\n```\n\nPublish the two sections the same way to fill the sidebar. The seed leaves each page's `section` empty\n(a seed cannot know the ids the server generates): set it once both exist, for example\n`vxil api PATCH /v1/cms/items/pages/$ID --data '{\"data\":{\"section\":\"<section item_id>\"}}'`. The public lane\ncaches for up to 60 seconds. After you edit `vxil.config.ts`, `vxil push` applies it and regenerates the types.\n\n## The whole point: a keyless public read path\n\nBecause `sections`, `pages`, and `changelog` are `public: true`, your front-end reads them with **no API\nkey**: readers get your published rows only, read-only, and the response is edge-cached\n(`s-maxage=60`, `stale-while-revalidate`). Drafts are never served.\n\n```ts\n// Reader front-end \u2014 NO api key, no Vxil client, no auth. The served SDK's\n// keyless helper (vxil.com/docs/guide/04-data-with-cms); or hit the URL with plain fetch.\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n\nconst TENANT = '00000000-0000-0000-0000-000000000000'; // yours: the `tenant:` line quickstart printed\n\n// the sidebar: published sections, in order\nconst { items: sections } = await listCmsPublic(TENANT, 'sections', { sort: 'order', limit: 100 });\n\n// one page by slug (published only: a draft slug is simply not there, an empty `items`)\nconst { items: [page] } = await listCmsPublic(TENANT, 'pages', { filter: { slug: 'introduction' }, limit: 1 });\n\n// every page in the \"guides\" section: resolve the slug to an id, then filter by the relation\n// (the public lane takes no dotted join keys like 'section.slug' \u2014 those work on the keyed list only)\nconst { items: [guidesSection] } = await listCmsPublic(TENANT, 'sections', { filter: { slug: 'guides' }, limit: 1 });\nconst { items: guides } = await listCmsPublic(TENANT, 'pages', { filter: { section: guidesSection.item_id }, sort: 'order' });\n\n// the changelog, newest first (released_at is slot-bound \u21D2 index-served)\nconst { items: releases } = await listCmsPublic(TENANT, 'changelog', { sort: '-released_at', limit: 25 });\n```\n\nOr with plain `fetch` (any language, any runtime):\n\n```bash\ncurl \"https://api.vxil.com/v1/cms/public/$VXIL_TENANT_ID/pages?sort=order&limit=100\"\n```\n\n## What to learn from this\n\n1. **Public delivery is one collection flag** (vxil.com/docs/guide/04-data-with-cms) \u2014 `public: true` opts a\n collection into the keyless lane; it is layered **on top of** the tenant isolation boundary, never replacing it.\n Carried by **both** push paths (`vxil push` AND the server-side apply, `vxil push --server`), so the\n flag is never inert.\n2. **Published-only, default-deny** \u2014 the lane FORCES `status = 'published'`; a `$status` filter is\n rejected, so no query param can ever surface a draft. Keep a page draft while writing and it stays\n private until you publish it. You can stage a whole release as drafts behind an API key, then publish\n each page (`vxil api POST /v1/cms/items/pages/<id>/publish`). Publishing is per page \u2014 there is no\n all-at-once publish \u2014 so readers can briefly see part of a release, and the public cache adds up to\n 60 seconds on top.\n3. **Owner ids never leak** \u2014 this lane is owner-**unscoped** by design (public content is not per-user);\n if a public collection also declares an `ownerField`, it is stripped from every served row. (The doc\n collections here declare none \u2014 they are shared content.)\n4. **Editorial invariants ride as tenant-owned Lane-A hooks** \u2014 \"a page needs a title\", \"slug must be\n lowercase\", \"a release needs a version\" are AST-checked safe expressions in **your** config, run inside\n the write transaction (guide ch. 7), not platform code.\n5. **Honest limits** (guide ch. 4, public delivery) \u2014 the public lane is **read-only** (no keyless writes), serves the safe query\n subset (`filter`/`sort`/`limit`/`cursor`) only \u2014 no dotted join keys and not the relational\n read-models \u2014 and never more than **100 rows/page**. Freshness is eventually-consistent within\n `s-maxage=60` of a change.\n\n## Add reader comments (optional)\n\nTo let readers comment on a page, enable the `comments` feature and drop the\n**`@vxil/react/comments`** widget (`npm i @vxil/react @vxil/sdk`) onto your page (a pure client-side component\nover the shipped comments API). The public docs stay keyless; comments authenticate as end-users.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nthe **blog** blueprint (an editorial variant with reader comments) \xB7 the **catalog** blueprint (a public product grid).\n\n**Own the shape.** The config is yours after `init` \u2014 add a `docs`-vs-`api` section type, an `authors`\nrelation, a search-index collection. Nothing is locked.\n",
18828
18923
  "functions": {}
18829
18924
  },
18830
18925
  {
@@ -18847,8 +18942,8 @@ export default defineConfig({
18847
18942
  "byoKeys": [
18848
18943
  "openai_key"
18849
18944
  ],
18850
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Journal\" \u2014 an AI-powered private journal, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 owner-scoped `entries` (each journal belongs to its writer)\n// \u2022 ai \u2192 the enrichment calls (summary + mood) \u2014 BYO provider key\n// \u2022 rag \u2192 \"ask your journal\": retrieval-grounded answers with citations\n// \u2022 vector-search \u2192 rag's retrieval leg (mock embedder by default, zero-config)\n// \u2022 functions \u2192 the async glue: enrich-on-write, ask endpoint, weekly cron\n// \u2022 notifications \u2192 the weekly digest email (mock provider until you wire one)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n//\n// ONE-TIME SETUP after `vxil push`: create the retrieval index (a vector-search\n// collection) once \u2014\n// curl -X POST https://api.vxil.com/v1/search/collections \\\n// -H \"Authorization: Bearer $VXIL_KEY\" -H \"Content-Type: application/json\" \\\n// -d '{\"collection\":\"journal\"}'\n// (dimensions/embedder come from the vector-search config defaults below).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe owner scoping: an end-user session may only touch collections\n // with an `ownerField` (`entries` has one); anything added later without\n // one answers `403 server_only` instead of being shared between users.\n strictEndUserScope: true,\n hooks: {\n // Every entry needs a title \u2014 a pure function of the row (Lane-A validate).\n entry_title: {\n collection: 'entries',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'an entry needs a title',\n },\n // Stamp written_at when the client omits it (Lane-A derive; `now` \u2014 one\n // ISO per request \u2014 is the write path's only ambient input).\n entry_written_at: {\n collection: 'entries',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'written_at',\n expr: 'coalesce(item.written_at, now)',\n },\n },\n },\n\n // The AI enrichment runs keyless out of the box: 'mock' is the deterministic\n // default provider. Go real by (1) `vxil secrets set ai/openai_key`, then\n // (2) flipping defaultProvider to 'openai' and defaults.model to a real one\n // (e.g. 'gpt-4.1-mini'). The keyRef below already points at the secret.\n ai: {\n defaultProvider: 'mock',\n providers: { openaiKeyRef: 'openai_key' }, // \u2192 secrets.openai_key (encrypted at rest)\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0.4 },\n },\n\n // rag owns the retrieve\u2192ground\u2192generate\u2192cite pipeline; the prompt/synthesis\n // stay yours. `defaultCollection` lets callers omit `collection`.\n rag: {\n defaultCollection: 'journal',\n retrieval: { topK: 6 },\n },\n\n // rag's retrieval leg \u2014 must be enabled or /v1/rag/* answers 501. The 'mock'\n // embedder is the zero-config default; for real embeddings set\n // embed: { provider: 'openai', model: 'text-embedding-3-small', apiKeyRef: \u2026 }.\n 'vector-search': {},\n\n notifications: { provider: 'mock', fromEmail: 'digest@journal.app' },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n entries: {\n singular: 'entry',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified end-user\n // reads/writes only their OWN entries. Server callers are unaffected.\n ownerField: 'user_id',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n body: { type: 'text' },\n mood: { type: 'string', indexSlot: 's2' }, // AI-derived by on-entry-written\n summary: { type: 'text' }, // AI-derived by on-entry-written\n written_at: { type: 'datetime', indexSlot: 't1' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n tags: { type: 'json' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (async, cross-feature) \u2500\u2500\n functions: {\n // Enrich on write: re-fetch the entry, ai-generate a 1-sentence summary +\n // one-word mood, PATCH them back, ingest title+body into the rag index.\n 'on-entry-written': {\n entry: './functions/on-entry-written.ts',\n trigger: { kind: 'cmsHook', collection: 'entries', event: 'beforeWrite' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'rag:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // \"Ask your journal\": POST /v1/fn/ask-journal { question } \u2192 a grounded\n // answer with citations via POST /v1/rag/answer.\n 'ask-journal': {\n entry: './functions/ask-journal.ts',\n trigger: { kind: 'http' },\n scopes: ['rag:write'],\n },\n // Weekly digest: every Monday 08:00 UTC, list the week's entries and send\n // each writer a notifications digest.\n 'weekly-digest': {\n entry: './functions/weekly-digest.ts',\n trigger: { kind: 'cron', schedule: '0 8 * * 1' },\n scopes: ['cms:read', 'notifications:send'],\n },\n },\n\n // \u2500\u2500 SECRET REFERENCES (never values) \u2014 `vxil secrets set ai/openai_key` \u2500\u2500\n secrets: {\n openai_key: { feature: 'ai', description: 'OpenAI API key (BYO provider, encrypted at rest)' },\n },\n\n seed: {\n cms: [\n {\n collection: 'entries',\n items: [\n {\n title: 'Welcome to your AI journal',\n body: 'Write anything. On every save, a function summarizes the entry, names its mood, and indexes it \u2014 then you can literally ask your journal questions.',\n written_at: '2026-07-01T09:00:00Z',\n user_id: 'demo-user',\n tags: ['welcome'],\n },\n ],\n },\n ],\n },\n});\n",
18851
- "readme": '# AI Journal template\n\nAn AI-powered private journal \u2014 declared end-to-end in one typed `vxil.config.ts`. Every saved entry is\nenriched by a function (one-sentence summary + one-word mood via the `ai` feature) and indexed for retrieval,\nso you can literally *ask your journal* and get grounded, cited answers back.\n\n**What it provisions:**\n- `entries` \u2014 title, body, AI-derived `mood`/`summary`, `written_at`, tags, and `user_id` as the **owner field**\n (a verified end-user only sees their own journal). Lane-A hooks require a title and stamp `written_at`.\n `cms.strictEndUserScope: true` makes any collection you add later without an owner field server-only for\n end users (`403 server_only`) instead of shared between them.\n- Features: `cms` + `ai` + `rag` + `vector-search` (rag\'s retrieval leg) + `notifications` + `functions`.\n- Functions: `on-entry-written` (cmsHook: enrich + ingest), `ask-journal` (http: grounded Q&A),\n `weekly-digest` (cron: Monday digest per writer).\n\n**Apply it:**\n\n```bash\nvxil init --template ai-journal\nvxil quickstart --env staging --no-push --invite <code> # --invite only when the email is new (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push\nvxil gen\n# one-time: create the retrieval index (dimensions/embedder come from config defaults)\ncurl -X POST https://api.vxil.com/v1/search/collections \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"collection":"journal"}\'\n```\n\n> **Plan note.** The three functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n1. **AI enrichment on write** \u2014 a `cmsHook` function re-fetches the entry by `item_id` (never trusts inline\n fields), calls `POST /v1/ai/generate` (raw-prompt mode), PATCHes `summary`/`mood` back, and latches on\n `summary` so its own write-back never re-enriches.\n2. **Retrieval-augmented "ask your journal"** \u2014 `POST /v1/rag/answer` retrieves top-k from the `journal`\n index and returns the answer *with citations* (`doc_id` = the entry\'s `item_id`); the prompt stays yours.\n3. **BYO AI key via encrypted secrets** \u2014 config carries only the reference (`providers.openaiKeyRef`);\n `vxil secrets set ai/openai_key` stores the value encrypted at rest, then flip `defaultProvider`/`model`.\n Until then the deterministic `mock` provider (and mock embedder) run the whole loop keyless.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ask-journal \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"question":"what made me happy this month?"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (ai, rag) \xB7 vxil.com/docs/guide/08-running-your-code-functions \xB7\nvxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/guide/04-data-with-cms (owner-scope) \xB7 `examples/ecommerce/` (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
18945
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Journal\" \u2014 an AI-powered private journal, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 owner-scoped `entries` (each journal belongs to its writer)\n// \u2022 ai \u2192 the enrichment calls (summary + mood) \u2014 BYO provider key\n// \u2022 rag \u2192 \"ask your journal\": retrieval-grounded answers with citations\n// \u2022 vector-search \u2192 rag's retrieval leg (mock embedder by default, zero-config)\n// \u2022 functions \u2192 the async glue: enrich-on-write, ask endpoint, weekly cron\n// \u2022 notifications \u2192 the weekly digest email (mock provider until you wire one)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n//\n// ONE-TIME SETUP after `vxil push`: create the retrieval index (a vector-search\n// collection) once \u2014\n// curl -X POST https://api.vxil.com/v1/search/collections \\\n// -H \"Authorization: Bearer $VXIL_KEY\" -H \"Content-Type: application/json\" \\\n// -d '{\"collection\":\"journal\"}'\n// (dimensions/embedder come from the vector-search config defaults below).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe owner scoping: an end-user session may only touch collections\n // with an `ownerField` (`entries` has one); anything added later without\n // one answers `403 server_only` instead of being shared between users.\n strictEndUserScope: true,\n hooks: {\n // Every entry needs a title \u2014 a pure function of the row (Lane-A validate).\n entry_title: {\n collection: 'entries',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'an entry needs a title',\n },\n // Stamp written_at when the client omits it (Lane-A derive; `now` \u2014 one\n // ISO per request \u2014 is the write path's only ambient input).\n entry_written_at: {\n collection: 'entries',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'written_at',\n expr: 'coalesce(item.written_at, now)',\n },\n },\n },\n\n // The AI enrichment runs keyless out of the box: 'mock' is the deterministic\n // default provider. Go real by (1) `vxil secrets set ai/openai_key`, then\n // (2) flipping defaultProvider to 'openai' and defaults.model to a real one\n // (e.g. 'gpt-4.1-mini'). The keyRef below already points at the secret.\n ai: {\n defaultProvider: 'mock',\n providers: { openaiKeyRef: 'openai_key' }, // \u2192 secrets.openai_key (encrypted at rest)\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0.4 },\n },\n\n // rag owns the retrieve\u2192ground\u2192generate\u2192cite pipeline; the prompt/synthesis\n // stay yours. `defaultCollection` lets callers omit `collection`.\n rag: {\n defaultCollection: 'journal',\n retrieval: { topK: 6 },\n },\n\n // rag's retrieval leg \u2014 must be enabled or /v1/rag/* answers 501. The 'mock'\n // embedder is the zero-config default; for real embeddings set\n // embed: { provider: 'openai', model: 'text-embedding-3-small', apiKeyRef: \u2026 }.\n 'vector-search': {},\n\n notifications: { provider: 'mock', fromEmail: 'digest@journal.example' },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n entries: {\n singular: 'entry',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified end-user\n // reads/writes only their OWN entries. Server callers are unaffected.\n ownerField: 'user_id',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n body: { type: 'text' },\n mood: { type: 'string', indexSlot: 's2' }, // AI-derived by on-entry-written\n summary: { type: 'text' }, // AI-derived by on-entry-written\n written_at: { type: 'datetime', indexSlot: 't1' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n tags: { type: 'json' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (async, cross-feature) \u2500\u2500\n functions: {\n // Enrich on write: re-fetch the entry, ai-generate a 1-sentence summary +\n // one-word mood, PATCH them back, ingest title+body into the rag index.\n 'on-entry-written': {\n entry: './functions/on-entry-written.ts',\n trigger: { kind: 'cmsHook', collection: 'entries', event: 'beforeWrite' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'rag:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // \"Ask your journal\": POST /v1/fn/ask-journal { question } \u2192 a grounded\n // answer with citations via POST /v1/rag/answer.\n 'ask-journal': {\n entry: './functions/ask-journal.ts',\n trigger: { kind: 'http' },\n scopes: ['rag:write'],\n },\n // Weekly digest: every Monday 08:00 UTC, list the week's entries and send\n // each writer a notifications digest.\n 'weekly-digest': {\n entry: './functions/weekly-digest.ts',\n trigger: { kind: 'cron', schedule: '0 8 * * 1' },\n scopes: ['cms:read', 'notifications:send'],\n },\n },\n\n // \u2500\u2500 SECRET REFERENCES (never values) \u2014 `vxil secrets set ai/openai_key` \u2500\u2500\n secrets: {\n openai_key: { feature: 'ai', description: 'OpenAI API key (BYO provider, encrypted at rest)' },\n },\n\n seed: {\n cms: [\n {\n collection: 'entries',\n items: [\n {\n title: 'Welcome to your AI journal',\n body: 'Write anything. On every save, a function summarizes the entry, names its mood, and indexes it \u2014 then you can literally ask your journal questions.',\n written_at: '2026-07-01T09:00:00Z',\n user_id: 'demo-user',\n tags: ['welcome'],\n },\n ],\n },\n ],\n },\n});\n",
18946
+ "readme": '# AI Journal template\n\nAn AI-powered private journal \u2014 declared end-to-end in one typed `vxil.config.ts`. Every saved entry is\nenriched by a function (one-sentence summary + one-word mood via the `ai` feature) and indexed for retrieval,\nso you can literally *ask your journal* and get grounded, cited answers back.\n\n**What it provisions:**\n- `entries` \u2014 title, body, AI-derived `mood`/`summary`, `written_at`, tags, and `user_id` as the **owner field**\n (a verified end-user only sees their own journal). Lane-A hooks require a title and stamp `written_at`.\n `cms.strictEndUserScope: true` makes any collection you add later without an owner field server-only for\n end users (`403 server_only`) instead of shared between them.\n- Features: `cms` + `ai` + `rag` + `vector-search` (rag\'s retrieval leg) + `notifications` + `functions`.\n- Functions: `on-entry-written` (cmsHook: enrich + ingest), `ask-journal` (http: grounded Q&A),\n `weekly-digest` (cron: Monday digest per writer).\n\n**Apply it:**\n\n```bash\nmkdir my-journal && cd my-journal\nvxil init --template ai-journal\nvxil quickstart --env staging --no-push --invite <code> # --invite only when the email is new (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + the three functions; writes vxil.types.ts\n# one-time, BEFORE the first entry: create the retrieval index (dimensions/embedder come from config defaults)\nvxil api POST /v1/search/collections --data \'{"collection":"journal"}\'\nvxil seed # one welcome entry \u2014 its save runs the enrich + index function\n```\n\nThe index must exist before the first entry is saved: the save\'s function ingests into `journal`, and an\ningest into a collection that does not exist answers `404 collection_not_found`. If you seeded first,\ncreate the collection, then re-run the enrichment for that entry by clearing its summary:\n`vxil api PATCH /v1/cms/items/entries/<item_id> --data \'{"data":{"summary":null}}\'`.\n\n**See it work.** The seeded entry gains a `summary` and a `mood` a moment after it is saved\n(`vxil api GET /v1/cms/items/entries`), and the journal answers questions:\n\n```bash\nvxil functions invoke ask-journal --data \'{"question":"what is this journal for?"}\'\n# \u2192 an answer with citations; each citation\'s doc_id is an entry\'s item_id\n```\n\n> **Plan note.** The three functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n1. **AI enrichment on write** \u2014 a `cmsHook` function re-fetches the entry by `item_id` (never trusts inline\n fields), calls `POST /v1/ai/generate` (raw-prompt mode), PATCHes `summary`/`mood` back, and latches on\n `summary` so its own write-back never re-enriches.\n2. **Retrieval-augmented "ask your journal"** \u2014 `POST /v1/rag/answer` retrieves top-k from the `journal`\n index and returns the answer *with citations* (`doc_id` = the entry\'s `item_id`); the prompt stays yours.\n3. **BYO AI key via encrypted secrets** \u2014 config carries only the reference (`providers.openaiKeyRef`);\n the value is stored encrypted at rest. Until you go real, the deterministic `mock` provider and the\n `mock` embedder run the whole loop keyless (next section).\n\nThe same question from your server, over HTTP (`vxil env pull` puts your key in `.env.local` as `VXIL_API_KEY`):\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ask-journal \\\n -H "Authorization: Bearer $VXIL_API_KEY" -H "Content-Type: application/json" \\\n -d \'{"question":"what made me happy this month?"}\'\n```\n\n## Going real: two separate switches\n\nThe blueprint runs on two test doubles, and **nothing in a response says so**. The `mock` AI provider\nwrites canned text, and the `mock` embedder turns text into a hash, not meaning: "ask your journal" still\nreturns ranked, cited answers, but the ranking is not semantic. Swapping the AI model does **not** change\nthe embedder, so do both.\n\n1. **The AI provider** (summaries, moods, answers):\n ```bash\n vxil secrets set ai/openai_key # hidden prompt, or pipe it: printf \'%s\' "$OPENAI_KEY" | vxil secrets set ai/openai_key\n ```\n then in `vxil.config.ts` set `ai.defaultProvider: \'openai\'` and `ai.defaults.model` to a real model\n (for example `\'gpt-4.1-mini\'`), and `vxil push`.\n2. **The embedder** (retrieval). Each search collection keeps the embedder it was created with, so\n changing the config alone leaves `journal` on `mock`:\n ```bash\n vxil secrets set vector-search/openai_key\n ```\n set `\'vector-search\': { embed: { provider: \'openai\', model: \'text-embedding-3-small\', apiKeyRef: \'secret:openai_key\', dimensions: 1536 } }`\n and `vxil push`; then re-send the collection create with the same width, which updates its embedder in place:\n ```bash\n vxil api POST /v1/search/collections --data \'{"collection":"journal","dimensions":1536,"embed":{"provider":"openai","model":"text-embedding-3-small","apiKeyRef":"secret:openai_key"}}\'\n ```\n Vectors already stored were made by `mock`, so re-index every entry: clear each entry\'s `summary`\n (`vxil api PATCH /v1/cms/items/entries/<item_id> --data \'{"data":{"summary":null}}\'`) and the function\n enriches and ingests it again. A different width (not 1536) needs a new collection name, since a\n collection\'s width cannot change.\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (ai, rag) \xB7 vxil.com/docs/guide/08-running-your-code-functions \xB7\nvxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/guide/04-data-with-cms (owner-scope) \xB7 the **storefront** blueprint (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
18852
18947
  "functions": {
18853
18948
  "ask-journal.ts": "// ask-journal.ts \u2014 \"ASK YOUR JOURNAL\" (a vxil function).\n//\n// Trigger: http \u2014 POST /v1/fn/ask-journal { question, user_id? }. Runs ONE\n// retrieval-augmented call: POST /v1/rag/answer over the `journal` index the\n// on-entry-written function keeps fed. rag retrieves top-k chunks from\n// vector-search, grounds the tenant-owned prompt, generates via the ai feature,\n// and returns the answer WITH citations pointing at the exact entries used \u2014\n// this function is a thin, scoped wrapper (rag:write only).\n//\n// In end-user mode the verified principal is propagated automatically into the\n// scoped token, so retrieval is owner-scoped; in server mode an optional\n// `user_id` rides along for per-user metering.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ question?: string; user_id?: string }>;\ninterface Citation { chunk_id?: string; doc_id?: string; score?: number }\ninterface AnswerRes { data?: { answer?: string; citations?: Citation[]; usage?: Record<string, unknown> } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const rag = env.scoped_jwts?.rag;\n if (!rag) return json({ error: 'missing rag scope' }, 403);\n\n const question = String(env.payload?.question ?? '').trim();\n if (!question) return json({ error: 'question required', example: { question: 'what made me happy last month?' } }, 400);\n\n const res = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n query: question.slice(0, 2000),\n collection: 'journal', // = rag config defaultCollection; explicit for clarity\n ...(env.payload?.user_id ? { user_id: env.payload.user_id } : {}),\n }),\n });\n if (!res.ok) {\n // A missing index is NOT a 404 here: rag's retrieve leg wraps a\n // vector-search failure as 502 retrieval_failed and attaches the\n // downstream error under error.upstream (only a 501 passes through),\n // so detect collection_not_found in the BODY, not the status. The\n // index is a one-time setup (see the template README).\n const errBody = (await res.json().catch(() => null)) as\n { error?: { code?: string; upstream?: { code?: string } } } | null;\n const code = errBody?.error?.upstream?.code ?? errBody?.error?.code;\n if (res.status === 404 || code === 'collection_not_found') {\n return json({ error: 'journal index not found', hint: 'POST /v1/search/collections {\"collection\":\"journal\"} once, then write an entry' }, 404);\n }\n return json({ error: 'answer_failed', status: res.status }, 502);\n }\n\n const body = (await res.json()) as AnswerRes;\n return json({\n answer: body.data?.answer ?? '',\n // provenance: which entries grounded the answer (doc_id = the entry's item_id).\n // `score` is a RANK value (~0.016\u20130.033 at the top) that orders the\n // citations \u2014 never threshold on it. For \"is this close enough?\", preview\n // with POST /v1/rag/search and threshold each hit's `similarity` (\u22121..1).\n sources: (body.data?.citations ?? []).map((c) => ({ entry_id: c.doc_id, score: c.score })),\n }, 200);\n },\n};\n\n// \u2500\u2500 tiny helper \u2500\u2500\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
18854
18949
  "on-entry-written.ts": "// on-entry-written.ts \u2014 AI ENRICHMENT ON WRITE (a vxil function).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `entries`. The hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// entry by id (through the edge, tenant-scoped), then:\n// 1. asks the ai feature (POST /v1/ai/generate, raw-prompt mode) for a\n// ONE-sentence summary and a ONE-word mood,\n// 2. PATCHes them back onto the entry (merge-patch; the summary-present LATCH\n// keeps our own write-back from re-enriching \u2014 clear `summary` to redo),\n// 3. ingests title+body into the rag retrieval index (POST /v1/rag/ingest/\n// journal \u2014 the vector-search passthrough) so ask-journal can ground on it.\n// At-least-once delivery is safe to redeliver: the summary latch skips a\n// re-enrich, the PATCH is idempotent by content, and the ingest converges \u2014\n// vector-search upserts by doc_id, so re-ingesting the same entry re-indexes\n// in place rather than duplicating.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface EntryData { title?: string; body?: string; summary?: string; mood?: string; written_at?: string; user_id?: string }\ninterface Item { data?: { data?: EntryData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const rag = env.scoped_jwts?.rag;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'entries' || !cms || !ai || !rag || !itemId) {\n return Response.json({ skipped: true });\n }\n\n // Re-fetch the entry (the payload carries only the id \u2014 never trust inline fields).\n const res = await fetch(`${base}/v1/cms/items/entries/${itemId}`, { headers: H(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const entry = ((await res.json()) as Item).data?.data ?? {};\n if (entry.summary) return Response.json({ skipped: true, reason: 'already enriched' });\n if (!entry.body) return Response.json({ skipped: true, reason: 'no body yet' });\n\n // 1. AI enrichment \u2014 two small raw-prompt generations ({ data: { text } }).\n const text = entry.body.slice(0, 6000);\n const summary = clip(await generate(base, ai,\n `Summarize this journal entry in exactly one sentence, first person:\\n\\n${text}`, 80, entry.user_id), 400);\n const moodRaw = await generate(base, ai,\n `Answer with ONE lowercase word (e.g. joyful, anxious, calm, tired) naming the dominant mood of this journal entry:\\n\\n${text}`, 8, entry.user_id);\n const mood = (moodRaw.trim().split(/\\s+/)[0] ?? '').toLowerCase().replace(/[^a-z-]/g, '').slice(0, 24);\n if (!summary) return Response.json({ skipped: true, reason: 'ai unavailable' });\n\n // 2. PATCH the derived fields back (merge-patch keys; bumps `version`).\n const patch = await fetch(`${base}/v1/cms/items/entries/${itemId}`, {\n method: 'PATCH',\n headers: H(cms),\n body: JSON.stringify({ data: { summary, ...(mood ? { mood } : {}) } }),\n });\n\n // 3. Ingest into the retrieval index (rag \u2192 vector-search passthrough, 202).\n // Idempotent by doc_id: vector-search UPSERTs on (collection, doc_id), so a\n // redelivered hook (or an edited entry) re-indexes in place.\n const ing = await fetch(`${base}/v1/rag/ingest/journal`, {\n method: 'POST',\n headers: H(rag),\n body: JSON.stringify({\n doc_id: itemId,\n ...(entry.user_id ? { user_id: entry.user_id } : {}),\n text: `${entry.title ?? ''}\\n\\n${entry.body}`,\n metadata: { ...(mood ? { mood } : {}), ...(entry.written_at ? { written_at: entry.written_at } : {}) },\n }),\n });\n return Response.json({\n enriched: patch.ok,\n mood,\n ingested: ing.ok,\n // the index is a one-time setup: POST /v1/search/collections {\"collection\":\"journal\"}\n ...(ing.status === 404 ? { hint: 'create the journal index first (see the template README)' } : {}),\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n/** One raw-prompt sync generation; '' on any failure (enrichment is best-effort). */\nasync function generate(base: string, jwt: string, prompt: string, maxTokens: number, userId?: string): Promise<string> {\n const r = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(jwt),\n body: JSON.stringify({ prompt, max_tokens: maxTokens, ...(userId ? { user_id: userId } : {}) }),\n }).catch(() => null);\n if (!r || !r.ok) return '';\n return String(((await r.json()) as { data?: { text?: string } }).data?.text ?? '');\n}\nconst clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s);\n",
@@ -18879,7 +18974,7 @@ export default defineConfig({
18879
18974
  "vxil_read_key"
18880
18975
  ],
18881
18976
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Agent Desk\" \u2014 the AGENT blueprint. A deliberately small support desk that\n// exercises the whole AI half, each piece doing exactly one job:\n//\n// \u2022 ai classify \u2192 what KIND of ticket is this? (a forced-label verdict)\n// \u2022 ai judge \u2192 how GOOD is this draft? (a forced-score verdict)\n// \u2022 vector-search\u2192 the knowledge index, kept in step with the `kb` collection\n// \u2022 rag \u2192 a grounded, CITED answer over that index\n// \u2022 copilot \u2192 the in-app assistant, with a propose \u2192 confirm action\n// catalog so a write never happens behind the user's back\n// \u2022 mcp \u2192 the same backend as TOOLS, narrowed to a least-privilege\n// agent key\n// \u2022 functions \u2192 the three deterministic steps: triage on write, draft on\n// a button, and a capability probe an agent can call to\n// discover what this backend can react to\n//\n// Everything runs on the deterministic `mock` AI provider until you add a key,\n// so the walkthrough is reproducible with no provider account.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // The model layer. `mock` is deterministic \u2014 swap `defaultProvider` and add\n // ONE key reference to run the same config on a real model.\n ai: {\n enabled: true,\n defaultProvider: 'mock',\n // providers: { openaiKeyRef: 'openai_key' }, // then defaultProvider: 'openai'\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0 },\n cache: { ttlSeconds: 0 },\n streaming: { enabled: true },\n },\n\n // The knowledge index. `mock` embeddings need no key; auto-sync keeps the\n // index in step with the `kb` cms collection so there is no second place to\n // remember to update.\n 'vector-search': {\n enabled: true,\n embed: { provider: 'mock', dimensions: 1536 },\n chunking: { maxTokens: 512, overlap: 64 },\n hybrid: { defaultMode: 'hybrid' },\n sync: [\n {\n source: 'cms',\n cmsCollection: 'kb', // the collection you author in\n collection: 'kb', // the index rag reads\n fields: ['title', 'body'], // what gets embedded\n metadataFields: ['topic'], // \u2026and what stays filterable\n statusFilter: 'published', // drafts are never indexed\n cron: '*/15 * * * *',\n },\n ],\n },\n\n // Retrieval \u2192 grounded answer. NOTE: `defaultTemplate` names a PROMPT\n // TEMPLATE, which is a versioned row you create with\n // `POST /v1/ai/templates` \u2014 prompts are yours, not config (see the README).\n rag: {\n enabled: true,\n defaultCollection: 'kb',\n defaultTemplate: 'support-answer',\n retrieval: { topK: 5, mode: 'hybrid' },\n context: { maxTokens: 2000, strategy: 'topk' },\n citations: true,\n streaming: true,\n },\n\n // The in-app assistant. `actions.mode: 'actions'` is what turns a read-only\n // chat into one that can PROPOSE a write; `requireConfirm` is what makes\n // the human the one who commits it.\n copilot: {\n enabled: true,\n agents: {\n desk: {\n name: 'Desk',\n tone: 'Concise, factual, never speculative.',\n greeting: 'Ask me about a ticket, or about anything in the knowledge base.',\n knowledge: { collections: ['kb'], useRag: true },\n citations: true,\n grounding: 'strict', // answer only from retrieved context\n actions: {\n mode: 'actions',\n // The keys ARE tool names from the catalog \u2014 an unknown key is\n // inert, never invented. Reads run inline; writes are proposed.\n allow: {\n cms_query_items: { requireConfirm: false },\n cms_create_item: { requireConfirm: true },\n cms_run_item_action: { requireConfirm: true },\n },\n },\n guardrails: {\n maxTurnsPerSession: 20,\n rateLimitPerUserPerDay: 50,\n maxInputChars: 4000,\n refusalMessage: \"I can only answer from this workspace's knowledge base.\",\n allowGuest: false,\n },\n },\n },\n limits: { consumeCredits: false },\n widget: { enabled: false, requireAuth: true, allowedOrigins: [] },\n },\n\n // The same backend, exposed as TOOLS. `custom` + an explicit list is the\n // least-privilege posture: the agent sees these and nothing else.\n mcp: {\n enabled: true,\n exposureLevel: 'custom',\n allowToolList: [\n 'cms_query_items',\n 'cms_create_item',\n 'cms_run_item_action',\n 'ai_classify',\n 'ai_judge',\n 'rag_answer',\n 'rag_search',\n 'search_query',\n 'webhooks_event_catalog',\n 'vxil_tool_search',\n ],\n rateLimits: { toolCallsPerMin: 60 },\n branding: {\n serverName: 'Agent Desk',\n serverInstructions:\n 'Answer from the knowledge base and cite it. Classify a ticket before replying. '\n + 'Never create or modify a record without the user confirming it first.',\n },\n // Config-declared prompts the agent can pull instead of you pasting one.\n prompts: {\n triage: {\n description: 'Triage an inbound ticket end to end.',\n template:\n 'Classify ticket {{ticket_id}} with ai_classify, then use rag_answer to draft a reply '\n + 'grounded in the knowledge base. Show me the draft and its citations before writing anything.',\n arguments: [{ name: 'ticket_id', description: 'the cms item id', required: true }],\n },\n },\n },\n\n // Enabled for ONE reason in this blueprint: the event CATALOG \u2014 the\n // machine-readable list of everything this backend can emit, which is how\n // an agent discovers what it is able to react to.\n webhooks: { enabled: true, maxSubscriptions: 5 },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n tickets: {\n singular: 'ticket',\n // ONE button per row. Pressing \"Draft reply\" runs the rag + judge step\n // once, as a human-initiated action \u2014 not on every write.\n actions: [{ key: 'draft_reply', label: 'Draft reply', fn: 'draft-reply' }],\n fields: {\n subject: { type: 'string', required: true, indexSlot: 's1' },\n requester: { type: 'string', indexSlot: 's2' },\n // written by the triage function, never by hand\n category: { type: 'string', indexSlot: 's3' }, // billing | bug | how_to | other\n state: { type: 'string', indexSlot: 's4' }, // open | drafted | closed\n draft_score: { type: 'int', indexSlot: 'n1' }, // the judge's score, 0\u201310\n created_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n draft: { type: 'text' }, // the last grounded draft reply\n },\n },\n\n kb: {\n singular: 'kb_article',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', unique: true, indexSlot: 's2' },\n topic: { type: 'string', indexSlot: 's3' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // TRIAGE ON WRITE. Fires on every new ticket, re-fetches it (a hook\n // delivery carries ids, not the row), asks for a forced-label verdict, and\n // writes the label back. One model call, one field.\n 'triage-ticket': {\n entry: './functions/triage-ticket.ts',\n trigger: { kind: 'cmsHook', collection: 'tickets', event: 'beforeCreate' },\n scopes: ['cms:read', 'cms:write', 'ai:write'],\n egressAllow: [],\n },\n\n // DRAFT ON DEMAND. Invoked by the `draft_reply` record action: retrieve \u2192\n // ground \u2192 answer with citations, then score the draft against a rubric\n // before a human ever sees it.\n 'draft-reply': {\n entry: './functions/draft-reply.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'rag:write', 'ai:write'],\n egressAllow: [],\n },\n\n // CAPABILITY DISCOVERY. Returns the event names this backend can emit,\n // folded to the features it actually has on \u2014 the answer to an agent's\n // \"what can I react to here?\". The catalog is a control-plane read, so it\n // uses a narrow BYO key rather than the function's feature callback.\n 'agent-capabilities': {\n entry: './functions/agent-capabilities.ts',\n trigger: { kind: 'http' },\n scopes: [],\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n },\n\n secrets: {\n vxil_read_key: {\n feature: 'functions',\n description: 'a vxil API key of this backend holding ONLY features:read + webhooks:read',\n },\n // openai_key: { feature: 'ai', description: 'BYO model key \u2014 then set ai.defaultProvider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'kb',\n items: [\n {\n title: 'How refunds work',\n slug: 'how-refunds-work',\n topic: 'billing',\n published_at: '2026-02-10T09:00:00Z',\n body:\n 'A refund is issued to the original payment method within 14 days of purchase. '\n + 'Ask the customer for the order id, confirm the purchase date, then issue the refund '\n + 'from the billing screen. Refunds are not available after 14 days.',\n },\n ],\n },\n ],\n },\n});\n",
18882
- "readme": '# Agent Desk (ai)\n\nThe whole AI half of vxil on a deliberately small support desk \u2014 two collections, three functions,\nand one idea per feature. If you have been trying to work out where `ai`, `rag`, `vector-search`,\n`copilot` and `mcp` differ, this is the blueprint that answers it by making each one do exactly its\nown job.\n\n```bash\nvxil init --template agent-desk\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging`\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + the three functions\n```\n\n> **Plan note.** The three functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nEverything runs on the deterministic **`mock`** model provider, so the walkthrough below is\nreproducible with no provider account and no spend. Swapping in a real model is one config line and\none secret \u2014 nothing else in this blueprint changes.\n\n`vxil_read_key` is a key **of this same backend** carrying only `features:read` and `webhooks:read`;\nthe capability probe uses it (dashboard \u2192 API keys \u2192 create, tick those two and nothing else).\n\n## One idea per feature\n\n| Feature | The one thing it does here | Why it is not one of the others |\n|---|---|---|\n| `ai` classify | pick exactly one label from a fixed set | a chat prompt can return a paragraph; a classifier cannot |\n| `ai` judge | score a draft as an integer on a fixed scale | the model that writes is not the authority on whether the writing is good |\n| `vector-search` | hold the knowledge index, synced from `kb` | retrieval, not generation \u2014 no prompt lives here |\n| `rag` | answer **only** from what was retrieved, with citations | the pipeline; the *prompt* is a template you own |\n| `copilot` | the in-app assistant: propose a write, a human confirms | it is a composition over the four above, not a fifth model |\n| `mcp` | the same backend, as tools, narrowed per key | an agent\'s *interface*, not an agent |\n| `functions` | the deterministic steps around the model calls | the parts that must not be creative |\n\n## Prompts are yours, not config\n\n`rag.defaultTemplate: \'support-answer\'` names a **prompt template**, which is a versioned row you\ncreate over the API \u2014 deliberately not a config leaf, because a prompt is the part of the product\nyou iterate on hourly. Create it before the first answer:\n\n```bash\nvxil api POST /v1/ai/templates --data \'{\n "template": "support-answer",\n "system": "You are a support agent. Answer ONLY from the context. If the context does not contain the answer, say you do not know.",\n "user": "Context:\\n{{context}}\\n\\nCustomer question:\\n{{query}}\\n\\nWrite a short, direct reply."\n}\'\n# 201 { "data": { "template": "support-answer", "version": 1 } }\n```\n\nRe-POST the same name and you get version 2 \u2014 old versions stay pinnable. `{{query}}` and\n`{{context}}` are what the retrieval step fills in. **A grounded answer with no template is a 404**,\nso this is step zero, not an optional flourish.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `ai:read ai:write rag:read rag:write vector-search:read\nvector-search:write cms:read cms:write copilot:read copilot:write webhooks:read functions:invoke\nfeatures:read`.\n\n**1. The index.** The `kb` cms collection is what you author in; the `kb` vector collection is what\nretrieval reads. Create the index, then push an article into it:\n\n```bash\nvxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536}\'\n# 201 { "data": { "collection": "kb", "dimensions": 1536, "backend": "\u2026" } }\n# (`vector-search.sync` also reconciles one scheduled job per entry \u2014 you can see it in\n# `GET /v1/jobs/schedules` as `vs-sync:cms~kb~kb`, on the cron you declared.)\n\nvxil api POST /v1/rag/ingest/kb --data \'{\n "doc_id": "how-refunds-work",\n "text": "A refund is issued to the original payment method within 14 days of purchase. Ask the customer for the order id, confirm the purchase date, then issue the refund from the billing screen. Refunds are not available after 14 days.",\n "metadata": { "topic": "billing" }\n}\'\n# 202 { "data": { "doc_id": "how-refunds-work", "status": "indexed", "chunks": 1, "embedding_tokens": \u2026 } }\n```\n\n`POST /v1/rag/ingest/{collection}` is a convenience: a key holding only `rag:write` can fill the\nindex without also holding a vector-search scope.\n\nYou do not have to remember to do that twice, though \u2014 `vector-search.sync` in `vxil.config.ts`\ndeclares the `kb` cms collection as a source, so published articles are embedded on a schedule and\nthe index never silently drifts from the content. The direct ingest above just saves you the wait.\n\n**2. Classification, on every new ticket.** Create one and watch the hook:\n\n```bash\nvxil api POST /v1/cms/items/tickets --data \'{"data":{"subject":"Billing: charged twice this month","requester":"u_ana","state":"open","body":"My card was charged twice on the 3rd. Can I get one of them back?"}}\'\n# 201 { "data": { "item_id": "itm_\u2026", \u2026 } }\n\n# a moment later\nvxil api GET /v1/cms/items/tickets/itm_\u2026\n# 200 \u2026 "data": { "subject": "Billing: charged twice this month", "category": "billing", "state": "open", \u2026 }\n```\n\n`triage-ticket` fired on the write, re-fetched the row (a hook delivery carries ids, not the\ndocument), and asked for a **forced-label verdict**:\n\n```bash\nvxil api POST /v1/ai/classify --data \'{"input":"Billing: charged twice this month","labels":["billing","bug","how_to","other"]}\'\n# 200 { "data": { "generation_id": "gen_\u2026", "label": "billing", "confidence": 0.9,\n# "rationale": "\u2026", "usage": { \u2026 }, "cached": false } }\n```\n\nThe label set is part of the request, so the answer is constrained to it by the schema \u2014 the model\ncannot invent a fifth category or reply with a sentence. The function is also idempotent by\ninspection: a ticket that already has a `category` is skipped, because hook delivery is\nat-least-once and a redelivery should not cost another model call.\n\n**3. A grounded, cited draft \u2014 and a second opinion on it.** Press the record\'s button:\n\n```bash\nvxil api POST /v1/cms/items/tickets/itm_\u2026/actions/draft_reply\n# 200 { "data": { "collection": "tickets", "item_id": "itm_\u2026", "action": "draft_reply",\n# "fn": "draft-reply",\n# "result": { "draft": "\u2026", "score": 10, "verdict": "pass",\n# "citations": [ { "chunk_id": "how-refunds-work#0",\n# "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "written": true } } }\n```\n\nA citation\'s `score` is a **rank** value (about 0.016\u20130.033 for the top hit), not a relevance\nmeasure \u2014 it orders the hits. To decide whether a chunk is close enough to the question, preview\nretrieval with `POST /v1/rag/search` and threshold each hit\'s `similarity` (cosine, \u22121..1) instead.\n\nTwo calls happened inside, and the split is the lesson:\n\n```bash\nvxil api POST /v1/rag/answer --data \'{"query":"My card was charged twice. Can I get one back?","collection":"kb","top_k":5,"stream":false}\'\n# 200 { "data": { "answer": "\u2026",\n# "citations": [ { "chunk_id": "how-refunds-work#0", "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "usage": { "retrieval_ms": 21, "retrieved": 1, "used": 1, \u2026 }, "finish": "stop" } }\n\nvxil api POST /v1/ai/judge --data \'{\n "input": "My card was charged twice. Can I get one back?",\n "candidate": "Refunds go back to the original payment method within 14 days of purchase.",\n "criteria": [ { "name": "answers the question asked", "weight": 2 },\n { "name": "is supported by the cited text", "weight": 2 } ],\n "scale": { "min": 0, "max": 10 } }\'\n# 200 { "data": { "generation_id": "gen_\u2026", "score": 10, "verdict": "pass", "rationale": "\u2026", \u2026 } }\n```\n\n`stream: false` is load-bearing. With streaming on (the default), this route answers with a\n`generation_id`, a channel, a token and a `resume_path` for a browser to attach to \u2014 the citations\narrive immediately and the text streams. A server-side step wants the finished text, so it asks for\nit. Getting this wrong is a silent empty draft, not an error.\n\n`citations` are the chunks that actually **survived the context budget** \u2014 not everything retrieved.\nThat distinction is what makes them auditable: every sentence in the draft is traceable to text in\nthe list. And the score is a forced integer on a fixed scale, so drafts are comparable to each\nother rather than each getting its own adjective.\n\nNothing was sent to a customer. The action writes `draft` and `draft_score` onto the ticket and\nstops \u2014 the last step is a person.\n\n**4. The assistant: propose, then confirm.** The copilot answers from the same index and, when a\nturn would *write*, stops and asks:\n\n```bash\nvxil api POST /v1/copilot/desk/messages --data \'{"user_id":"u_agent","message":"What is our refund window?"}\'\n# 200 { "data": { "conversation_id": "cnv_\u2026", "message_id": "msg_\u2026",\n# "answer": "Refunds are available within 14 days of purchase\u2026",\n# "action_status": "none", "citations": [ \u2026 ], \u2026 } }\n\nvxil api POST /v1/copilot/desk/messages --data \'{"conversation_id":"cnv_\u2026","user_id":"u_agent","message":"Open a ticket for Ana about the double charge."}\'\n# 200 { "data": { "message_id": "msg_\u2026", "action_status": "proposed",\n# "proposal": { "message_id": "msg_\u2026", "tool": "cms_create_item",\n# "args": { "collection": "tickets", "data": { "subject": "\u2026", \u2026 } },\n# "feature": "cms", "proposed_at": "\u2026", "require_confirm": true,\n# "confirm_path": "/v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm" },\n# \u2026 } }\n```\n\nOn the **mock** provider that second turn answers `action_status: "none"` \u2014 the mock does not decide\nto call a tool on its own. Steer it with the marker the platform\'s own end-to-end tests use, and the\nturn produces a real proposal you can confirm:\n\n```text\nOpen a ticket for Ana about the double charge.\n[[tool_call:cms_create_item {"collection":"tickets","data":{"subject":"Double charge for Ana","requester":"u_ana","state":"open"}}]]\n```\n\nNothing has been written yet. The proposal names the tool and the exact arguments, and hands you\nthe confirm path. Commit it:\n\n```bash\nvxil api POST /v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm\n# 200 { "data": { "message_id": "msg_\u2026", "proposal_message_id": "msg_\u2026", "action_status": "confirmed",\n# "confirmed_at": "\u2026", "result": { "data": { "item_id": "itm_\u2026", \u2026 } } } }\n```\n\nConfirm takes **no body** \u2014 the ids in the path are the whole request, which is what makes the\nlatch tamper-proof: you cannot confirm a *different* write than the one you were shown. Call it\ntwice and the second answers `already: true`. Wait fifteen minutes and it is\n`410 proposal_expired`. And the permission check runs **again at confirm time**, so a scope revoked\nbetween proposal and confirm stops the write.\n\nThe keys under `copilot.agents.desk.actions.allow` are tool names from the catalog \u2014\n`cms_query_items` (a read, run inline) and `cms_create_item` / `cms_run_item_action` (writes,\nproposed). An unknown key there is inert, never invented.\n\n**5. The same backend, as tools.** Point an agent at it:\n\n```bash\nvxil mcp install --client claude --scopes features:read,cms:read,ai:write,rag:read,vector-search:read,webhooks:read\n```\n\nThat mints a dedicated, `agent`-tagged, revocable key and writes the MCP server entry for your\nclient. Three layers decide what the agent can do, and they compose:\n\n1. **`mcp.exposureLevel: \'custom\'` + `allowToolList`** in this config \u2014 the tenant-wide surface.\n2. **the key\'s scopes** \u2014 what the underlying REST route will accept.\n3. **the key\'s `allowed_tools` / `denied_tools`** \u2014 a per-key narrowing on top, editable after\n minting without rotating the key.\n\n`features:read` is load-bearing: without it the policy probe (`GET /v1/config/mcp`) is refused and\nthe agent sees **zero** tools with no obvious error. Mint least privilege, but not less than that.\n\n**6. What can I react to here?** The last function answers the question an agent always has to ask\na human today:\n\n```bash\nvxil functions invoke agent-capabilities\n# { "catalog_events": 170,\n# "enabled_features": [ "ai", "cms", "copilot", "functions", "mcp", "rag", "vector-search", "webhooks" ],\n# "reactable_prefixes": [ { "prefix": "cms.item.", "count": \u2026 }, { "prefix": "ai.", "count": \u2026 }, \u2026 ],\n# "failure_events": [ "job.dead_lettered", "jobs.schedule.missed",\n# "webhooks.delivery.dead_lettered", \u2026 ],\n# "how_to_subscribe": "POST /v1/webhooks/subscriptions \u2026" }\n```\n\nIt reads `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every lifecycle and\nfailure event the platform writes, with a prefix roll-up \u2014 and folds it against the features this\nbackend actually has on. The agent can call the catalog itself, too: `webhooks_event_catalog` is in\nthe tool list above, which is the difference between an agent that *has* tools and one that can\n**discover** what the system will tell it.\n\nActing on that discovery is one call with a key that carries `webhooks:write` \u2014 deliberately not\nthe read-only key this function holds:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["cms.item.","ai."]}\'\n```\n\n## What to learn from this\n\n- **Forcing the shape is the feature.** Classify returns one of *your* labels; judge returns an\n integer in *your* range. Most "the model went off the rails" problems are a missing schema, not a\n missing instruction.\n- **Two passes beat one long prompt.** Writing and evaluating are different jobs, and separating\n them gives you a number you can threshold, chart and regress against.\n- **Grounding is a pipeline, not a prompt trick.** Retrieval, a context budget, and citations of\n the chunks that survived it \u2014 the answer is auditable because the pipeline kept the receipts.\n- **Propose \u2192 confirm is where agent safety actually lives.** Not in a system prompt asking the\n model to be careful: in a latch that persists the exact arguments, re-checks permission at commit\n time, expires, and executes at most once.\n- **Least privilege for an agent is three layers, not one.** The tenant\'s exposure list, the key\'s\n scopes, and the key\'s per-tool narrowing \u2014 each can be tightened without touching the others.\n- **An agent should be able to ask the backend what it can do.** A tool catalog and an event\n catalog are both machine-readable for the same reason: the alternative is a prompt that goes stale\n the next time you ship.\n\n**Pairs with:** `templates/ai-journal/` (enrichment on write, and asking your own data questions)\nand `templates/helpdesk/` (the same desk without the AI half).\n',
18977
+ "readme": '# Agent Desk (ai)\n\nThe whole AI half of vxil on a deliberately small support desk \u2014 two collections, three functions,\nand one idea per feature. If you have been trying to work out where `ai`, `rag`, `vector-search`,\n`copilot` and `mcp` differ, this is the blueprint that answers it by making each one do exactly its\nown job.\n\n```bash\nmkdir my-desk && cd my-desk\nvxil init --template agent-desk\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name desk-capabilities --scopes webhooks:read --json | jq -r .api_key | vxil secrets set functions/vxil_read_key\nvxil push # collections + the three functions; writes vxil.types.ts\nvxil seed # one kb article ("How refunds work", a draft)\n```\n\nThe mint line needs `jq` (it pipes the key straight into the secret, so it never shows in your\nterminal); without it, run the mint with `--json`, copy `api_key`, and paste it at the hidden prompt of\n`vxil secrets set functions/vxil_read_key`.\n\n> **Plan note.** The three functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nEverything runs on the deterministic **`mock`** model provider and the **`mock`** embedder, so the\nwalkthrough below is reproducible with no provider account and no spend. Going real is two switches,\nnot one (see [Going real](#going-real-the-model-and-the-embedder) at the end): the model, and the\nembedder the `kb` index was created with.\n\n`vxil_read_key` is a key **of this same backend** carrying only `webhooks:read`; the capability probe\nuses it for the event catalog (`GET /v1/features` answers any authenticated key, so it needs no extra\nscope). The key is a holdover in the shipped function, not a platform limit: a function that declares\n`webhooks:read` reads the event catalog through its own scoped callback, with no stored key. (The\nconfig\'s comments ask for `features:read` too; the probe does not need it.)\n\n## One idea per feature\n\n| Feature | The one thing it does here | Why it is not one of the others |\n|---|---|---|\n| `ai` classify | pick exactly one label from a fixed set | a chat prompt can return a paragraph; a classifier cannot |\n| `ai` judge | score a draft as an integer on a fixed scale | the model that writes is not the authority on whether the writing is good |\n| `vector-search` | hold the knowledge index, synced from `kb` | retrieval, not generation \u2014 no prompt lives here |\n| `rag` | answer **only** from what was retrieved, with citations | the pipeline; the *prompt* is a template you own |\n| `copilot` | the in-app assistant: propose a write, a human confirms | it is a composition over the four above, not a fifth model |\n| `mcp` | the same backend, as tools, narrowed per key | an agent\'s *interface*, not an agent |\n| `functions` | the deterministic steps around the model calls | the parts that must not be creative |\n\n## Prompts are yours: declare them, or create them\n\n`rag.defaultTemplate: \'support-answer\'` names a **prompt template**, a versioned row. You can\n**declare** it in `vxil.config.ts`, and `vxil push` converges it (a new version only when the text\nchanged \u2014 the funnel-saas blueprint\'s "Declared API state" shows the whole table):\n\n```ts\nai: {\n // \u2026the blueprint\'s ai block, plus:\n templates: [{\n template: \'support-answer\',\n system: \'You are a support agent. Answer ONLY from the context. If the context does not contain the answer, say you do not know.\',\n user: \'Context:\\n{{context}}\\n\\nCustomer question:\\n{{query}}\\n\\nWrite a short, direct reply.\',\n }],\n},\n```\n\nOr create it over the API when you iterate on a prompt hourly and do not want a push per wording\nchange. Either way it must exist before the first answer:\n\n```bash\nvxil api POST /v1/ai/templates --data \'{\n "template": "support-answer",\n "system": "You are a support agent. Answer ONLY from the context. If the context does not contain the answer, say you do not know.",\n "user": "Context:\\n{{context}}\\n\\nCustomer question:\\n{{query}}\\n\\nWrite a short, direct reply."\n}\'\n# 201 { "data": { "template": "support-answer", "version": 1 } }\n```\n\nRe-POST the same name and you get version 2 \u2014 old versions stay pinnable. `{{query}}` and\n`{{context}}` are what the retrieval step fills in. **A grounded answer with no template is a 404**,\nso this is step zero, not an optional flourish.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `ai:read ai:write rag:read rag:write vector-search:read\nvector-search:write cms:read cms:write copilot:read copilot:write webhooks:read functions:invoke\nfeatures:read`. The project key quickstart minted carries these (`vxil env pull && set -a && . ./.env.local && set +a`, then `KEY=$VXIL_API_KEY`); the `vxil api` lines need no variable at all.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .field`, not `jq .data.field`.\n\n**1. The index.** The `kb` cms collection is what you author in; the `kb` vector collection is what\nretrieval reads. Create the index, then push an article into it:\n\n```bash\nvxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536}\'\n# 201 { "data": { "collection": "kb", "dimensions": 1536, "backend": "\u2026" } }\n# (`vector-search.sync` also reconciles one scheduled job per entry \u2014 you can see it in\n# `GET /v1/jobs/schedules` as `vs-sync:cms~kb~kb`, on the cron you declared.)\n\nvxil api POST /v1/rag/ingest/kb --data \'{\n "doc_id": "how-refunds-work",\n "text": "A refund is issued to the original payment method within 14 days of purchase. Ask the customer for the order id, confirm the purchase date, then issue the refund from the billing screen. Refunds are not available after 14 days.",\n "metadata": { "topic": "billing" }\n}\'\n# 202 { "data": { "doc_id": "how-refunds-work", "status": "indexed", "chunks": 1, "embedding_tokens": \u2026 } }\n```\n\n`POST /v1/rag/ingest/{collection}` is a convenience: a key holding only `rag:write` can fill the\nindex without also holding a vector-search scope.\n\nYou do not have to remember to do that twice, though \u2014 `vector-search.sync` in `vxil.config.ts`\ndeclares the `kb` cms collection as a source, so published articles are embedded on a schedule and\nthe index never silently drifts from the content. The direct ingest above just saves you the wait.\n\n**2. Classification, on every new ticket.** Create one and watch the hook:\n\n```bash\nvxil api POST /v1/cms/items/tickets --data \'{"data":{"subject":"Billing: charged twice this month","requester":"u_ana","state":"open","body":"My card was charged twice on the 3rd. Can I get one of them back?"}}\'\n# 201 { "data": { "item_id": "itm_\u2026", \u2026 } }\n\n# a moment later\nvxil api GET /v1/cms/items/tickets/itm_\u2026\n# 200 \u2026 "data": { "subject": "Billing: charged twice this month", "category": "billing", "state": "open", \u2026 }\n```\n\n`triage-ticket` fired on the write, re-fetched the row (a hook delivery carries ids, not the\ndocument), and asked for a **forced-label verdict**:\n\n```bash\nvxil api POST /v1/ai/classify --data \'{"input":"Billing: charged twice this month","labels":["billing","bug","how_to","other"]}\'\n# 200 { "data": { "generation_id": "gen_\u2026", "label": "billing", "confidence": 0.9,\n# "rationale": "\u2026", "usage": { \u2026 }, "cached": false } }\n```\n\nThe label set is part of the request, so the answer is constrained to it by the schema \u2014 the model\ncannot invent a fifth category or reply with a sentence. The function is also idempotent by\ninspection: a ticket that already has a `category` is skipped, because hook delivery is\nat-least-once and a redelivery should not cost another model call.\n\n**3. A grounded, cited draft \u2014 and a second opinion on it.** Press the record\'s button:\n\n```bash\nvxil api POST /v1/cms/items/tickets/itm_\u2026/actions/draft_reply\n# 200 { "data": { "collection": "tickets", "item_id": "itm_\u2026", "action": "draft_reply",\n# "fn": "draft-reply",\n# "result": { "draft": "\u2026", "score": 10, "verdict": "pass",\n# "citations": [ { "chunk_id": "how-refunds-work#0",\n# "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "written": true } } }\n```\n\nA citation\'s `score` is a **rank** value (about 0.016\u20130.033 for the top hit), not a relevance\nmeasure \u2014 it orders the hits. To decide whether a chunk is close enough to the question, preview\nretrieval with `POST /v1/rag/search` and threshold each hit\'s `similarity` (cosine, \u22121..1) instead.\n\nTwo calls happened inside, and the split is the lesson:\n\n```bash\nvxil api POST /v1/rag/answer --data \'{"query":"My card was charged twice. Can I get one back?","collection":"kb","top_k":5,"stream":false}\'\n# 200 { "data": { "answer": "\u2026",\n# "citations": [ { "chunk_id": "how-refunds-work#0", "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "usage": { "retrieval_ms": 21, "retrieved": 1, "used": 1, \u2026 }, "finish": "stop" } }\n\nvxil api POST /v1/ai/judge --data \'{\n "input": "My card was charged twice. Can I get one back?",\n "candidate": "Refunds go back to the original payment method within 14 days of purchase.",\n "criteria": [ { "name": "answers the question asked", "weight": 2 },\n { "name": "is supported by the cited text", "weight": 2 } ],\n "scale": { "min": 0, "max": 10 } }\'\n# 200 { "data": { "generation_id": "gen_\u2026", "score": 10, "verdict": "pass", "rationale": "\u2026", \u2026 } }\n```\n\n`stream: false` is load-bearing. With streaming on (the default), this route answers with a\n`generation_id`, a channel, a token and a `resume_path` for a browser to attach to \u2014 the citations\narrive immediately and the text streams. A server-side step wants the finished text, so it asks for\nit. Getting this wrong is a silent empty draft, not an error.\n\n`citations` are the chunks that actually **survived the context budget** \u2014 not everything retrieved.\nThat distinction is what makes them auditable: every sentence in the draft is traceable to text in\nthe list. And the score is a forced integer on a fixed scale, so drafts are comparable to each\nother rather than each getting its own adjective.\n\nNothing was sent to a customer. The action writes `draft` and `draft_score` onto the ticket and\nstops \u2014 the last step is a person.\n\n**4. The assistant: propose, then confirm.** The copilot answers from the same index and, when a\nturn would *write*, stops and asks:\n\n```bash\nvxil api POST /v1/copilot/desk/messages --data \'{"user_id":"u_agent","message":"What is our refund window?"}\'\n# 200 { "data": { "conversation_id": "cnv_\u2026", "message_id": "msg_\u2026",\n# "answer": "Refunds are available within 14 days of purchase\u2026",\n# "action_status": "none", "citations": [ \u2026 ], \u2026 } }\n\nvxil api POST /v1/copilot/desk/messages --data \'{"conversation_id":"cnv_\u2026","user_id":"u_agent","message":"Open a ticket for Ana about the double charge."}\'\n# 200 { "data": { "message_id": "msg_\u2026", "action_status": "proposed",\n# "proposal": { "message_id": "msg_\u2026", "tool": "cms_create_item",\n# "args": { "collection": "tickets", "data": { "subject": "\u2026", \u2026 } },\n# "feature": "cms", "proposed_at": "\u2026", "require_confirm": true,\n# "confirm_path": "/v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm" },\n# \u2026 } }\n```\n\nOn the **mock** provider that second turn answers `action_status: "none"` \u2014 the mock does not decide\nto call a tool on its own. Steer it with the marker the platform\'s own end-to-end tests use, and the\nturn produces a real proposal you can confirm:\n\n```text\nOpen a ticket for Ana about the double charge.\n[[tool_call:cms_create_item {"collection":"tickets","data":{"subject":"Double charge for Ana","requester":"u_ana","state":"open"}}]]\n```\n\nNothing has been written yet. The proposal names the tool and the exact arguments, and hands you\nthe confirm path. Commit it:\n\n```bash\nvxil api POST /v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm\n# 200 { "data": { "message_id": "msg_\u2026", "proposal_message_id": "msg_\u2026", "action_status": "confirmed",\n# "confirmed_at": "\u2026", "result": { "data": { "item_id": "itm_\u2026", \u2026 } } } }\n```\n\nConfirm takes **no body** \u2014 the ids in the path are the whole request, which is what makes the\nlatch tamper-proof: you cannot confirm a *different* write than the one you were shown. Call it\ntwice and the second answers `already: true`. Wait fifteen minutes and it is\n`410 proposal_expired`. And the permission check runs **again at confirm time**, so a scope revoked\nbetween proposal and confirm stops the write.\n\nThe keys under `copilot.agents.desk.actions.allow` are tool names from the catalog \u2014\n`cms_query_items` (a read, run inline) and `cms_create_item` / `cms_run_item_action` (writes,\nproposed). An unknown key there is inert, never invented.\n\n**5. The same backend, as tools.** Point an agent at it:\n\n```bash\nvxil mcp install --client claude --scopes features:read,cms:read,ai:write,rag:read,vector-search:read,webhooks:read\n```\n\nThat mints a dedicated, `agent`-tagged, revocable key and writes the MCP server entry for your\nclient. Three layers decide what the agent can do, and they compose:\n\n1. **`mcp.exposureLevel: \'custom\'` + `allowToolList`** in this config \u2014 the tenant-wide surface.\n2. **the key\'s scopes** \u2014 what the underlying REST route will accept.\n3. **the key\'s `allowed_tools` / `denied_tools`** \u2014 a per-key narrowing on top, editable after\n minting without rotating the key.\n\n`features:read` is load-bearing: without it the policy probe (`GET /v1/config/mcp`) is refused and\nthe agent sees **zero** tools with no obvious error. Mint least privilege, but not less than that.\n\n**6. What can I react to here?** The last function answers the question an agent always has to ask\na human today:\n\n```bash\nvxil functions invoke agent-capabilities\n# { "catalog_events": \u2026,\n# "enabled_features": [ "ai", "cms", "copilot", "functions", "mcp", "rag", "vector-search", "webhooks" ],\n# "reactable_prefixes": [ { "prefix": "cms.item.", "count": \u2026 }, { "prefix": "ai.", "count": \u2026 }, \u2026 ],\n# "failure_events": [ "job.dead_lettered", "jobs.schedule.missed",\n# "webhooks.delivery.dead_lettered", \u2026 ],\n# "how_to_subscribe": "POST /v1/webhooks/subscriptions \u2026" }\n```\n\nIt reads `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every lifecycle and\nfailure event the platform writes, with a prefix roll-up \u2014 and folds it against the features this\nbackend actually has on. The agent can call the catalog itself, too: `webhooks_event_catalog` is in\nthe tool list above, which is the difference between an agent that *has* tools and one that can\n**discover** what the system will tell it.\n\nActing on that discovery is one call with a key that carries `webhooks:write` \u2014 deliberately not\nthe read-only key this function holds:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["cms.item.","ai."]}\'\n```\n\n## What to learn from this\n\n- **Forcing the shape is the feature.** Classify returns one of *your* labels; judge returns an\n integer in *your* range. Most "the model went off the rails" problems are a missing schema, not a\n missing instruction.\n- **Two passes beat one long prompt.** Writing and evaluating are different jobs, and separating\n them gives you a number you can threshold, chart and regress against.\n- **Grounding is a pipeline, not a prompt trick.** Retrieval, a context budget, and citations of\n the chunks that survived it \u2014 the answer is auditable because the pipeline kept the receipts.\n- **Propose \u2192 confirm is where agent safety actually lives.** Not in a system prompt asking the\n model to be careful: in a latch that persists the exact arguments, re-checks permission at commit\n time, expires, and executes at most once.\n- **Least privilege for an agent is three layers, not one.** The tenant\'s exposure list, the key\'s\n scopes, and the key\'s per-tool narrowing \u2014 each can be tightened without touching the others.\n- **An agent should be able to ask the backend what it can do.** A tool catalog and an event\n catalog are both machine-readable for the same reason: the alternative is a prompt that goes stale\n the next time you ship.\n\n## Going real: the model and the embedder\n\nNothing in a response says that `mock` produced it, and swapping the model does **not** change the\nembedder: retrieval keeps ranking by a test hash, not by meaning, until you change both.\n\n1. **The model.** `vxil secrets set ai/openai_key`, then set `ai.defaultProvider: \'openai\'` and a real\n model in `ai.defaults.model`, and `vxil push`.\n2. **The embedder.** `vxil secrets set vector-search/openai_key`; set\n `features[\'vector-search\'].embed` to `{ provider: \'openai\', model: \'text-embedding-3-small\', apiKeyRef: \'secret:openai_key\', dimensions: 1536 }`\n and push. The `kb` index keeps the embedder it was **created** with, so re-send the create with the\n same width, which updates it in place:\n ```bash\n vxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536,"embed":{"provider":"openai","model":"text-embedding-3-small","apiKeyRef":"secret:openai_key"}}\'\n ```\n then re-ingest every article (the step 1 ingest per article, or publish them again for the sync to\n pick up), because the vectors already stored were made by `mock`. A different width needs a new\n collection name: a collection\'s width cannot change.\n\n**Pairs with:** the **ai-journal** blueprint (enrichment on write, and asking your own data questions)\nand the **helpdesk** blueprint (the same desk without the AI half).\n',
18883
18978
  "functions": {
18884
18979
  "agent-capabilities.ts": "// agent-capabilities.ts \u2014 \"WHAT CAN I REACT TO HERE?\" (a vxil function).\n//\n// Trigger: http. An agent (or your own onboarding screen) calls this once and\n// learns, from the backend itself, what this workspace can emit \u2014 instead of a\n// human pasting a list into a prompt that goes stale the next release.\n//\n// Two reads, folded together:\n// \u2022 `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every\n// lifecycle and failure event the platform writes, with a `prefixes` roll-up\n// you can subscribe to directly.\n// \u2022 `GET /v1/features` \u2014 which features THIS backend actually has on.\n// The answer is the intersection: the prefixes worth subscribing to here.\n//\n// WHY A KEY AND NOT THE FUNCTION'S OWN CALLBACK: a function's scoped callback\n// covers the feature APIs (cms, ai, rag, \u2026). The event catalog and the feature\n// list are platform reads, so this uses the narrowest key that can reach them \u2014\n// one holding only `features:read` and `webhooks:read`, stored as a secret,\n// resolved per invocation, revocable in one click without a redeploy.\n//\n// To actually SUBSCRIBE, POST to /v1/webhooks/subscriptions with\n// { target_url, event_prefixes } using a key that carries `webhooks:write` \u2014\n// deliberately NOT this one (see the README).\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n/** prefix segment \u2192 the feature key it belongs to, where the names differ. */\nconst PREFIX_FEATURE: Record<string, string> = {\n job: 'jobs', jobs: 'jobs', user: 'auth', auth: 'auth', session: 'auth',\n org: 'orgs', orgs: 'orgs', rate_limits: 'rate-limits', feeds: 'activity-feed',\n 'vector-search': 'vector-search', functions: 'functions',\n};\n\ntype Env = HttpFunctionEnvelope<{ all?: boolean }>;\ninterface CatalogEvent { name?: string; feature?: string; level?: string }\ninterface Prefix { prefix?: string; count?: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const key = env.secrets?.vxil_read_key;\n if (!key) {\n return Response.json(\n { error: 'missing_secret', message: 'set the vxil_read_key secret first' },\n { status: 503 },\n );\n }\n const h = { authorization: `Bearer ${key}` };\n\n const [catRes, featRes] = await Promise.all([\n fetch(`${base}/v1/webhooks/events/catalog`, { headers: h }),\n fetch(`${base}/v1/features`, { headers: h }),\n ]);\n if (!catRes.ok) {\n return Response.json({ error: 'catalog_unavailable', status: catRes.status }, { status: 502 });\n }\n const cat = ((await catRes.json()) as {\n data?: { count?: number; events?: CatalogEvent[]; prefixes?: Prefix[] };\n }).data ?? {};\n const enabled = new Set(\n featRes.ok\n ? ((await featRes.json()) as { data?: { features?: string[] } }).data?.features ?? []\n : [],\n );\n\n const all = env.payload?.all === true;\n const prefixes = (cat.prefixes ?? []).filter((p) => {\n if (all || enabled.size === 0) return true;\n const head = String(p.prefix ?? '').replace(/\\.$/, '');\n return enabled.has(PREFIX_FEATURE[head] ?? head);\n });\n\n // The failure half is the half worth wiring first: it is what tells you the\n // backend is unhappy before a customer does.\n const failures = (cat.events ?? [])\n .filter((e) => e.level === 'failure')\n .map((e) => e.name)\n .filter((n): n is string => typeof n === 'string')\n .sort();\n\n return Response.json({\n catalog_events: cat.count ?? (cat.events ?? []).length,\n enabled_features: [...enabled].sort(),\n reactable_prefixes: prefixes,\n failure_events: failures,\n how_to_subscribe:\n 'POST /v1/webhooks/subscriptions { \"target_url\": \"https://\u2026\", \"event_prefixes\": [\"job.\", \"cms.item.\"] } '\n + 'with a key carrying webhooks:write',\n });\n },\n};\n",
18885
18980
  "draft-reply.ts": "// draft-reply.ts \u2014 RETRIEVE \u2192 GROUND \u2192 SCORE (a vxil function).\n//\n// Trigger: the per-record action `draft_reply` on `tickets`. The action envelope\n// carries the WHOLE row, so this step needs no re-fetch:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// Three calls, three jobs, in order:\n// 1. `POST /v1/rag/answer` \u2014 retrieve from the `kb` index and answer ONLY from\n// what came back, returning the chunks it used as citations. A grounded\n// answer you can audit beats a confident one you cannot.\n// 2. `POST /v1/ai/judge` \u2014 score that draft against a rubric, as an integer on\n// a fixed scale. The model that writes is not the authority on whether the\n// writing is good; a second, schema-forced pass is.\n// 3. one PATCH \u2014 persist the draft + its score so a human decides what to send.\n//\n// Nothing here sends anything to a customer. The last step is always a person.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst CRITERIA = [\n { name: 'answers the question asked', weight: 2 },\n { name: 'is supported by the cited knowledge-base text', weight: 2 },\n { name: 'is concise and free of speculation', weight: 1 },\n];\n\ntype Env = HttpFunctionEnvelope<{\n collection?: string;\n item_id?: string;\n item?: { data?: { subject?: string; body?: string; category?: string } };\n}>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const rag = env.scoped_jwts?.rag;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (!cms || !rag || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ error: 'bad_request', message: 'not a tickets action' }, { status: 400 });\n }\n\n const t = env.payload?.item?.data ?? {};\n const question = `${t.subject ?? ''}\\n\\n${t.body ?? ''}`.trim();\n if (!question) return Response.json({ error: 'empty_ticket' }, { status: 422 });\n\n // 1. GROUNDED ANSWER. `template` falls back to the rag config's\n // `defaultTemplate`, so the call stays this short. `stream: false` is\n // load-bearing: with streaming enabled (the default) this route answers\n // with a channel + resume path for a browser to attach to, NOT the text.\n // A server-side step wants the text, so it says so.\n const answered = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({ query: question, collection: 'kb', top_k: 5, stream: false }),\n });\n if (!answered.ok) {\n const detail = await answered.text();\n return Response.json(\n { error: 'retrieval_failed', status: answered.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const grounded = ((await answered.json()) as {\n data?: { answer?: string; citations?: unknown[]; usage?: unknown };\n }).data ?? {};\n const draft = String(grounded.answer ?? '').trim();\n const citations = Array.isArray(grounded.citations) ? grounded.citations : [];\n if (!draft) return Response.json({ error: 'empty_draft' }, { status: 502 });\n\n // 2. SCORE IT. A forced integer on a fixed scale \u2014 comparable across drafts,\n // unlike \"this looks good\".\n const scored = await fetch(`${base}/v1/ai/judge`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input: question,\n candidate: draft,\n criteria: CRITERIA,\n scale: { min: 0, max: 10 },\n }),\n });\n const verdict = scored.ok\n ? ((await scored.json()) as { data?: { score?: number; verdict?: string; rationale?: string } }).data ?? {}\n : {};\n const score = typeof verdict.score === 'number' ? Math.round(verdict.score) : null;\n\n // 3. PERSIST. A human reads it, edits it, and decides whether it is sent.\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { draft, ...(score === null ? {} : { draft_score: score }), state: 'drafted' },\n }),\n });\n\n return Response.json({\n item_id: itemId,\n draft,\n score,\n verdict: verdict.verdict ?? null,\n rationale: verdict.rationale ?? null,\n citations,\n written: patch.ok,\n });\n },\n};\n",
@@ -18905,7 +19000,7 @@ export default defineConfig({
18905
19000
  "fal_key"
18906
19001
  ],
18907
19002
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Media Studio\" \u2014 images and video from fal.ai, with credits that cannot\n// leak. One function starts a render; vxil does the rest:\n//\n// start-render (your function, end-user mode)\n// \u2192 writes a `renders` row the user owns\n// \u2192 POST /v1/jobs/generation: a fal QUEUE job, credits HELD for it\n// vxil's generation lane\n// \u2192 calls fal with your key, the signed callback in `?fal_webhook=`\n// \u2192 fal POSTs back `{ status: 'OK' | 'ERROR', payload }`\n// \u2192 status_map reads OK/ERROR, result_path picks the images / the video\n// \u2192 the `renders` row gets generation_status + result\n// \u2192 credits COMMIT on success, REFUND on failure or timeout\n//\n// No fal adapter exists in vxil and none is needed: the three generic options\n// (`completion.callback.query_param`, `completion.status_map`,\n// `completion.result_path`) describe any \"POST the job, we call your webhook\"\n// vendor. The platform owns the part a function cannot \u2014 the held credits,\n// the timeout and the refund.\n//\n// The credits half is a payments INTEGRATION on the deterministic `mock`\n// provider \u2014 no provider account needed to try it. \"Credits\" are usage units,\n// not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n maxConcurrent: 10,\n // fal images finish in seconds, an 8 s Veo clip in a few minutes \u2014\n // a run fal never calls back about fails (and refunds) at 15 minutes\n defaultTimeoutMs: 900_000,\n maxTimeoutMs: 1_800_000,\n pollMaxAttempts: 30,\n // a video costs 10 credits; one run may never hold more than 20\n maxReserveCredits: 20,\n // \u2026nor may all of this project's in-flight renders together hold more than 2,000\n maxOutstandingReserveCredits: 2_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n media_pack_100: { creditType: 'media_credits', amount: 100, period: 'once' },\n },\n // subscription tiers: a monthly top-up for subscribers\n tierMap: {\n studio: {\n entitlements: ['render'],\n quotas: {},\n rank: 10,\n grants: [{ creditType: 'media_credits', amount: 300, period: 'monthly' }],\n },\n },\n // a render that fails or times out gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a collection with no owner is server-only \u2014 renders\n // declares one, so a signed-in user sees only their own renders\n strictEndUserScope: true,\n // Lane-A hook (guide ch. 7): the dedupe key IS owner + ':' + request_key,\n // server-enforced \u2014 so one user's request_key can never collide with, or\n // block, another user's (a body naming another owner is a 400 anyway).\n hooks: {\n render_dedupe_key: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.dedupe_key == concat(item.owner, ':', item.request_key)\",\n message: \"dedupe_key must be owner + ':' + request_key\",\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR \u2014 owner + ':' + the client's own request_key\n // (the hook above enforces the composition), so the key is per user.\n // A retried start (a double tap, a timeout) hits 409 instead of\n // paying twice, and start-render re-drives a start that never got\n // its run.\n dedupe_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n kind: { type: 'string', required: true, indexSlot: 's3', validation: { enum: ['image', 'video'] } },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n generation_status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n prompt: { type: 'text', required: true },\n run_id: { type: 'text' },\n // written by vxil's status mirror on completion: the value at\n // `result_path` \u2014 fal's `images` list, or its `video` object\n result: { type: 'json' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode (with\n // the user's session): the held credits are forced onto that user, and the\n // row is theirs.\n 'start-render': {\n entry: './functions/start-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // your fal API key; it rides the provider call's Authorization header\n secrets: ['secret:fal_key'],\n // the function itself never calls fal \u2014 vxil's generation lane does\n egressAllow: [],\n // `vxil gen` types vx.fn['start-render'] from this\n signature: {\n input: { kind: 'string', prompt: 'string', request_key: 'string' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; generation_status: string }',\n },\n },\n },\n\n secrets: {\n fal_key: {\n feature: 'functions',\n description: 'your fal.ai API key (the \"Key \u2026\" credential) \u2014 vxil never holds a fal account of its own',\n },\n },\n});\n",
18908
- "readme": "# AI Media Studio \u2014 fal.ai images and Veo video, with credits that cannot leak\n\n```bash\nmkdir my-studio && cd my-studio\nvxil init --template fal-media # init scaffolds into the CURRENT directory\nprintf '%s' \"$FAL_KEY\" | vxil secrets set functions/fal_key\nvxil push\n```\n\n> **Plan note.** The function deploys on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nOne function starts a render; vxil carries it to the end. You bring a fal.ai key; vxil holds the\nuser's credits while fal works, writes the image or the video onto the user's `renders` row when\nfal calls back, and gives the credits back when fal fails or never answers.\n\n**What this blueprint teaches that the others do not:** a third-party *queue* API \u2014 \"POST the job,\nwe call your webhook\" \u2014 completed through the generation lane's **generic completion options**,\nwith no vendor adapter in vxil and no long call held open in a function:\n\n| option | what it does | fal's case |\n|---|---|---|\n| `completion.callback.query_param` | puts vxil's signed callback URL in the provider URL's query string, and sends `provider.body` exactly as written | fal reads its webhook from `?fal_webhook=` and the model input from the body |\n| `completion.status_map` | up to 8 of the provider's own status words \u2192 `completed` \xB7 `failed` \xB7 `processing` | fal's webhook says `OK` or `ERROR` \u2014 words vxil's built-in list would read as \"still working\" until the run timed out |\n| `completion.result_path` | the value the run settles with, written to your record as `result` | `payload.images` (a list of `{ url, width, height, \u2026 }`) or `payload.video` (`{ url, \u2026 }`) |\n\nThe same three options fit any vendor that calls you back (a transcode API, a render farm, a\nlong-running inference host) \u2014 change the URL, the words and the path.\n\n## What you get\n\n- **`renders`** \u2014 one row per request, owned by the user who started it (`strictEndUserScope`, so a\n signed-in user reads only their own renders). `dedupe_key` (the owner + `:` + the client's\n `request_key`, the composition enforced by a `beforeWrite` hook) is unique, so keys are **per user**: a\n double tap or a retried start is a `409` the function reads back. A row that already has its run is a\n duplicate; a row with no run (the first start died, or lost its enqueue answer, between the row and\n the enqueue) is **re-driven** \u2014 the same `dedupe_key` is the generation run's `idempotency_key`, so\n jobs hands back the existing run and fal is never asked twice.\n- **`start-render`** (http function, end-user mode) \u2014 writes the row, then enqueues ONE generation run:\n fal's queue URL, your key in `Authorization: Key \u2026`, the three options above, a status mirror onto\n the row, and `reserve_credits` for the render's cost (image 1, video 10 `media_credits`).\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed). The hold is\n taken *before* the run is queued; a user without the credits gets `402` at once and nothing is held.\n Success commits the hold; a fal `ERROR`, a run fal never calls back about (the timeout: 5 min for an\n image, 15 for a video) or a cancel refunds it (`ledger.autoRefundOnJobFailure`).\n\n## Run it\n\nGive a user some credits from your server (or sell `media_pack_100` through your payments provider):\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"media_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser, and a function cannot hold credits for anyone else):\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['start-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['start-render']({ kind: 'image', prompt: 'a red fox in the snow', request_key: 'fox-1' });\n// \u2192 { item_id, run_id, credits: 1 }\n// (or { duplicate: true, request_key, item_id, run_id, generation_status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `generation_status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.generation_status \u2192 'completed'\n // row.result \u2192 [{ url: 'https://fal.media/files/\u2026png', width: 1024, height: 1024, \u2026 }]\n}\n```\n\nEvery run ends with `job.generation.completed` or `job.generation.failed` (`generation_id` = the row's\n`item_id`, `correlation_id` = its `request_key`), so a function bound to `job.generation.` can react \u2014\nsend a push, thumbnail the image \u2014 without a lookup table of its own.\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| fal answered `OK` | `completed` | `generation_status: completed`, `result` set | committed |\n| fal answered `ERROR` (its `error` text is the run's `last_error_msg`; `job.generation.failed` carries `error_class: 'ProviderFailed'`) | `failed` | `generation_status: failed` | refunded |\n| fal never called back | `failed` (`GenerationExpired`) at the timeout | `failed` | refunded |\n| the start call to fal failed with a 5xx / network fault | retried with backoff; terminal after the attempts | `processing` \u2192 the outcome above | held until then |\n| the user had too few credits | ended at once (`ReserveInsufficient`), never queued | `failed` (the function marks it); that `request_key` is spent \u2014 retry after a top-up with a new one | nothing held |\n| too many renders in flight, or a jobs-side fault | not created (the function answers the caller `429` with the wait in `retry_after`, or `502`) | `pending`, no run \u2014 calling start-render again with the **same** `request_key` re-drives it | nothing held |\n\n## Evidence\n\n- **Executed** (vxil's own integration tests):\n the `?fal_webhook=` callback URL is signed and verifies, the provider receives exactly the model input,\n `OK` settles completed with `payload.images` mirrored as `result` and the hold committed, `ERROR`\n settles failed with the hold refunded, a progress ping stays processing.\n- **Read in fal's documentation, not executed against fal from vxil** \u2014 the queue base\n `https://queue.fal.run/<model>`, the `fal_webhook` query parameter, the webhook body\n `{ request_id, status: 'OK' | 'ERROR', payload, error }`, and the model ids `fal-ai/flux/dev` and\n `fal-ai/veo3` with their inputs. Check the model page for each model's exact input fields before you\n ship, and keep `num_images` / `duration` to what your model accepts.\n\n## Your key, and where it lives\n\n`fal_key` is a function secret; `start-render` reads it at invoke time and puts it in the generation\nrun's provider headers, which vxil stores with the run (your project only) for as long as the jobs\nretention keeps the run, and sends to fal on the start call. It is **never returned by a read**:\n`GET /v1/jobs/runs/{run_id}` (and the dashboard and MCP reads built on it) shows the header names of a\ngeneration run with every value as `[redacted]`. Rotate it with `vxil secrets set functions/fal_key`;\nruns already queued keep the key they were started with.\n",
19003
+ "readme": "# AI Media Studio \u2014 fal.ai images and Veo video, with credits that cannot leak\n\n```bash\nmkdir my-studio && cd my-studio\nvxil init --template fal-media # init scaffolds into the CURRENT directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf '%s' \"$FAL_KEY\" | vxil secrets set functions/fal_key # or run it bare for a hidden prompt\nvxil push # collections + the function; writes vxil.types.ts (vx.fn typing)\n```\n\n`--env staging` matters here beyond the plan: the payments block runs on the `mock` provider, which the\nproduction promotion gate refuses on a project labelled `production`.\n\n> **Plan note.** The function deploys on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nOne function starts a render; vxil carries it to the end. You bring a fal.ai key; vxil holds the\nuser's credits while fal works, writes the image or the video onto the user's `renders` row when\nfal calls back, and gives the credits back when fal fails or never answers.\n\n**What this blueprint teaches that the others do not:** a third-party *queue* API \u2014 \"POST the job,\nwe call your webhook\" \u2014 completed through the generation lane's **generic completion options**,\nwith no vendor adapter in vxil and no long call held open in a function:\n\n| option | what it does | fal's case |\n|---|---|---|\n| `completion.callback.query_param` | puts vxil's signed callback URL in the provider URL's query string, and sends `provider.body` exactly as written | fal reads its webhook from `?fal_webhook=` and the model input from the body |\n| `completion.status_map` | up to 8 of the provider's own status words \u2192 `completed` \xB7 `failed` \xB7 `processing` | fal's webhook says `OK` or `ERROR` \u2014 words vxil's built-in list would read as \"still working\" until the run timed out |\n| `completion.result_path` | the value the run settles with, written to your record as `result` | `payload.images` (a list of `{ url, width, height, \u2026 }`) or `payload.video` (`{ url, \u2026 }`) |\n\nThe same three options fit any vendor that calls you back (a transcode API, a render farm, a\nlong-running inference host) \u2014 change the URL, the words and the path.\n\n## What you get\n\n- **`renders`** \u2014 one row per request, owned by the user who started it (`strictEndUserScope`, so a\n signed-in user reads only their own renders). `dedupe_key` (the owner + `:` + the client's\n `request_key`, the composition enforced by a `beforeWrite` hook) is unique, so keys are **per user**: a\n double tap or a retried start is a `409` the function reads back. A row that already has its run is a\n duplicate; a row with no run (the first start died, or lost its enqueue answer, between the row and\n the enqueue) is **re-driven** \u2014 the same `dedupe_key` is the generation run's `idempotency_key`, so\n jobs hands back the existing run and fal is never asked twice.\n- **`start-render`** (http function, end-user mode) \u2014 writes the row, then enqueues ONE generation run:\n fal's queue URL, your key in `Authorization: Key \u2026`, the three options above, a status mirror onto\n the row, and `reserve_credits` for the render's cost (image 1, video 10 `media_credits`).\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed). The hold is\n taken *before* the run is queued; a user without the credits gets `402` at once and nothing is held.\n Success commits the hold; a fal `ERROR`, a run fal never calls back about (the timeout: 5 min for an\n image, 15 for a video) or a cancel refunds it (`ledger.autoRefundOnJobFailure`).\n\n## Run it\n\nGive a user some credits from your server (or sell `media_pack_100` through your payments provider).\n`$KEY` is a server key: `vxil env pull && set -a && . ./.env.local && set +a && KEY=$VXIL_API_KEY`.\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"media_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser, and a function cannot hold credits for anyone else):\n\n**The two values the browser needs.** `VXIL_PUBLISHABLE_KEY` is a **public-class** key, the kind that\nlives in a browser or an app and only works alongside a signed-in user's session:\n\n```bash\nvxil keys mint --name studio-browser --class public --scopes functions:invoke,cms:read --json\n```\n\n`USER_SESSION` is that user's session token. This blueprint does not enable sign-in: add\n`auth: { methods: { emailPassword: true } }` to `features`, push, and sign the user in with a second,\nsign-in-only key (`vxil keys mint --name studio-signin --scopes auth:signin --json`; it cannot be\npublic-class, because it runs before there is a session) \u2014 vxil.com/docs/guide/06-feature-catalog, auth,\n\"The end-user identity loop, worked end to end\". The `user_id` you grant credits to is that signed-in\nuser's id.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['start-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['start-render']({ kind: 'image', prompt: 'a red fox in the snow', request_key: 'fox-1' });\n// \u2192 { item_id, run_id, credits: 1 }\n// (or { duplicate: true, request_key, item_id, run_id, generation_status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `generation_status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.generation_status \u2192 'completed'\n // row.result \u2192 [{ url: 'https://fal.media/files/\u2026png', width: 1024, height: 1024, \u2026 }]\n}\n```\n\nEvery run ends with `job.generation.completed` or `job.generation.failed` (`generation_id` = the row's\n`item_id`, `correlation_id` = its `request_key`), so a function bound to `job.generation.` can react \u2014\nsend a push, thumbnail the image \u2014 without a lookup table of its own.\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| fal answered `OK` | `completed` | `generation_status: completed`, `result` set | committed |\n| fal answered `ERROR` (its `error` text is the run's `last_error_msg`; `job.generation.failed` carries `error_class: 'ProviderFailed'`) | `failed` | `generation_status: failed` | refunded |\n| fal never called back | `failed` (`GenerationExpired`) at the timeout | `failed` | refunded |\n| the start call to fal failed with a 5xx / network fault | retried with backoff; terminal after the attempts | `processing` \u2192 the outcome above | held until then |\n| the user had too few credits | ended at once (`ReserveInsufficient`), never queued | `failed` (the function marks it); that `request_key` is spent \u2014 retry after a top-up with a new one | nothing held |\n| too many renders in flight, or a jobs-side fault | not created (the function answers the caller `429` with the wait in `retry_after`, or `502`) | `pending`, no run \u2014 calling start-render again with the **same** `request_key` re-drives it | nothing held |\n\n## Evidence\n\n- **Executed** (vxil's own integration tests):\n the `?fal_webhook=` callback URL is signed and verifies, the provider receives exactly the model input,\n `OK` settles completed with `payload.images` mirrored as `result` and the hold committed, `ERROR`\n settles failed with the hold refunded, a progress ping stays processing.\n- **Read in fal's documentation, not executed against fal from vxil** \u2014 the queue base\n `https://queue.fal.run/<model>`, the `fal_webhook` query parameter, the webhook body\n `{ request_id, status: 'OK' | 'ERROR', payload, error }`, and the model ids `fal-ai/flux/dev` and\n `fal-ai/veo3` with their inputs. Check the model page for each model's exact input fields before you\n ship, and keep `num_images` / `duration` to what your model accepts.\n\n## Your key, and where it lives\n\n`fal_key` is a function secret; `start-render` reads it at invoke time and puts it in the generation\nrun's provider headers, which vxil stores with the run (your project only) for as long as the jobs\nretention keeps the run, and sends to fal on the start call. It is **never returned by a read**:\n`GET /v1/jobs/runs/{run_id}` (and the dashboard and MCP reads built on it) shows the header names of a\ngeneration run with every value as `[redacted]`. Rotate it with `vxil secrets set functions/fal_key`;\nruns already queued keep the key they were started with.\n",
18909
19004
  "functions": {
18910
19005
  "start-render.ts": "// start-render.ts \u2014 start ONE fal.ai render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/start-render (with the user's session)\n// { \"kind\": \"image\" | \"video\", \"prompt\": \"\u2026\", \"request_key\": \"<your idempotency key>\" }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, generation_status }\n// this user's request_key already started one\n//\n// What happens after the 202 is vxil's, not this function's:\n// \u2022 the generation lane POSTs fal's QUEUE API with your key and the signed\n// callback URL in `?fal_webhook=` (completion.callback.query_param);\n// \u2022 fal calls back `{ status: 'OK' | 'ERROR', payload, error? }` \u2014 status_map\n// turns its words into completed / failed;\n// \u2022 result_path picks `payload.images` (or `payload.video`), and the status\n// mirror writes it onto this render's `result` with generation_status;\n// \u2022 the held credits commit on completed and are REFUNDED on failed, on a\n// run fal never calls back about (the timeout), and on a cancel.\n// Every run ends with job.generation.completed or job.generation.failed\n// (generation_id = the render's item_id, correlation_id = its request_key) if\n// you want to react to it in another function.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's `dedupe_key`\n// (owner + ':' + request_key \u2014 PER USER, so one user's key can never block\n// another's) is declared unique, so a second start with the same key is a\n// 409. On a 409 we read THIS user's row: a row that already has its run is a\n// duplicate; a row with no run (the first start died, or its enqueue answer\n// was lost, between the row and the enqueue) is RE-DRIVEN \u2014 the generation\n// run carries the same dedupe_key as its idempotency_key, so jobs hands back\n// the existing run instead of enqueueing a second fal job.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { kind?: string; prompt?: string; request_key?: string };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = { item_id: string; data: { kind?: string; prompt?: string; run_id?: string; generation_status?: string } };\n\n/** fal model ids (https://fal.ai/models) \u2014 swap freely; both are queue APIs. */\nconst MODELS = {\n image: { url: 'https://queue.fal.run/fal-ai/flux/dev', credits: 1, resultPath: 'payload.images' },\n video: { url: 'https://queue.fal.run/fal-ai/veo3', credits: 10, resultPath: 'payload.video' },\n} as const;\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 jobs = env.scoped_jwts?.jobs;\n const falKey = env.secrets?.fal_key;\n if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n if (!falKey) return Response.json({ error: 'store your fal key: vxil secrets set functions/fal_key' }, { status: 500 });\n // the held credits are FORCED onto the verified end-user \u2014 a function\n // cannot hold credits against a user it does not act for\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: 'invoke start-render with the user\\'s session (end-user mode)' }, { status: 401 });\n\n const kind = env.payload?.kind === 'video' ? 'video' : env.payload?.kind === 'image' ? 'image' : null;\n const prompt = typeof env.payload?.prompt === 'string' ? env.payload.prompt.trim().slice(0, 2_000) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n if (!kind || !prompt || !requestKey) {\n return Response.json({ error: 'need { kind: image|video, prompt, request_key }' }, { status: 422 });\n }\n const dedupeKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // 1. the render row (owned by the user \u2014 cms forces `owner` in end-user mode;\n // the render_dedupe_key hook checks dedupe_key = owner + ':' + request_key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n dedupe_key: dedupeKey, request_key: requestKey, owner: user, kind, prompt,\n credits: MODELS[kind].credits, generation_status: 'pending', created_at: new Date().toISOString(),\n },\n }),\n });\n if (created.status === 409) {\n // THIS user's row for this key (the read is owner-scoped in end-user mode)\n const filter = encodeURIComponent(JSON.stringify({ dedupe_key: dedupeKey }));\n const found = await fetch(`${base}/v1/cms/items/renders?filter=${filter}&limit=1`, { headers: H });\n const row = found.ok ? ((await found.json()) as { data?: { items?: RenderRow[] } }).data?.items?.[0] : undefined;\n if (!row) return Response.json({ error: `render lookup: ${found.status}` }, { status: 502 });\n if (row.data.run_id || row.data.generation_status === 'failed') {\n return Response.json({\n duplicate: true, request_key: requestKey, item_id: row.item_id,\n run_id: row.data.run_id ?? null, generation_status: row.data.generation_status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on dedupe_key)\n const rowKind = row.data.kind === 'video' ? 'video' : 'image';\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, row.item_id, rowKind, row.data.prompt ?? prompt);\n }\n if (!created.ok) return Response.json({ error: `render row: ${created.status}` }, { status: 502 });\n const itemId = ((await created.json()) as { data: { item_id: string } }).data.item_id;\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, itemId, kind, prompt);\n },\n};\n\n/** 2. the fal queue job, babysat by vxil, credits held for it. Idempotent on\n * `dedupeKey`: a re-drive gets the run that already exists. */\nasync function startRun(\n base: string, H: Record<string, string>, jobs: string, falKey: string, user: string,\n dedupeKey: string, requestKey: string, itemId: string, kind: 'image' | 'video', prompt: string,\n): Promise<Response> {\n const model = MODELS[kind];\n const enq = await fetch(`${base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: `fal.${kind}`,\n // the body is EXACTLY fal's model input (query-string callbacks send\n // provider.body as-is \u2014 no payload / callback_url keys merged in)\n provider: {\n url: model.url,\n method: 'POST',\n headers: { authorization: `Key ${falKey}` },\n body: kind === 'image' ? { prompt, num_images: 1 } : { prompt, duration: '8s' },\n },\n completion: {\n mode: 'webhook',\n status_path: 'status',\n callback: { query_param: 'fal_webhook' },\n status_map: { OK: 'completed', ERROR: 'failed' },\n result_path: model.resultPath,\n },\n status_mirror: { feature: 'cms', collection: 'renders', record_id: itemId, column: 'generation_status' },\n reserve_credits: { amount: model.credits, user_id: user, credit_type: 'media_credits', reason: `fal ${kind}` },\n timeout: { after_ms: kind === 'image' ? 300_000 : 900_000 },\n // rides job.generation.* as generation_id / correlation_id\n payload: { generation_id: itemId, correlation_id: requestKey },\n idempotency_key: dedupeKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held \u2014 mark the row and say so.\n // This key is spent; a new attempt (after a top-up) uses a new request_key.\n await markFailed(base, H, itemId);\n return Response.json({ error: 'insufficient_credits', item_id: itemId }, { status: 402 });\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 5xx: NO run is promised \u2014 leave the row\n // pending with no run, so a retry with the SAME request_key re-drives it\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, item_id: itemId, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 ? 429 : 502 },\n );\n }\n const runId = ((await enq.json()) as { data: { run_id: string } }).data.run_id;\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { run_id: runId } }),\n });\n return Response.json({ item_id: itemId, run_id: runId, credits: model.credits }, { status: 202 });\n}\n\nasync function markFailed(base: string, H: Record<string, string>, itemId: string): Promise<void> {\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { generation_status: 'failed' } }),\n });\n}\n"
18911
19006
  }
@@ -18938,7 +19033,7 @@ export default defineConfig({
18938
19033
  "stripe_webhook"
18939
19034
  ],
18940
19035
  "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// \"Storefront\" \u2014 a single-seller e-commerce backend, declared end-to-end in ONE\n// typed file + four functions. There is no vxil \"e-commerce feature\": this is a\n// BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 the relational spine (catalog, variants, carts, orders, coupons, reviews)\n// \u2022 payments \u2192 a payments INTEGRATION with your own Stripe account (checkout\n// sessions + a store-credit ledger; vxil is never in the flow of funds)\n// \u2022 auth \u2192 shopper accounts, incl. anonymous GUEST checkout\n// \u2022 functions \u2192 the domain logic that isn't config: the checkout SAGA + pricing engine\n// \u2022 notifications \u2192 order receipts + abandoned-cart nudges\n// The fully-annotated deep version of this blueprint lives in examples/ecommerce.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // published rows are the live catalog; drafts are staging\n // Fail-safe: a verified end-user may only touch collections that declare an\n // ownerField (carts/orders/reviews) \u2014 never a tenant-wide collection.\n strictEndUserScope: true,\n hooks: {\n // \u2605 THE ORDER STATE MACHINE: reject any illegal status transition, atomically,\n // in the same write tx. (Cross-row work \u2014 the checkout saga \u2014 is a function.)\n order_transition: {\n collection: 'orders',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n \"item.status == before.status\" +\n \" || (before.status == 'pending' && (item.status == 'paid' || item.status == 'cancelled'))\" +\n \" || (before.status == 'paid' && (item.status == 'fulfilled' || item.status == 'refunded'))\" +\n \" || (before.status == 'fulfilled' && item.status == 'shipped')\",\n message: 'illegal order status transition',\n },\n // A review must carry a 1\u20135 rating.\n review_rating: {\n collection: 'reviews',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.rating >= 1 && item.rating <= 5',\n message: 'rating must be 1\u20135',\n },\n },\n },\n\n auth: {\n methods: { emailPassword: true, magicLink: true },\n anonymous: { enabled: true }, // guest checkout \u2192 claim the email later\n },\n\n payments: {\n provider: 'stripe',\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n defaults: { currency: 'usd' },\n // The ledger is for STORE CREDIT / gift cards / loyalty (add-only grants +\n // balance-guarded consume) \u2014 populate productMap/tierMap per vxil.com/docs/guide/06-feature-catalog (payments).\n ledger: { productMap: {}, tierMap: {} },\n },\n\n notifications: { provider: 'mock', fromEmail: 'orders@storefront.example' },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n products: {\n singular: 'product',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the SHOPFRONT \u2014 published products read with\n // NO API key over GET /v1/cms/public/:tenantId/products, edge-cached. This is\n // the anonymous browse tier; the transactional collections below (carts,\n // orders, coupons) are NOT public and stay behind the owner-scoped authed lane.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n category: { type: 'string', indexSlot: 's3' },\n brand: { type: 'string', indexSlot: 's4' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n rating_avg: { type: 'float', indexSlot: 'n2' }, // derived from reviews\n description: { type: 'text' },\n images: { type: 'json' },\n },\n },\n variants: {\n singular: 'variant',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the product-detail page needs the sellable\n // variants (price, availability, provider price_ref) \u2014 all shopfront-public\n // by design. `stock` here is a public availability signal; the oversell-safe\n // DECREMENT still happens only on the authed write lane inside checkout.ts.\n public: true,\n fields: {\n // `indexed` (guide ch. 4, indexed fields): the product page filters variants by\n // product (`{ product: productId }`) \u2014 equality only, so no slot is spent on it.\n product: { type: 'relation', relationTo: 'products', indexed: true },\n sku: { type: 'string', required: true, indexSlot: 's1', unique: true },\n title: { type: 'string', indexSlot: 's2' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n // \u2605 THE OVERSELL-SAFE STOCK PATTERN: validation.min:0 makes a `$inc{stock:-qty}`\n // a single-statement conditional decrement (WHERE stock-qty >= 0) \u2014 exactly one\n // 200 under N concurrent buyers, 409 inc_out_of_bounds otherwise. No oversell.\n stock: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n // The PROVIDER's price id for this variant (e.g. a Stripe Price) \u2014 the hosted\n // checkout-session charges by provider price ref, so mirror each sellable\n // variant into your Stripe catalog and store its id here (BYO provider).\n price_ref: { type: 'string' },\n options: { type: 'json' }, // { size, color, \u2026 } \u2014 not indexed\n },\n },\n carts: {\n singular: 'cart',\n ownerField: 'end_user', // owner-scoped: a shopper (or guest session) sees only their own carts\n fields: {\n status: { type: 'string', indexSlot: 's1' }, // open | ordered | abandoned\n currency: { type: 'string', indexSlot: 's2' },\n end_user: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n last_activity: { type: 'datetime', indexSlot: 't1' }, // abandoned-cart cron filter\n },\n },\n cart_items: {\n singular: 'cart_item',\n fields: {\n // checkout + price-cart read a cart's lines by `{ cart: cartId }` on every call\n cart: { type: 'relation', relationTo: 'carts', indexed: true },\n variant: { type: 'relation', relationTo: 'variants' },\n qty: { type: 'int', indexSlot: 'n1', validation: { min: 1 } },\n unit_price_cents: { type: 'int', indexSlot: 'n2' }, // price snapshot at add-to-cart\n // provider price-id snapshot at add-to-cart (from variants.price_ref) \u2014\n // checkout's line_items charge by THIS ref (payments checkout-sessions contract).\n price_ref: { type: 'string', required: true },\n },\n },\n orders: {\n singular: 'order',\n ownerField: 'end_user',\n fields: {\n number: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // \u2605 EXACTLY-ONCE PLACEMENT: unique cart_ref \u2014 N racing checkouts of the same\n // cart \u2192 exactly one order insert succeeds (409 on the rest = idempotent).\n cart_ref: { type: 'string', required: true, indexSlot: 's2', unique: true },\n status: { type: 'string', indexSlot: 's3' }, // pending|paid|fulfilled|shipped|refunded|cancelled\n end_user: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n total_cents: { type: 'int', indexSlot: 'n1' },\n placed_at: { type: 'datetime', indexSlot: 't1' },\n lines: { type: 'json' }, // [{ variant, qty, unit_price_cents, price_ref }] snapshot\n },\n },\n coupons: {\n singular: 'coupon',\n fields: {\n code: { type: 'string', required: true, indexSlot: 's1', unique: true },\n kind: { type: 'string', indexSlot: 's2' }, // percent | fixed\n value: { type: 'int', indexSlot: 'n1' }, // percent (1\u2013100) or cents\n max_uses: { type: 'int', indexSlot: 'n2' },\n },\n },\n // \u2605 One row per redemption. When you honor a coupon at capture, write the row\n // with a `guards[]` count-cap ON THE WRITE BODY \u2014 { lock: 'coupon:'+code,\n // guards: [{ filter: { code }, max: max_uses }] } (vxil.com/docs/api;\n // guard/guards ride the request, they are NOT config) \u2014 so a coupon can never\n // over-redeem under concurrency. (Not wired into checkout.ts \u2014 add it there.)\n coupon_redemptions: {\n singular: 'coupon_redemption',\n fields: {\n code: { type: 'string', required: true, indexSlot: 's1' },\n order: { type: 'relation', relationTo: 'orders' },\n },\n },\n // Owner-scoped write (a verified shopper posts their own), moderated via draft/publish.\n reviews: {\n singular: 'review',\n ownerField: 'end_user',\n fields: {\n product: { type: 'relation', relationTo: 'products' },\n rating: { type: 'int', required: true, indexSlot: 'n1', validation: { min: 1, max: 5 } },\n title: { type: 'string', indexSlot: 's1' },\n end_user: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n body: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions \u2500\u2500\n functions: {\n // The checkout SAGA \u2014 reserve inventory + create order + open payment, all-or-nothing.\n // Invoke checkout/price-cart SERVER-SIDE (your backend, server key): with\n // strictEndUserScope on, an end-user-mode invocation is correctly denied on the\n // shared collections they touch (cart_items/variants/coupons).\n checkout: {\n entry: './functions/checkout.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'payments:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // The pricing ENGINE \u2014 full JS (stacking/BOGO/tiers/coupons); cross-row cart math\n // is forbidden in hooks by design. Recompute server-side = anti-tamper.\n 'price-cart': {\n entry: './functions/price-cart.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read'],\n },\n // Settlement: on order \u2192 paid, fire the receipt + fulfillment webhook (side-effects).\n 'on-order-paid': {\n entry: './functions/on-order-paid.ts',\n trigger: { kind: 'cmsHook', collection: 'orders', event: 'beforeUpdate' },\n scopes: ['cms:read', 'notifications:send'],\n egressAllow: ['fulfillment.example.com'], // your 3PL/warehouse webhook\n },\n // Abandoned-cart nudge \u2014 hourly cron sweep over stale open carts.\n 'abandoned-cart': {\n entry: './functions/abandoned-cart.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n // cms:write \u2014 the sweep PATCHes each nudged cart to `abandoned` (out of the filter)\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n },\n },\n\n secrets: {\n stripe_secret: { feature: 'payments', description: 'Stripe secret key (BYO)' },\n stripe_webhook: { feature: 'payments', description: 'Stripe webhook signing secret' },\n },\n\n seed: {\n cms: [\n {\n collection: 'products',\n items: [\n { title: 'Aeron Chair', slug: 'aeron-chair', category: 'furniture', brand: 'Herman Miller', price_cents: 149900, rating_avg: 4.8 },\n { title: 'Standing Desk', slug: 'standing-desk', category: 'furniture', brand: 'Uplift', price_cents: 59900, rating_avg: 4.6 },\n ],\n },\n { collection: 'coupons', items: [{ code: 'WELCOME10', kind: 'percent', value: 10, max_uses: 1000 }] },\n ],\n },\n});\n",
18941
- "readme": "# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the lock + guard pattern, guide ch. 4 \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge \u2014 one per abandonment: a nudged cart is marked `abandoned`).\n\n**Apply it:**\n\n```bash\nvxil init --template storefront\nvxil quickstart --env staging --no-push\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook\nvxil push\nvxil seed # demo products + the WELCOME10 coupon\nvxil gen\n```\n\n> **Plan note.** The four functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, guide ch. 4, uniqueness), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n\n```typescript\n// browse the live catalog with a server key (typed SDK)\nconst { items } = await vx.from('products').query({\n filter: { $status: 'published' },\n sort: '-price_cents',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = 'published'` and edge-caches the response. Only the write path (cart,\n checkout, admin) needs a key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-price_cents', limit: 24 });\n// a product's sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic('ten_your_tenant_id', 'variants', { filter: { product: productId } });\n```\n\n5. **Equality without spending a slot** \u2014 `variants.product` and `cart_items.cart` are only ever matched by\n equality (`{ product: productId }`, `{ cart: cartId }`), so they are marked `indexed: true` instead of\n taking one of the eight index slots: those filters are index-served on the authed list and the public\n lane alike (up to 4 per collection; guide ch. 4, indexed fields).\n\n**Reader UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** [`examples/ecommerce`](../../examples/ecommerce) is the fully-annotated deep version of this\nblueprint; vxil.com/docs/guide/04-data-with-cms (**public delivery**), vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (`$inc`, `lock`/`guards[]` on the item write routes),\nvxil.com/docs/guide/06-feature-catalog (payments),\nvxil.com/docs/guide/08-running-your-code-functions, and `templates/catalog/` (a content-only\npublic product grid).\n",
19036
+ "readme": '# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the lock + guard pattern, guide ch. 4 \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge \u2014 one per abandonment: a nudged cart is marked `abandoned`).\n\n**Apply it:**\n\n```bash\nmkdir my-store && cd my-store\nvxil init --template storefront\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook # your Stripe TEST-mode secret key + webhook signing secret (hidden prompts)\nvxil push # collections + hooks + the four functions; writes vxil.types.ts\nvxil seed # two demo products + the WELCOME10 coupon (drafts)\n```\n\n> **Plan note.** The four functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, guide ch. 4, uniqueness), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n **Two known gaps in the shipped `checkout.ts` \u2014 fix them before real traffic:**\n - **A repeat checkout leaks stock.** The function decrements `stock` *before* it creates the order,\n and the `409` (`already_placed`) branch returns without giving that stock back (only the other\n failure branch compensates). Each repeat or racing checkout of one cart costs one unit per line,\n for good. Fix: run the same `$inc: { stock: +qty }` compensation loop before answering\n `already_placed` \u2014 or, better, look the order up by `cart_ref` before reserving anything.\n - **Order numbers collide across carts.** `number` is `` `ORD-${cart_id.slice(0, 8)}` `` \u2014 the\n `itm_` prefix plus the first four id characters, which are a coarse timestamp, so carts created\n within about three days share a number. `orders.number` is `unique`, so a *second* cart\'s checkout\n also gets a `409` and is reported `already_placed` although no order exists. Fix: change that line\n to `` `ORD-${cart_id.slice(4)}` `` (the whole id) and push.\n\n```typescript\n// browse the live catalog with a server key (typed SDK;\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }))\nconst { items } = await vx.from(\'products\').query({\n filter: { $status: \'published\' },\n sort: \'-price_cents\',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = \'published\'` and caches the response for up to 60 seconds, so\n publish the seeded products first (step 2 below). Only the write path (cart, checkout, admin) needs a\n key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from \'https://vxil.com/sdk.mjs\';\n// the public grid \u2014 NO api key\nconst TENANT_ID = \'00000000-0000-0000-0000-000000000000\'; // yours: the `tenant:` line quickstart printed\nconst { items } = await listCmsPublic(TENANT_ID, \'products\', { sort: \'-price_cents\', limit: 24 });\n// a product\'s sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic(TENANT_ID, \'variants\', { filter: { product: productId } });\n```\n\n5. **Equality without spending a slot** \u2014 `variants.product` and `cart_items.cart` are only ever matched by\n equality (`{ product: productId }`, `{ cart: cartId }`), so they are marked `indexed: true` instead of\n taking one of the eight index slots: those filters are index-served on the authed list and the public\n lane alike (up to 4 per collection; guide ch. 4, indexed fields).\n\n## First order, end to end\n\nThe seed has products but no variants, carts or price ids, so one order needs four short steps. Use\nStripe **test mode** throughout; `price_\u2026` below is a Price id from your Stripe test dashboard.\n\n1. **Let Stripe reach your backend.** In Stripe\'s dashboard, add a webhook endpoint\n `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenant id>` (the `tenant:` line quickstart\n printed), and store that endpoint\'s signing secret as `stripe_webhook` (above).\n2. **A buyer, a sellable variant and a cart** (server key; `vxil api` uses the bound project\'s). The\n cart\'s `end_user` must be a real end-user of the project, or the receipt in step 4 has nobody to go to:\n ```bash\n vxil api POST /v1/users --data \'{"id":"usr_demo","email":"buyer@example.com"}\'\n PRODUCT=$(vxil api GET \'/v1/cms/items/products?filter={"slug":"aeron-chair"}\' | jq -r \'.items[0].item_id\')\n vxil api POST /v1/cms/items/products/$PRODUCT/publish\n VARIANT=$(vxil api POST /v1/cms/items/variants --data \'{"data":{"product":"\'$PRODUCT\'","sku":"AERON-B","title":"Aeron, size B","price_cents":149900,"stock":5,"price_ref":"price_\u2026"},"status":"published"}\' | jq -r .item_id)\n CART=$(vxil api POST /v1/cms/items/carts --data \'{"data":{"status":"open","currency":"usd","end_user":"usr_demo"},"status":"published"}\' | jq -r .item_id)\n vxil api POST /v1/cms/items/cart_items --data \'{"data":{"cart":"\'$CART\'","variant":"\'$VARIANT\'","qty":1,"unit_price_cents":149900,"price_ref":"price_\u2026"},"status":"published"}\'\n ```\n3. **Check out** (from your server, as here, never from the browser):\n ```bash\n vxil functions invoke checkout --data \'{"cart_id":"\'$CART\'"}\'\n # \u2192 { "order_id": "itm_\u2026", "number": "\u2026", "checkout_url": "https://checkout.stripe.com/\u2026" }\n vxil functions invoke checkout --data \'{"cart_id":"\'$CART\'"}\'\n # \u2192 { "status": "already_placed", "number": "\u2026" } \u2014 the same cart never makes a second order\n ```\n The variant\'s `stock` is now 4 after the first call \u2014 and **3** after the repeat, because of the\n stock leak described under What to learn #3 (apply that fix and the repeat leaves it at 4). Open `checkout_url` and pay with Stripe\'s test card `4242 4242 4242 4242`\n (any future date, any CVC).\n4. **Mark the order paid.** The payment folds into the payments integration as\n `payments.charge.succeeded`, but this blueprint does **not** ship the handler that flips the order:\n that is yours to write (a function subscribed to the `payments.` prefix, or your backend after the\n hosted checkout returns). To watch the rest of the flow, flip it by hand:\n ```bash\n vxil api PATCH /v1/cms/items/orders/<order_id> --data \'{"data":{"status":"paid"}}\'\n ```\n `on-order-paid` then e-mails the receipt to `usr_demo` (the `mock` notifications provider records it \u2014\n see it with `vxil api GET /v1/notifications/deliveries`; the function ignores send errors, so if the\n buyer from step 2 is missing you simply see no delivery) and posts the\n fulfilment webhook to `fulfillment.example.com`, which you replace with your warehouse\'s host in the\n function\'s `egressAllow` and code. The state-machine hook refuses any other jump (`pending \u2192 shipped`\n is a clean `422`).\n\n**Reader UI (optional):** drop **`@vxil/react/comments`** (`npm i @vxil/react @vxil/sdk`) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (**public delivery**), vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (`$inc`, `lock`/`guards[]` on the item write routes),\nvxil.com/docs/guide/06-feature-catalog (payments),\nvxil.com/docs/guide/08-running-your-code-functions, and the **catalog** blueprint (a content-only\npublic product grid).\n',
18942
19037
  "functions": {
18943
19038
  "abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n//\n// ONE nudge per abandonment: after the send, the cart is marked `abandoned`, which takes\n// it out of the `status: 'open'` filter \u2014 so the next tick reads the NEXT stale carts\n// instead of the same 100 again (and a shopper is not nudged every hour forever). Your\n// app sets `status` back to `open` (with a fresh `last_activity`) when the shopper\n// touches the cart again; a later abandonment is nudged again. The Idempotency-Key\n// (cart id + last_activity) keeps a redelivered tick, or a failed mark, from\n// sending the same nudge twice.\n\n// cron-walk: drains-filter \u2014 a nudged cart is marked `abandoned` and leaves the `status: 'open'` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Cart { status: string; last_activity: string; end_user?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, guide ch. 6, notifications).\n let nudged = 0;\n let markFailed = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `abandoned-cart:${c.item_id}:${c.data.last_activity}`,\n },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (!sent) continue; // left open: the next tick tries again (same Idempotency-Key)\n nudged++;\n // Out of the filter: the next tick reads the next stale carts, not these again.\n // The mark needs `cms:write` (declared in vxil.config.ts). A refused mark is\n // COUNTED, never swallowed: the cart stays open, so the next tick re-reads it\n // (the Idempotency-Key keeps the nudge from going out twice) \u2014 and a run that\n // reports `mark_failed > 0` answers 500, so it is SEEN (`functions.run.failed`).\n const marked = await fetch(`${base}/v1/cms/items/carts/${c.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { status: 'abandoned' } }),\n }).then((r) => r.ok).catch(() => false);\n if (!marked) markFailed++;\n }\n return Response.json({ scanned: carts.length, nudged, mark_failed: markFailed }, { status: markFailed > 0 ? 500 : 200 });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
18944
19039
  "checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; success_url?: string; cancel_url?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n const number = `ORD-${cart_id.slice(0, 8)}`;\n const orderRes = await fetch(`${base}/v1/cms/items/orders`, {\n method: 'POST', headers: H(cms),\n body: JSON.stringify({\n data: {\n number, cart_ref: cart_id, status: 'pending', end_user: shopper,\n total_cents: total, placed_at: new Date().toISOString(), lines,\n },\n }),\n });\n if (orderRes.status === 409) {\n // a concurrent checkout already placed this cart \u2192 idempotent: report it placed\n return json({ status: 'already_placed', number }, 200);\n }\n if (!orderRes.ok) {\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'order_create_failed' }, 502);\n }\n const order = (await orderRes.json()) as { data?: { item_id?: string } };\n\n // 4. open the hosted payment (one-time). Idempotency-Key = order number \u21D2 safe to retry.\n // The documented checkout-sessions contract (guide ch. 6, payments): user_id + line_items\n // [{ price_ref, quantity, amount_cents?, currency? }] + mode + success/cancel URLs.\n // price_ref is the PROVIDER's price id (a Stripe Price) snapshot on the cart line \u2014\n // Stripe's adapter charges by price id; amount_cents/currency serve amount-based\n // providers (PayPal payment mode). NOTE: the shipped Stripe adapter charges\n // line_items[0] only \u2014 for multi-line carts on Stripe, collapse to one provider\n // line (or one order-total price) before opening the session.\n const currency = cart.currency ?? 'usd';\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n headers: { ...H(pay), 'idempotency-key': number },\n body: JSON.stringify({\n user_id: shopper,\n mode: 'payment',\n line_items: lines.map((l) => ({ price_ref: l.price_ref, quantity: l.qty, amount_cents: l.unit_price_cents, currency })),\n success_url: success_url ?? 'https://storefront.example/checkout/success',\n cancel_url: cancel_url ?? 'https://storefront.example/checkout/cancel',\n }),\n });\n if (!sess.ok) {\n // the order stays placed (pending) \u2014 surface the payment error so the caller can\n // retry the session (same Idempotency-Key) after fixing price_refs / provider keys.\n return json({ order_id: order.data?.item_id, number, error: 'payment_session_failed' }, 502);\n }\n const s = (await sess.json()) as { data?: { url?: string } };\n\n return json({ order_id: order.data?.item_id, number, checkout_url: s.data?.url }, 201);\n },\n};\n\n// \u2500\u2500 tiny helpers (the vxil REST envelope is { data: { items }, meta }; items carry item_id) \u2500\u2500\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
@@ -18961,7 +19056,7 @@ export default defineConfig({
18961
19056
  "hasFunctions": false,
18962
19057
  "byoKeys": [],
18963
19058
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Catalog\" \u2014 a CONTENT-ONLY product catalog (browse/list products by category),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 products (resolved by relation)\n// This is the catalog CONTENT SHELL, not a storefront: checkout/inventory/orders\n// are the full `examples/ecommerce` blueprint (a reserve\u2192settle\u2192reverse saga in\n// tenant functions). Keep those in a function; a catalog is pure content.\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // draft products while staging; publish to list them\n hooks: {\n // Price (if given) can't be negative \u2014 a pure function of the row.\n product_price: {\n collection: 'products',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'isNull(item.price_cents) || item.price_cents >= 0',\n message: 'price_cents must be >= 0',\n },\n },\n },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n categories: {\n singular: 'category',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the category nav reads keyless too, so a\n // static storefront front-end can render the browse tree with no API key.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n products: {\n singular: 'product',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): a content catalog IS a reader-facing surface \u2014\n // published products are readable with NO API key over\n // GET /v1/cms/public/:tenantId/products, edge-cached, drafts never served.\n // A JAMstack storefront (or the served `listCmsPublic()` helper) fetches the\n // grid anonymously; only your admin writes need a key.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n brand: { type: 'string', indexSlot: 's4' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n rating: { type: 'float', indexSlot: 'n2' },\n description: { type: 'text' },\n images: { type: 'json' }, // [\"obj_\u2026\", \u2026] file refs or urls\n in_stock: { type: 'bool' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'products',\n items: [\n { title: 'Aeron Chair', slug: 'aeron-chair', brand: 'Herman Miller', price_cents: 149900, rating: 4.8, in_stock: true },\n { title: 'Standing Desk', slug: 'standing-desk', brand: 'Uplift', price_cents: 59900, rating: 4.6, in_stock: true },\n ],\n },\n ],\n },\n});\n",
18964
- "readme": "# Product Catalog template\n\nA **content-only** product catalog \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name + unique slug. **`public: true`** \u2014 the browse nav reads keyless.\n- `products` \u2014 title, unique slug, `category` relation (slot-bound for filtering), brand, `price_cents`\n (`min: 0`), rating, JSON `images`, `in_stock`. A Lane-A hook rejects a negative price. **`public: true`** \u2014\n the product grid reads keyless (see below).\n\n**This is the catalog shell, not a storefront.** Checkout, inventory, orders, and coupons \u2014 the transactional spine\n\u2014 are the full **`examples/ecommerce`** blueprint (a reserve\u2192settle\u2192reverse saga in tenant `functions`, plus the\noversell-safe `$inc` stock pattern and `guards[]` coupon caps). A catalog is pure content; add the commerce logic\nfrom that example when you need it.\n\n**Use it:**\n\n```bash\nvxil init --template catalog\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The same invariant, two layers** \u2014 `price_cents` carries declarative `validation: { min: 0 }` AND a Lane-A\n validate hook: field validation covers one field, a hook expression can span the whole row\n (vxil.com/docs/guide/07-validation-and-hooks).\n- **Numeric slots buy range + sort** \u2014 `price_cents` (`n1`) and `rating` (`n2`) make \"under $100, best-rated\n first\" a fully index-served query (guide ch. 4, querying).\n- **Content shell vs transactional spine** \u2014 cross-row commerce math (carts, stock, coupons) belongs in\n `functions`, never in a Lane-A hook.\n\n```ts\n// storefront browse with a server key: published products under $100, best-rated first\nconst { items } = await vx.from('products').query({\n filter: { price_cents: { $lte: 10000 }, $status: 'published' }, sort: '-rating', limit: 24,\n});\n```\n\n**The public grid \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `products` and `categories` are\n`public: true`, a static storefront renders with **no API key**: the edge forces `status = 'published'`\n(drafts never served) and edge-caches the response. Only admin writes need a key.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-rating', limit: 24 });\n// one category's products \u2014 the single-hop dotted-key join (guide ch. 4, relational depth)\nconst inCat = await listCmsPublic('ten_your_tenant_id', 'products', { filter: { 'category.slug': 'furniture' } });\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/api (the item write routes) \xB7\n`templates/storefront/` (the full commerce blueprint with checkout + a public shopfront) \xB7\n`examples/ecommerce/` (the full commerce saga) \xB7 `templates/blog/` (the same content shape for posts).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
19059
+ "readme": '# Product Catalog template\n\nA **content-only** product catalog \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name + unique slug. **`public: true`** \u2014 the browse nav reads keyless.\n- `products` \u2014 title, unique slug, `category` relation (slot-bound for filtering), brand, `price_cents`\n (`min: 0`), rating, JSON `images`, `in_stock`. A Lane-A hook rejects a negative price. **`public: true`** \u2014\n the product grid reads keyless (see below).\n\n**This is the catalog shell, not a storefront.** Checkout, inventory, orders, and coupons \u2014 the transactional spine\n\u2014 are the **storefront** blueprint (`vxil init --template storefront`: a reserve\u2192settle\u2192reverse saga in tenant\n`functions`, plus the oversell-safe `$inc` stock pattern and `guards[]` coupon caps). A catalog is pure content; add\nthe commerce logic from that blueprint when you need it.\n\n**Use it:**\n\n```bash\nmkdir my-catalog && cd my-catalog\nvxil init --template catalog\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\nvxil seed # two demo products (as drafts)\n```\n\n**See it work.** Seeded products are drafts, so publish one, then read it with no key at all:\n\n```bash\nID=$(vxil api GET /v1/cms/items/products | jq -r \'.items[0].item_id\')\nvxil api POST /v1/cms/items/products/$ID/publish\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_TENANT_ID (and your key) into the shell\ncurl "https://api.vxil.com/v1/cms/public/$VXIL_TENANT_ID/products?sort=-rating"\n# \u2192 { "data": { "items": [ { "item_id": "\u2026", "data": { "title": "\u2026", \u2026 } } ], "next_cursor": null } }\n```\n\nThe public lane caches for up to 60 seconds, so an item you just published can take a minute to appear.\nAfter you edit `vxil.config.ts`, `vxil push` applies the change and regenerates the types.\n\n**What to learn from this:**\n- **The same invariant, two layers** \u2014 `price_cents` carries declarative `validation: { min: 0 }` AND a Lane-A\n validate hook: field validation covers one field, a hook expression can span the whole row\n (vxil.com/docs/guide/07-validation-and-hooks).\n- **Numeric slots buy range + sort** \u2014 `price_cents` (`n1`) and `rating` (`n2`) make "under $100, best-rated\n first" a fully index-served query (guide ch. 4, querying).\n- **Content shell vs transactional spine** \u2014 cross-row commerce math (carts, stock, coupons) belongs in\n `functions`, never in a Lane-A hook.\n\n```ts\n// storefront browse with a server key (`vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! })):\n// published products under $100, best-rated first\nconst { items } = await vx.from(\'products\').query({\n filter: { price_cents: { $lte: 10000 }, $status: \'published\' }, sort: \'-rating\', limit: 24,\n});\n```\n\n**The public grid \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `products` and `categories` are\n`public: true`, a static storefront renders with **no API key**: the edge forces `status = \'published\'`\n(drafts never served) and edge-caches the response. Only admin writes need a key.\n\nThe seed creates no categories and leaves `category` empty, so for the one-category read below, create and\npublish a `furniture` category and link the product you published above:\n\n```bash\nCAT=$(vxil api POST /v1/cms/items/categories --data \'{"data":{"name":"Furniture","slug":"furniture"},"status":"published"}\' | jq -r .item_id)\nvxil api PATCH /v1/cms/items/products/$ID --data \'{"data":{"category":"\'$CAT\'"}}\'\n```\n\n```ts\nimport { listCmsPublic } from \'https://vxil.com/sdk.mjs\';\nconst TENANT_ID = \'00000000-0000-0000-0000-000000000000\'; // yours: the `tenant:` line quickstart printed\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic(TENANT_ID, \'products\', { sort: \'-rating\', limit: 24 });\n// one category\'s products: resolve the slug to an id, then filter by the relation\nconst { items: [cat] } = await listCmsPublic(TENANT_ID, \'categories\', { filter: { slug: \'furniture\' }, limit: 1 });\nconst inCat = await listCmsPublic(TENANT_ID, \'products\', { filter: { category: cat.item_id } });\n```\n\nPublic rows carry your fields under `data` (`items[0].data.title`). Dotted join keys\n(`{ \'category.slug\': \'furniture\' }`, guide ch. 4, relational depth) work on the keyed list\n(`vx.from(\'products\').query`) but not on the public lane, which answers `422` for them; the two-step read\nabove is the keyless way.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/api (the item write routes) \xB7\nthe **storefront** blueprint (the full commerce blueprint with checkout + a public shopfront) \xB7\nthe **blog** blueprint (the same content shape for posts).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
18965
19060
  "functions": {}
18966
19061
  },
18967
19062
  {
@@ -19037,7 +19132,7 @@ export default defineConfig({
19037
19132
  },
19038
19133
  },
19039
19134
  auth: { methods: { emailPassword: true } }, // bookers sign in as end-users
19040
- notifications: { provider: 'mock', fromEmail: 'noreply@bookings.app' },
19135
+ notifications: { provider: 'mock', fromEmail: 'noreply@bookings.example' },
19041
19136
  },
19042
19137
 
19043
19138
  // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500
@@ -19088,7 +19183,7 @@ export default defineConfig({
19088
19183
  },
19089
19184
  });
19090
19185
  `,
19091
- "readme": '# Event Bookings template\n\nEvents/appointments with **race-proof capacity** \u2014 declared end-to-end in one typed `vxil.config.ts`.\nCapacity here is not a hope or a cron sweep: it is a declarative invariant enforced atomically at write\ntime, so overselling is structurally impossible.\n\n**Provisions:**\n- `events` \u2014 title, unique slug, `starts_at`/`ends_at` (slot-bound for range queries), `capacity`, status.\n- `bookings` \u2014 `event` relation, `member` (the **owner field** \u2014 a booker only sees their own),\n status (`booked | cancelled`, enforced by a Lane-A validate hook), `booked_at`.\n- `auth` (email/password) \u2014 a booker is a verified end-user.\n- `notifications` \u2014 send confirmations from your own code (`mock` provider until you wire a real one).\n\n**Use it:**\n\n```bash\nvxil init --template bookings\nvxil quickstart --env staging\nvxil push\nvxil seed # the demo event ("Launch Workshop", capacity 3) \u2014 push alone doesn\'t seed\nvxil gen\n```\n\n**What to learn from this:**\n1. **Per-key serialization (`lock`).** `lock: "evt:<event_id>"` takes a per-(project,collection,key)\n lock at the start of the write \u2014 every writer of THIS event queues;\n writers of other events don\'t contend.\n2. **Multi-invariant `guards[]`.** Each guard asserts *"after this write, at most `max` live items match\n `filter`"* \u2014 seat capacity AND one-booking-per-member, checked co-atomically under the ONE lock.\n Guards ride the **write request body**, never config (vxil.com/docs/api: the item write routes).\n3. **Exactly one winner.** N concurrent bookings of the last seat: the lock serializes them, the first\n passes both counts and commits (**201**); every later one counts a full event and gets **409 `guard_failed`**\n with your `message` ("event is full"). Cancelling (PATCH `status` \u2192 `\'cancelled\'`) frees the seat,\n since the guard filters only count `status: \'booked\'` rows.\n\nThe canonical oversell-proof write (curl; `$EVENT_ID` = the seeded event, capacity 3 \u2014 note the\ntop-level `"status"` is the draft/published **lifecycle**, distinct from the booking\'s own `status` field):\n\n```bash\ncurl -s -X POST "$EDGE/v1/cms/items/bookings" \\\n -H "Authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d \'{\n "data": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked", "booked_at": "2026-08-01T17:00:00Z" },\n "status": "published",\n "lock": "evt:\'$EVENT_ID\'",\n "guards": [\n { "filter": { "event": "\'$EVENT_ID\'", "status": "booked" }, "max": 3, "message": "event is full" },\n { "filter": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked" }, "max": 1, "message": "already booked" }\n ]\n }\'\n```\n\nOr the typed SDK \u2014 `vx.from(...).create` takes `lock`/`guards` as write options:\n\n```ts\nconst { item_id } = await vx.from(\'bookings\').create(\n { event: eventId, member: me, status: \'booked\', booked_at: new Date().toISOString() },\n {\n status: \'published\',\n lock: `evt:${eventId}`,\n guards: [\n { filter: { event: eventId, status: \'booked\' }, max: capacity, message: \'event is full\' },\n { filter: { event: eventId, member: me, status: \'booked\' }, max: 1, message: \'already booked\' },\n ],\n },\n);\n```\n\n(`max` is the event\'s `capacity` \u2014 read the event row first; the guard re-counts atomically at write\ntime, so a stale seat COUNT can never oversell. A concurrently edited `capacity` is still read\npre-lock \u2014 pass the freshest value you have.)\n\n**Go deeper:** vxil.com/docs/api (lock/guard/guards on the item write routes), vxil.com/docs/guide/07-validation-and-hooks (Lane-A hooks), vxil.com/docs/guide/04-data-with-cms (owner-scoping);\nvxil.com/docs/guide/06-feature-catalog (auth); `examples/ecommerce/`, which teaches the same idiom for coupon redemption caps.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
19186
+ "readme": '# Event Bookings template\n\nEvents/appointments with **race-proof capacity** \u2014 declared end-to-end in one typed `vxil.config.ts`.\nCapacity here is not a hope or a cron sweep: it is a declarative invariant enforced atomically at write\ntime, so overselling is structurally impossible.\n\n**Provisions:**\n- `events` \u2014 title, unique slug, `starts_at`/`ends_at` (slot-bound for range queries), `capacity`, status.\n- `bookings` \u2014 `event` relation, `member` (the **owner field** \u2014 a booker only sees their own),\n status (`booked | cancelled`, enforced by a Lane-A validate hook), `booked_at`.\n- `auth` (email/password) \u2014 a booker is a verified end-user.\n- `notifications` \u2014 send confirmations from your own code (`mock` provider until you wire a real one).\n\n**Use it:**\n\n```bash\nmkdir my-bookings && cd my-bookings\nvxil init --template bookings\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\nvxil seed # the demo event ("Launch Workshop", capacity 3) \u2014 push alone doesn\'t seed\n```\n\nAfter you edit `vxil.config.ts`, `vxil push` applies it and regenerates the types.\n\n**What to learn from this:**\n1. **Per-key serialization (`lock`).** `lock: "evt:<event_id>"` takes a per-(project,collection,key)\n lock at the start of the write \u2014 every writer of THIS event queues;\n writers of other events don\'t contend.\n2. **Multi-invariant `guards[]`.** Each guard asserts *"after this write, at most `max` live items match\n `filter`"* \u2014 seat capacity AND one-booking-per-member, checked co-atomically under the ONE lock.\n Guards ride the **write request body**, never config (vxil.com/docs/api: the item write routes).\n3. **Exactly one winner.** N concurrent bookings of the last seat: the lock serializes them, the first\n passes both counts and commits (**201**); every later one counts a full event and gets **409 `guard_failed`**\n with your `message` ("event is full"). Cancelling (PATCH `status` \u2192 `\'cancelled\'`) frees the seat,\n since the guard filters only count `status: \'booked\'` rows.\n\nThe canonical oversell-proof write (curl; `$EVENT_ID` = the seeded event, capacity 3 \u2014 note the\ntop-level `"status"` is the draft/published **lifecycle**, distinct from the booking\'s own `status` field).\nFirst put the four values it uses into your shell:\n\n```bash\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_API_KEY + VXIL_BASE_URL\nexport KEY=$VXIL_API_KEY EDGE=$VXIL_BASE_URL\nEVENT_ID=$(vxil api GET \'/v1/cms/items/events?filter={"slug":"launch-workshop"}\' | jq -r \'.items[0].item_id\')\nME=member_1 # with a server key, `member` is whatever id you assert; an end-user session pins it\n```\n\n```bash\ncurl -s -X POST "$EDGE/v1/cms/items/bookings" \\\n -H "Authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d \'{\n "data": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked", "booked_at": "2026-08-01T17:00:00Z" },\n "status": "published",\n "lock": "evt:\'$EVENT_ID\'",\n "guards": [\n { "filter": { "event": "\'$EVENT_ID\'", "status": "booked" }, "max": 3, "message": "event is full" },\n { "filter": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked" }, "max": 1, "message": "already booked" }\n ]\n }\'\n```\n\n**See the guard hold.** Run that request with `ME=member_1`, `member_2` and `member_3`: each answers\n`201`. A fourth member gets `409 guard_failed` with your message, `event is full`, and a second booking\nby `member_1` gets `already booked`. That `409` is the success signal: the event cannot oversell however\nmany requests race for the last seat.\n\nOr the typed SDK \u2014 `vx.from(...).create` takes `lock`/`guards` as write options:\n\n```ts\nconst { item_id } = await vx.from(\'bookings\').create(\n { event: eventId, member: me, status: \'booked\', booked_at: new Date().toISOString() },\n {\n status: \'published\',\n lock: `evt:${eventId}`,\n guards: [\n { filter: { event: eventId, status: \'booked\' }, max: capacity, message: \'event is full\' },\n { filter: { event: eventId, member: me, status: \'booked\' }, max: 1, message: \'already booked\' },\n ],\n },\n);\n```\n\n(`max` is the event\'s `capacity` \u2014 read the event row first; the guard re-counts atomically at write\ntime, so a stale seat COUNT can never oversell. A concurrently edited `capacity` is still read\npre-lock \u2014 pass the freshest value you have.)\n\n**Go deeper:** vxil.com/docs/api (lock/guard/guards on the item write routes), vxil.com/docs/guide/07-validation-and-hooks (Lane-A hooks), vxil.com/docs/guide/04-data-with-cms (owner-scoping);\nvxil.com/docs/guide/06-feature-catalog (auth); the **storefront** blueprint, which uses the same idiom for coupon redemption caps.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
19092
19187
  "functions": {}
19093
19188
  },
19094
19189
  {
@@ -19109,7 +19204,7 @@ export default defineConfig({
19109
19204
  "hasFunctions": true,
19110
19205
  "byoKeys": [],
19111
19206
  "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// \"Helpdesk\" \u2014 a support-ticketing backend (tickets + threaded messages),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 tickets \u2192 ticket_messages (resolved by relation), with a\n// Lane-A STATE-MACHINE hook guarding ticket.status\n// \u2022 auth \u2192 requesters sign in as end-users; tickets are owner-scoped\n// \u2022 notifications \u2192 the acknowledgement message on every new ticket\n// \u2022 functions \u2192 the async side: the ack cms-hook + the hourly SLA sweep\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe owner scoping: a signed-in requester may only touch collections\n // with an `ownerField`. `tickets` has one; `ticket_messages` does not, so\n // without this flag every requester could read every thread. With it, the\n // thread is server-only for end users (your agent backend writes and reads\n // it) \u2014 give it an owner field if requesters should post replies themselves.\n strictEndUserScope: true,\n hooks: {\n // The TICKET STATE MACHINE: reject any illegal status transition,\n // atomically, in the same write (`closed` is terminal). Async\n // side-effects (the ack send, the SLA sweep) live in functions \u2014\n // a Lane-A hook is a pure expression and can't do I/O.\n ticket_transition: {\n collection: 'tickets',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.status == before.status' +\n \" || (before.status == 'open' && (item.status == 'pending' || item.status == 'resolved'))\" +\n \" || (before.status == 'pending' && (item.status == 'open' || item.status == 'resolved'))\" +\n \" || (before.status == 'resolved' && item.status == 'closed')\",\n message: 'illegal ticket status transition',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // requesters are verified end-users\n notifications: { provider: 'mock', fromEmail: 'support@helpdesk.example' },\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n tickets: {\n singular: 'ticket',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a signed-in requester\n // reads/edits ONLY their own tickets \u2014 one declarative flag, a no-op for\n // server callers (your agent backend sees the whole queue).\n ownerField: 'requester',\n fields: {\n subject: { type: 'string', required: true, indexSlot: 's1' },\n status: { type: 'string', indexSlot: 's2' }, // open | pending | resolved | closed (hook-guarded)\n priority: { type: 'string', indexSlot: 's3' }, // low | normal | high | urgent\n requester: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n opened_at: { type: 'datetime', indexSlot: 't1' },\n sla_due: { type: 'datetime', indexSlot: 't2' }, // the sla-sweep cron's range filter\n body: { type: 'text' },\n },\n },\n ticket_messages: {\n singular: 'ticket_message',\n fields: {\n ticket: { type: 'relation', relationTo: 'tickets', indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' }, // requester or agent id\n body: { type: 'text' },\n sent_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions \u2500\u2500\n functions: {\n // Acknowledgement: on ticket CREATE, RE-FETCH the ticket by id (the cms-hook\n // payload carries only { event, collection, item_id }) and send the requester\n // an acknowledgement through notifications.\n 'on-ticket-created': {\n entry: './functions/on-ticket-created.ts',\n trigger: { kind: 'cmsHook', collection: 'tickets', event: 'beforeCreate' },\n scopes: ['cms:read', 'notifications:send'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // SLA automation: hourly sweep of breached tickets (sla_due < now, still\n // open/pending, not yet urgent) \u2192 PATCH priority to 'urgent'. Idempotent\n // by construction \u2014 an escalated ticket falls out of the filter.\n 'sla-sweep': {\n entry: './functions/sla-sweep.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'tickets',\n items: [\n {\n subject: 'Cannot sign in on mobile',\n status: 'open',\n priority: 'normal',\n requester: 'user_demo',\n opened_at: '2026-07-01T09:00:00Z',\n sla_due: '2026-07-01T17:00:00Z',\n body: 'Login on the iOS app spins forever after entering credentials.',\n },\n ],\n },\n ],\n },\n});\n",
19112
- "readme": "# Helpdesk / Support Ticketing template\n\nA support-ticketing backend \u2014 requester-owned tickets with a hook-enforced status state machine,\nthreaded conversation messages, an acknowledgement send on every new ticket, and an hourly\nSLA-escalation cron \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**What it provisions:**\n- `tickets` \u2014 subject, `status` (state machine below), priority, `requester` (the **owner field**),\n `opened_at`, `sla_due`, body. Every queue-driving field is slot-indexed for filter/sort.\n- `ticket_messages` \u2014 the conversation thread: `ticket` relation, author, body, `sent_at`.\n- Features: `cms` + `auth` (email/password end-users) + `notifications` (mock provider) + `functions`.\n- Functions: `on-ticket-created` (cmsHook \u2192 acknowledgement send) and `sla-sweep` (hourly cron).\n\n**Apply it:**\n\n```bash\nvxil init --template helpdesk\nvxil quickstart --env staging --no-push\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push\nvxil gen\n```\n\n> **Plan note.** The two functions deploy on Free when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n- **A status state machine in a Lane-A hook** \u2014 the `beforeUpdate` validate allows only\n `open\u2192pending|resolved`, `pending\u2192open|resolved`, `resolved\u2192closed`; any other transition is a\n clean 422, atomically, in the write itself (vxil.com/docs/guide/07-validation-and-hooks).\n- **SLA automation as a cron function** \u2014 `sla-sweep` queries breached tickets with the filter DSL (guide ch. 4, querying)\n (`status $in` + `sla_due $lt`, slot-indexed) and PATCHes `priority: 'urgent'`; the `$ne: 'urgent'`\n term makes re-runs idempotent.\n- **Requester-scoped end-user access** \u2014 `tickets.ownerField = 'requester'`: a signed-in requester\n sees and edits only their **own** tickets (vxil.com/docs/guide/04-data-with-cms); server keys see the queue.\n- **Fail-safe scoping with `strictEndUserScope: true`** \u2014 `ticket_messages` has no owner field, so\n without the flag every signed-in requester could read every thread. With it, an end-user session gets\n `403 server_only` on any collection without an `ownerField`: the thread is written and read by your\n agent backend (server key). Give `ticket_messages` an owner field if requesters should post replies.\n\n```ts\nconst { item_id } = await vx.from('tickets').create({\n subject: 'Cannot sign in on mobile', status: 'open', priority: 'normal',\n requester: 'user_demo', opened_at: new Date().toISOString(),\n sla_due: new Date(Date.now() + 8 * 3600e3).toISOString(), body: 'Steps to reproduce\u2026',\n});\n\n// the agent queue, most-overdue first (slot-indexed \u2192 typed filter/sort, index-served)\nconst { items } = await vx.from('tickets').query({\n filter: { status: { $in: ['open', 'pending'] } }, sort: 'sla_due', limit: 25,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 owner-scoping) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/08-running-your-code-functions \xB7 vxil.com/docs/guide/06-feature-catalog (notifications) \xB7 `examples/ecommerce/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
19207
+ "readme": "# Helpdesk / Support Ticketing template\n\nA support-ticketing backend \u2014 requester-owned tickets with a hook-enforced status state machine,\nthreaded conversation messages, an acknowledgement send on every new ticket, and an hourly\nSLA-escalation cron \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**What it provisions:**\n- `tickets` \u2014 subject, `status` (state machine below), priority, `requester` (the **owner field**),\n `opened_at`, `sla_due`, body. Every queue-driving field is slot-indexed for filter/sort.\n- `ticket_messages` \u2014 the conversation thread: `ticket` relation, author, body, `sent_at`.\n- Features: `cms` + `auth` (email/password end-users) + `notifications` (mock provider) + `functions`.\n- Functions: `on-ticket-created` (cmsHook \u2192 acknowledgement send) and `sla-sweep` (hourly cron).\n\n**Apply it:**\n\n```bash\nmkdir my-helpdesk && cd my-helpdesk\nvxil init --template helpdesk\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hook + both functions; writes vxil.types.ts\nvxil api POST /v1/users --data '{\"id\":\"user_demo\",\"email\":\"requester@example.com\"}' # the seeded ticket's requester\nvxil seed # one open ticket whose SLA is already past due\n```\n\n**See it work.** The seeded ticket's create fires `on-ticket-created`, which sends the acknowledgement to\nthe ticket's `requester` \u2014 recorded by the `mock` notifications provider (`vxil api GET\n/v1/notifications/deliveries`); `vxil functions logs on-ticket-created` shows the run. The `vxil api POST\n/v1/users` line above matters: the seed's ticket names `user_demo` as its requester but does not create\nthat end-user, and a send to an unknown user answers `404 user_not_found`. The function still finishes, so\nwithout that line the log shows a successful run with `delivery: 404` and no delivery is recorded. Its `sla_due` is\nalready in the past, so run the sweep by hand rather than waiting for the hour:\n\n```bash\nvxil functions invoke sla-sweep\nvxil api GET /v1/cms/items/tickets | jq '.items[0].data.priority' # \u2192 \"urgent\"\n```\n\nRun the sweep again and nothing changes: the `$ne: 'urgent'` term makes it idempotent.\n\n> **Plan note.** The two functions deploy on Free when the project's workload is `staging` or\n> `development` (the `vxil projects workload` step above; create the project with quickstart, not an\n> empty `vxil projects create`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n- **A status state machine in a Lane-A hook** \u2014 the `beforeUpdate` validate allows only\n `open\u2192pending|resolved`, `pending\u2192open|resolved`, `resolved\u2192closed`; any other transition is a\n clean 422, atomically, in the write itself (vxil.com/docs/guide/07-validation-and-hooks).\n- **SLA automation as a cron function** \u2014 `sla-sweep` queries breached tickets with the filter DSL (guide ch. 4, querying)\n (`status $in` + `sla_due $lt`, slot-indexed) and PATCHes `priority: 'urgent'`; the `$ne: 'urgent'`\n term makes re-runs idempotent.\n- **Requester-scoped end-user access** \u2014 `tickets.ownerField = 'requester'`: a signed-in requester\n sees and edits only their **own** tickets (vxil.com/docs/guide/04-data-with-cms); server keys see the queue.\n- **Fail-safe scoping with `strictEndUserScope: true`** \u2014 `ticket_messages` has no owner field, so\n without the flag every signed-in requester could read every thread. With it, an end-user session gets\n `403 server_only` on any collection without an `ownerField`: the thread is written and read by your\n agent backend (server key). Give `ticket_messages` an owner field if requesters should post replies.\n\n```ts\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }) \u2014 the key from `vxil env pull`\nconst { item_id } = await vx.from('tickets').create({\n subject: 'Cannot sign in on mobile', status: 'open', priority: 'normal',\n requester: 'user_demo', opened_at: new Date().toISOString(),\n sla_due: new Date(Date.now() + 8 * 3600e3).toISOString(), body: 'Steps to reproduce\u2026',\n});\n\n// the agent queue, most-overdue first (slot-indexed \u2192 typed filter/sort, index-served)\nconst { items } = await vx.from('tickets').query({\n filter: { status: { $in: ['open', 'pending'] } }, sort: 'sla_due', limit: 25,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 owner-scoping) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/08-running-your-code-functions \xB7 vxil.com/docs/guide/06-feature-catalog (notifications) \xB7 the **storefront** blueprint (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
19113
19208
  "functions": {
19114
19209
  "on-ticket-created.ts": "// on-ticket-created.ts \u2014 the ACKNOWLEDGEMENT hook (a vxil function).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `tickets`. On a CREATE, send the\n// requester an acknowledgement through notifications. The cms-hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// ticket by id (through the edge, tenant-scoped) rather than trusting inline fields.\n// Delivery is at-least-once: the envelope idempotency_key rides the send as its\n// Idempotency-Key header, so a redelivered hook never double-sends.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; status?: string; requester?: string; sla_due?: string }\ninterface Item { data?: { data?: TicketData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'tickets' || !cms || !notif || !itemId) {\n return Response.json({ skipped: true });\n }\n // acknowledge only the CREATE (the cms.item.* subscription also delivers updates)\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n // Re-fetch the ticket (the payload carries only the id).\n const res = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const ticket = ((await res.json()) as Item).data?.data ?? {};\n if (!ticket.requester) return Response.json({ skipped: true, reason: 'no requester' });\n\n // Acknowledge to the requester (email/inbox via the configured provider).\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}),\n },\n body: JSON.stringify({\n user_id: ticket.requester,\n template: 'transactional',\n data: {\n subject: `We got your ticket: ${ticket.subject ?? itemId}`,\n paragraph:\n `Your ticket is ${ticket.status ?? 'open'} and in our queue` +\n `${ticket.sla_due ? ` (response due by ${ticket.sla_due})` : ''}. ` +\n 'Reply in the app to add details.',\n },\n }),\n });\n return Response.json({ acknowledged: itemId, delivery: send.status });\n },\n};\n",
19115
19210
  "sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL (vxil.com/docs/guide/04-data-with-cms):\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\n// cron-walk: drains-filter \u2014 an escalated ticket gets priority 'urgent' and leaves the `$ne` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Ticket { item_id: string; data: { subject?: string; priority?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
@@ -19132,8 +19227,8 @@ export default defineConfig({
19132
19227
  ],
19133
19228
  "hasFunctions": false,
19134
19229
  "byoKeys": [],
19135
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Sales CRM\" \u2014 companies \u2192 contacts \u2192 a deal pipeline, declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 companies \u2192 contacts \u2192 deals (resolved by relation), with a\n// Lane-A STATE MACHINE guarding the pipeline stage\n// \u2022 activity-feed \u2192 the per-deal timeline (calls, emails, notes) + the rep bell\n// \u2022 notifications \u2192 the email channel (mock provider until you bring a key)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // THE PIPELINE STATE MACHINE (Lane-A validate \u2014 runs inside the write\n // transaction): a deal may only move lead \u2192 qualified \u2192 proposal \u2192\n // won | lost. Any other transition is a clean 422, rolled back\n // atomically. Cross-row work (e.g. \"email the rep on won\") belongs in\n // a function or webhook, never in a hook \u2014 hooks are pure row logic.\n deal_stage_machine: {\n collection: 'deals',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.stage == before.stage' +\n \" || (before.stage == 'lead' && item.stage == 'qualified')\" +\n \" || (before.stage == 'qualified' && item.stage == 'proposal')\" +\n \" || (before.stage == 'proposal' && (item.stage == 'won' || item.stage == 'lost'))\",\n message: 'illegal pipeline transition (lead \u2192 qualified \u2192 proposal \u2192 won|lost)',\n },\n },\n },\n\n // The per-deal timeline. The DEFAULT feed groups already fit a CRM:\n // `timeline` (flat, chronological) keyed per deal \u2014 ingest with\n // POST /v1/feeds/timeline/{deal_item_id}/activities { actor, verb, object }\n // \u2014 and the aggregated `notification` bell per rep.\n 'activity-feed': {},\n\n // Email channel. `mock` is the zero-config staging provider; for real\n // sends switch to provider: 'resend' and set `resendApiKeyRef`, or to\n // provider: 'ses' with `ses: { region, accessKeyIdRef, secretAccessKeyRef }`.\n notifications: { provider: 'mock', fromEmail: 'noreply@crm.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n companies: {\n singular: 'company',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // Want `domain` unique? Declare `unique: true` right here \u2014 value\n // uniqueness (guide ch. 4, uniqueness) is carried by `vxil push` on collection/field\n // CREATE. Flipping it on an ALREADY-pushed field is an in-place alter\n // via the dashboard field designer or a same-name+type re-add through\n // POST /v1/cms/collections/companies/fields (push diffs name+type only).\n domain: { type: 'string', indexSlot: 's2' },\n industry: { type: 'string', indexSlot: 's3' },\n employees: { type: 'int', indexSlot: 'n1' },\n },\n },\n contacts: {\n singular: 'contact',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n email: { type: 'string', indexSlot: 's2' }, // unique \u21D2 see the `domain` note above\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n last_touch: { type: 'datetime', indexSlot: 't1' },\n phone: { type: 'string' }, // stored, not indexed\n },\n },\n deals: {\n singular: 'deal',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // The pipeline stage: enum-validated on every write; the Lane-A hook\n // above additionally locks the TRANSITIONS between stages.\n stage: {\n type: 'string',\n required: true,\n indexSlot: 's2',\n validation: { enum: ['lead', 'qualified', 'proposal', 'won', 'lost'] },\n },\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n owner: { type: 'string', indexSlot: 's4' }, // the rep working the deal\n amount_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n close_date: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n },\n },\n\n // `vxil seed` POSTs each item verbatim \u2014 item_ids are assigned at runtime, so\n // the `company` relation on contacts/deals is left unset here; link records\n // after seeding (PATCH the created items with the company's item_id).\n seed: {\n cms: [\n {\n collection: 'companies',\n items: [{ name: 'Acme Corp', domain: 'acme.com', industry: 'Manufacturing', employees: 250 }],\n },\n {\n collection: 'contacts',\n items: [{ name: 'Jane Porter', email: 'jane@acme.com', phone: '+1 555 0100' }],\n },\n {\n collection: 'deals',\n items: [\n { title: 'Acme starter plan', stage: 'lead', owner: 'jordan', amount_cents: 480_000 },\n { title: 'Acme enterprise rollout', stage: 'proposal', owner: 'sam', amount_cents: 12_000_000 },\n ],\n },\n ],\n },\n});\n",
19136
- "readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, declare `unique: true` on the field \u2014 carried by `vxil push` on create\n (guide ch. 4, uniqueness); flipping it on an already-pushed field is a dashboard/REST in-place alter.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef`, or `ses` +\n `ses: { region, accessKeyIdRef, secretAccessKeyRef }`, for real sends).\n\n**Apply it:**\n\n```bash\nvxil init --template crm\nvxil quickstart --env staging # or `vxil link` to an existing tenant\nvxil push\nvxil gen # then optionally: `vxil seed` (1 company, 1 contact, 2 deals)\n```\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (aggregates), vxil.com/docs/guide/06-feature-catalog (activity-feed,\nnotifications), and `examples/ecommerce/` for a bigger state machine in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
19230
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Sales CRM\" \u2014 companies \u2192 contacts \u2192 a deal pipeline, declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 companies \u2192 contacts \u2192 deals (resolved by relation), with a\n// Lane-A STATE MACHINE guarding the pipeline stage\n// \u2022 activity-feed \u2192 the per-deal timeline (calls, emails, notes) + the rep bell\n// \u2022 notifications \u2192 the email channel (mock provider until you bring a key)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // THE PIPELINE STATE MACHINE (Lane-A validate \u2014 runs inside the write\n // transaction): a deal may only move lead \u2192 qualified \u2192 proposal \u2192\n // won | lost. Any other transition is a clean 422, rolled back\n // atomically. Cross-row work (e.g. \"email the rep on won\") belongs in\n // a function or webhook, never in a hook \u2014 hooks are pure row logic.\n deal_stage_machine: {\n collection: 'deals',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.stage == before.stage' +\n \" || (before.stage == 'lead' && item.stage == 'qualified')\" +\n \" || (before.stage == 'qualified' && item.stage == 'proposal')\" +\n \" || (before.stage == 'proposal' && (item.stage == 'won' || item.stage == 'lost'))\",\n message: 'illegal pipeline transition (lead \u2192 qualified \u2192 proposal \u2192 won|lost)',\n },\n },\n },\n\n // The per-deal timeline. The DEFAULT feed groups already fit a CRM:\n // `timeline` (flat, chronological) keyed per deal \u2014 ingest with\n // POST /v1/feeds/timeline/{deal_item_id}/activities { actor, verb, object }\n // \u2014 and the aggregated `notification` bell per rep.\n 'activity-feed': {},\n\n // Email channel. `mock` is the zero-config staging provider; for real\n // sends switch to provider: 'resend' and set `resendApiKeyRef`, or to\n // provider: 'ses' with `ses: { region, accessKeyIdRef, secretAccessKeyRef }`.\n notifications: { provider: 'mock', fromEmail: 'noreply@crm.example' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n companies: {\n singular: 'company',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // Want `domain` unique? Declare `unique: true` right here \u2014 value\n // uniqueness (guide ch. 4, uniqueness) is carried by `vxil push` on collection/field\n // CREATE. Flipping it on an ALREADY-pushed field is an in-place alter\n // via the dashboard field designer or a same-name+type re-add through\n // POST /v1/cms/collections/companies/fields (push diffs name+type only).\n domain: { type: 'string', indexSlot: 's2' },\n industry: { type: 'string', indexSlot: 's3' },\n employees: { type: 'int', indexSlot: 'n1' },\n },\n },\n contacts: {\n singular: 'contact',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n email: { type: 'string', indexSlot: 's2' }, // unique \u21D2 see the `domain` note above\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n last_touch: { type: 'datetime', indexSlot: 't1' },\n phone: { type: 'string' }, // stored, not indexed\n },\n },\n deals: {\n singular: 'deal',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // The pipeline stage: enum-validated on every write; the Lane-A hook\n // above additionally locks the TRANSITIONS between stages.\n stage: {\n type: 'string',\n required: true,\n indexSlot: 's2',\n validation: { enum: ['lead', 'qualified', 'proposal', 'won', 'lost'] },\n },\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n owner: { type: 'string', indexSlot: 's4' }, // the rep working the deal\n amount_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n close_date: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n },\n },\n\n // `vxil seed` POSTs each item verbatim \u2014 item_ids are assigned at runtime, so\n // the `company` relation on contacts/deals is left unset here; link records\n // after seeding (PATCH the created items with the company's item_id).\n seed: {\n cms: [\n {\n collection: 'companies',\n items: [{ name: 'Acme Corp', domain: 'acme.com', industry: 'Manufacturing', employees: 250 }],\n },\n {\n collection: 'contacts',\n items: [{ name: 'Jane Porter', email: 'jane@acme.com', phone: '+1 555 0100' }],\n },\n {\n collection: 'deals',\n items: [\n { title: 'Acme starter plan', stage: 'lead', owner: 'jordan', amount_cents: 480_000 },\n { title: 'Acme enterprise rollout', stage: 'proposal', owner: 'sam', amount_cents: 12_000_000 },\n ],\n },\n ],\n },\n});\n",
19231
+ "readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, add `unique: true` to the field and `vxil push` (guide ch. 4, uniqueness). On a\n field that already exists, the push applies it in place; it fails if live rows already hold duplicates,\n and clearing it again later needs `vxil push --allow-destructive`.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef`, or `ses` +\n `ses: { region, accessKeyIdRef, secretAccessKeyRef }`, for real sends).\n\n**Apply it:**\n\n```bash\nmkdir my-crm && cd my-crm\nvxil init --template crm\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\n # (or `vxil link <slug> --env staging` + `vxil push` for an existing backend)\nvxil seed # 1 company, 1 contact, 2 deals\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_API_KEY for the curl example below\n```\n\nAfter you edit `vxil.config.ts`, `vxil push` applies it and regenerates the types. The `notifications`\nfeature ships on the `mock` provider: sends are recorded, not delivered, until you configure a real one.\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (aggregates), vxil.com/docs/guide/06-feature-catalog (activity-feed,\nnotifications), and the **storefront** blueprint for a bigger state machine (orders) in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
19137
19232
  "functions": {}
19138
19233
  },
19139
19234
  {
@@ -19156,7 +19251,7 @@ export default defineConfig({
19156
19251
  "oidc_client_secret"
19157
19252
  ],
19158
19253
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Team Workspace\" \u2014 the ENTERPRISE blueprint: many companies inside one\n// backend, each with its own members and roles, signing in through the\n// company's own identity provider, reading a document set where ONE field is\n// visible only to finance. Declared end-to-end in ONE typed file.\n//\n// \u2022 orgs \u2192 organizations + memberships + a tenant-defined `finance` role\n// \u2022 auth \u2192 email/password or magic link today, a generic OIDC issuer as a\n// config swap; account-security controls; a 3-device session cap\n// \u2022 cms \u2192 projects \u2192 documents, owner-scoped, `restrict`-protected, with\n// ONE per-record action button and ONE role-gated field\n// \u2022 functions \u2192 the single step the Archive button runs\n//\n// The one thing that is NOT in this file: inviting a teammate to the vxil\n// PROJECT itself (the dashboard's pending-email invite). That is an operator\n// flow, not app config \u2014 see the README.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // \u2500\u2500 Workspaces for YOUR customers' teams \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // An organization is a customer company; a membership carries a role. The\n // built-in lattice is owner > admin > member > viewer; `finance` below is a\n // CUSTOM role you define once over the API (see the README) and then assign\n // like any built-in one.\n orgs: {\n enabled: true,\n maxMembersPerOrg: 200,\n invitationTtlHours: 72, // an org invitation token is single-use + TTL-bound\n },\n\n auth: {\n // The demo path: email+password (and magic link) so the walkthrough runs\n // with no identity provider at all.\n methods: { emailPassword: true, magicLink: true },\n\n // \u2500\u2500 SSO: the generic OIDC issuer, as a CONFIG SWAP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Uncomment this block, store the client secret once, and every member of\n // the workspace signs in through the company's IdP instead. The endpoints\n // and signing keys are discovered from the issuer \u2014 nothing else changes\n // in this file, and no code changes at all. `clientId` is not a secret\n // (it rides every authorize URL); the secret stays a REFERENCE.\n //\n // providers: {\n // oidc: {\n // issuer: 'https://login.example-idp.com', // https, no query/fragment\n // clientId: 'vxil-team-workspace',\n // clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n // scopes: ['email', 'profile'], // `openid` is always added\n // claims: { email: 'email', name: 'name', roles: 'groups' },\n // allowedDomains: ['example.com'], // fail-closed domain fence\n // autoLink: true, // link to a matching verified email\n // },\n // },\n\n // Roles ride the SESSION. With this on, the member's active-org role is\n // embedded in the session at sign-in and refresh, so a read can be gated\n // on it without a round-trip. It is a SNAPSHOT (refreshed with the\n // session) \u2014 use the orgs permission check for revocation-grade calls.\n orgClaims: { enabled: true },\n\n // A member may hold at most three live sessions; a fourth sign-in takes\n // over the oldest (it is revoked, and the sign-in reports which).\n session: { ttlMinutes: 60, refreshTtlDays: 30, maxConcurrent: 3 },\n\n // \u2500\u2500 Account-security controls (all opt-in, all off by default) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n security: {\n // repeated bad passwords on one identifier \u2192 locked, with a retry hint\n lockout: { maxFailures: 5, windowMinutes: 15, lockMinutes: 15 },\n // refuse a sign-up / reset whose password appears in a breach corpus\n breachedPasswords: true,\n // EVERY caller-supplied return URL must match one of these exactly \u2014\n // the anti-open-redirect fence for magic links, resets and SSO returns.\n allowedRedirectOrigins: ['https://app.example.com'],\n // captchaSecretRef: 'turnstile_secret', // add to require a captcha token\n },\n },\n\n cms: {\n hooks: {\n // The DOCUMENT lifecycle, enforced atomically inside the same write.\n // `archived` is terminal; the Archive button below is just the last\n // legal transition, so the button and the API agree by construction.\n document_stage: {\n collection: 'documents',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'draft' && (item.state == 'in_review' || item.state == 'archived'))\"\n + \" || (before.state == 'in_review' && (item.state == 'draft' || item.state == 'approved'))\"\n + \" || (before.state == 'approved' && item.state == 'archived')\",\n message: 'illegal document state transition',\n },\n },\n },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n projects: {\n singular: 'project',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // one project code per workspace \u2014 a duplicate is a clean 409\n code: { type: 'string', unique: true, indexSlot: 's2' },\n stage: { type: 'string', indexSlot: 's3' }, // discovery | active | closed\n created_at: { type: 'datetime', indexSlot: 't1' },\n summary: { type: 'text' },\n },\n },\n\n documents: {\n singular: 'document',\n // Owner-scoping: a signed-in member reads/edits only their OWN\n // documents. A no-op for server callers \u2014 your own backend still sees\n // the whole set.\n ownerField: 'author',\n\n // ONE human-initiated step per record. The dashboard renders a button\n // on every row; pressing it invokes the named function ONCE with\n // { collection, item_id, action, actor, item }. No conditions, no\n // chaining, no scheduling \u2014 the moment it needs branches it is a\n // function of your own, not a button.\n actions: [{ key: 'archive', label: 'Archive', fn: 'archive-document' }],\n\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' }, // the owner (end-user id)\n // `restrict`: while a live document points at a project, deleting\n // that project is REFUSED (409) instead of silently orphaning or\n // cascading. The reverse read (`\u2026/backlinks`) tells you who holds it.\n project: { type: 'relation', relationTo: 'projects', onDelete: 'restrict', indexSlot: 's3' },\n state: { type: 'string', indexSlot: 's4' }, // draft | in_review | approved | archived\n // \u2500\u2500 FIELD-LEVEL READ SECURITY \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Only a signed-in member whose session carries the `finance` role\n // ever receives this field. Everyone else gets the document WITHOUT\n // it \u2014 absent, not null \u2014 and cannot filter or sort on it either, so\n // it can never be read one bit at a time. Your own server key still\n // sees it: this gates END USERS, not you.\n budget_usd: { type: 'int', indexSlot: 'n1', readRoles: ['finance'] },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The one step that isn't config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // The Archive button. Invoked through the per-record action route with the\n // pressing member's verified principal carried whole, so the write it makes\n // is owner-scoped exactly as if the member had made it themselves.\n 'archive-document': {\n entry: './functions/archive-document.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // nothing external; it only talks back to your own backend\n },\n },\n\n // References only \u2014 values are stored once and never appear in this file.\n secrets: {\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret for the workspace identity provider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'projects',\n items: [\n {\n name: 'Northwind Rollout',\n code: 'NW-2026',\n stage: 'active',\n created_at: '2026-01-06T09:00:00Z',\n summary: 'Migration of the Northwind account onto the new platform.',\n },\n ],\n },\n ],\n },\n});\n",
19159
- "readme": '# Team Workspace (saas)\n\nThe **B2B** blueprint: many customer companies inside one backend, each with its own members\nand roles, signing in through the company\'s own identity provider \u2014 and a document set where\none field is visible only to finance.\n\nFive things most "add multi-tenancy to my SaaS" projects end up building by hand, declared here\ninstead: **organizations**, **roles that ride the session**, **SSO as a config swap**,\n**field-level read security**, and **one button per record**.\n\n```bash\nvxil init --template team-workspace\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the archive function\n```\n\n> **Plan note.** The archive function deploys on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n## The five things, and where each one lives\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| Customer companies + memberships | `features.orgs` | `POST /v1/orgs`, members, invitations, a permission check \u2014 no membership table of your own |\n| A `finance` role | **not config** \u2014 `POST /v1/orgs/roles` | roles are rows, so you add one without a deploy |\n| Roles on the session | `features.auth.orgClaims.enabled` | the member\'s active-org role rides the session token; a read can be gated on it with no round-trip |\n| SSO | `features.auth.providers.oidc` (commented) | one block swaps email+password for the customer\'s identity provider |\n| Lockout / breach / redirect fence | `features.auth.security` | the account-security controls, all opt-in, all off until you ask |\n| A 3-device cap | `features.auth.session.maxConcurrent` | a fourth sign-in takes over the oldest session and tells you which |\n| Hiding `budget_usd` | `readRoles: [\'finance\']` on the field | the field is **absent** for everyone else \u2014 and unfilterable, so it cannot be read one bit at a time |\n| Refusing an orphaning delete | `onDelete: \'restrict\'` on the relation | deleting a project that still holds documents is a clean 409, not a cascade you did not ask for |\n| The Archive button | `actions: [{ key, label, fn }]` | one human-initiated step, one function, no workflow engine |\n\n## SSO \u2014 the config swap\n\nThe blueprint ships with email + password so the walkthrough runs with no identity provider.\nTo move a workspace onto its company\'s IdP, uncomment the `providers.oidc` block in\n`vxil.config.ts`, fill in three values, store one secret, and push:\n\n```ts\nproviders: {\n oidc: {\n issuer: \'https://login.example-idp.com\', // https, no query or fragment\n clientId: \'vxil-team-workspace\', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: \'secret:oidc_client_secret\', // a REFERENCE; the value never enters this file\n scopes: [\'email\', \'profile\'], // `openid` is always added\n claims: { email: \'email\', name: \'name\', roles: \'groups\' },\n allowedDomains: [\'example.com\'], // fail-closed: an unlisted domain is refused\n autoLink: true, // link to an existing verified email\n },\n},\n```\n\n```bash\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nvxil push\n```\n\nThen send people to `GET /v1/auth/oauth/oidc/start?redirect_uri=https://app.example.com/callback`.\nThe authorize endpoint, token endpoint and signing keys are **discovered from the issuer** \u2014 there\nis nothing else to configure and no code change at all. The presence of the block is the opt-in;\nthere is no separate toggle.\n\nTwo claims feed the session\'s role list: the member\'s **active-org role** (from `orgClaims`) and\nwhatever claim you name in `claims.roles` (from the IdP). Either one alone is enough to satisfy\n`readRoles: [\'finance\']`, which is why the same config works before and after SSO.\n\nAny broker that speaks OIDC \u2014 Okta, Entra, Auth0, WorkOS \u2014 puts a SAML customer behind this same\nblock. There is deliberately no separate SAML surface to learn.\n\n## Inviting people: two different invitations\n\nThey are easy to confuse, so name them apart:\n\n- **Your customers\' teammates** \u2192 `POST /v1/orgs/{org_id}/invitations` with `{ email, role }`,\n where `role` is `admin`, `member` or `viewer`. The single-use token comes back **once**, in that\n response \u2014 it is deliberately never emailed, so your app builds its own accept link and controls\n the wording. Accept with `POST /v1/orgs/invitations/accept`; list pending ones with\n `GET /v1/orgs/{org_id}/invitations`; revoke with `DELETE /v1/orgs/invitations/{invite_id}`.\n To land someone on a custom role such as `finance`, invite them as `member` and then\n `POST /v1/orgs/{org_id}/members` with the role. There is no resend \u2014 issue a new invitation and\n revoke the old one.\n- **Your own colleagues, on the vxil project itself** \u2192 the dashboard\'s **Members \u2192 Invite by\n email**. Type an address and it becomes a *pending* row with Resend and Revoke beside it; when\n they accept, they get a dashboard seat on this backend. That one is pure operator flow \u2014 no code,\n nothing in this config.\n\n## The 10-minute walkthrough\n\nEvery response below is the real shape. `$KEY` is a server key with `cms:read cms:write orgs:read\norgs:write auth:signin auth:write features:read`; `$API` is `https://api.vxil.com`.\n\n**1. Define the `finance` role.** Roles are rows, so this needs no deploy.\n\n```bash\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["budgets.approve","reports.export"],"rank":2}\'\n# 201 { "data": { "role_key": "finance", "name": "Finance", "permissions": [...], "rank": 2, ... } }\n```\n\nA custom role is a named **permission set**, and the permission strings are yours \u2014 vxil never\ninterprets them, it only answers whether this member holds one. The four built-in roles\n(`owner > admin > member > viewer`) keep working alongside it.\n\n**2. Create a workspace and two members.**\n\n```bash\nvxil api POST /v1/orgs --data \'{"slug":"northwind","name":"Northwind","owner_user_id":"u_owner"}\'\n# 201 { "data": { "org_id": "org_\u2026", "slug": "northwind", "name": "Northwind", "created_at": "\u2026" } }\n```\n\nSign two people up, then seat them \u2014 one plain `member`, one on `finance`:\n\n```bash\nvxil api POST /v1/auth/sign-up --data \'{"email":"alice@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/auth/sign-up --data \'{"email":"dana@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<alice>","role":"member"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<dana>","role":"finance"}\'\n# 201 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "role": "finance" } }\n```\n\nCheck what the session will carry:\n\n```bash\nvxil api GET "/v1/orgs/session-claims?user_id=<dana>"\n# 200 { "data": { "user_id": "\u2026", "org_id": "org_\u2026", "role": "finance", "perms": [...] } }\n```\n\n**3. Sign in \u2014 and watch the device cap.** Sign the same person in four times:\n\n```bash\ncurl -s -X POST "$API/v1/auth/sign-in" -H "authorization: Bearer $KEY" \\\n -H \'content-type: application/json\' \\\n -d \'{"email":"dana@example.com","password":"<a strong one>"}\'\n# 200 { "data": { "user_id": "\u2026",\n# "session": { "token": "\u2026", "refresh_token": "\u2026", "expires_at": "\u2026" },\n# "took_over": [ "sess_\u2026" ] } }\n```\n\nThe fourth sign-in reports the session it revoked in `took_over`. That array only appears because\n`session.maxConcurrent` is set \u2014 leave it out and responses are byte-identical to a backend that\nnever heard of the cap.\n\nRepeated wrong passwords stop being cheap after five: `429 account_locked` with a `Retry-After`\nheader, for fifteen minutes. Because the counter is keyed on a hash of the identifier, an unknown\naddress locks out exactly like a real one \u2014 no probing for which emails exist.\n\n**4. Write a document with a budget.** As the server key (no end-user session):\n\n```bash\nvxil api POST /v1/cms/items/projects --data \'{"data":{"name":"Northwind Rollout","code":"NW-2026","stage":"active"}}\'\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Statement of work","author":"<dana>","project":"<project item_id>","state":"draft","budget_usd":240000}}\'\n# 201 { "data": { "item_id": "itm_\u2026", "collection": "documents", "status": "draft",\n# "data": { "title": "\u2026", "budget_usd": 240000, \u2026 }, "version": 1, \u2026 } }\n```\n\nThe server key sees `budget_usd`. That is the point: the gate is for **end users**, not for you.\n\n**5. The gate, live.** Read the same document as a signed-in member, by sending the session\'s\n`token` in the `X-Vxil-End-User` header:\n\n```bash\n# dana \u2014 role `finance`\ncurl -s "$API/v1/cms/items/documents/<id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 \u2026 "data": { "title": "Statement of work", "budget_usd": 240000, "state": "draft", \u2026 }\n\n# alice \u2014 role `member`\ncurl -s "$API/v1/cms/items/documents/<her own document\'s id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 200 \u2026 "data": { "title": "\u2026", "state": "draft", \u2026 } \u2190 budget_usd is ABSENT\n```\n\nAbsent, not `null` \u2014 a `null` would itself be an answer. And it cannot be reached sideways either:\n\n```bash\ncurl -s "$API/v1/cms/items/documents?filter=%7B%22budget_usd%22%3A%7B%22%24gt%22%3A0%7D%7D" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 422 { "error": { "code": "invalid_query", "message": "unknown field \'budget_usd\' \u2026" } }\n```\n\nTo a member without the role the field does not exist \u2014 not in the document, not in a filter, not\nin a sort, not through an expanded relation. Promote alice to `finance`, have her sign in again,\nand the field is simply there: the role travels on the session, so a new session is all it takes.\n\n**6. A delete that refuses.** The project still has a document pointing at it:\n\n```bash\nvxil api DELETE /v1/cms/items/projects/<project item_id>\n# 409 { "error": { "code": "referenced",\n# "message": "this item is still referenced by 1 live item(s) through an on_delete: \'restrict\' relation \u2026; nothing was deleted.",\n# "referencing": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "has_more": false } }\n```\n\nAsk who is holding it, the same way the 409 did:\n\n```bash\nvxil api GET /v1/cms/items/projects/<project item_id>/backlinks\n# 200 { "data": { "collection": "projects", "item_id": "itm_\u2026",\n# "backlinks": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "count": 1, "has_more": false, "limit": 25 } }\n```\n\n**7. The button.** One action, one function, one step:\n\n```bash\ncurl -s -X POST "$API/v1/cms/items/documents/<id>/actions/archive" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 { "data": { "collection": "documents", "item_id": "itm_\u2026", "action": "archive",\n# "fn": "archive-document",\n# "result": { "archived": "itm_\u2026", "from": "draft", "state": "archived" } } }\n```\n\nThe function receives `{ collection, item_id, action, actor, item }` and runs with **the pressing\nmember\'s** verified identity, so its write is owner-scoped exactly as if they had made it. Press it\nagain and it answers `already: true` \u2014 a button a human can double-click needs to be idempotent.\n\nTry an illegal jump instead (`archived \u2192 draft`) and the collection\'s lifecycle hook rejects it\ninside the same write, so the button and the API can never disagree:\n\n```bash\nvxil api PATCH /v1/cms/items/documents/<id> --data \'{"data":{"state":"draft"}}\'\n# 422 \u2026 "illegal document state transition"\n```\n\n**8. Real authorization, when advisory is not enough.** The session role is a *snapshot*, refreshed\nwith the session. For anything that must reflect a revocation immediately, ask:\n\n```bash\nvxil api GET "/v1/orgs/org_\u2026/check?user_id=<dana>&permission=budgets.approve"\n# 200 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "permission": "budgets.approve",\n# "role": "finance", "allowed": true, "source": "role" } }\n```\n\n## What to learn from this\n\n- **Roles are data; the gate is config.** `finance` is a row you can create at 4pm on a Friday.\n `readRoles: [\'finance\']` is one field attribute. Neither is a code path you maintain.\n- **Field-level security has to be fail-safe in every direction, or it is theatre.** A gated field\n is removed from the document, from filters, from sorts, from expanded relations, and it is never\n served on a public read lane. The only caller that still sees it is your own backend.\n- **Owner-scoping and role-gating answer different questions.** `ownerField` decides *which rows*\n a member can see. `readRoles` decides *which fields* inside a row they get. You usually want both.\n- **`restrict` beats a cascade you did not think about.** Refusing the delete and naming the\n holders turns a data-loss bug into a 409 your UI can explain.\n- **An action is one step, on purpose.** The moment a button needs conditions or a second step, it\n is a function of yours, not a config entry \u2014 and that boundary is what keeps this from becoming a\n workflow engine.\n\n**Pairs with:** `templates/crm/` (the same relational spine without the org layer) and\n`templates/helpdesk/` (owner-scoped records with a state machine).\n',
19254
+ "readme": '# Team Workspace (saas)\n\nThe **B2B** blueprint: many customer companies inside one backend, each with its own members\nand roles, signing in through the company\'s own identity provider \u2014 and a document set where\none field is visible only to finance.\n\nFive things most "add multi-tenancy to my SaaS" projects end up building by hand, declared here\ninstead: **organizations**, **roles that ride the session**, **SSO as a config swap**,\n**field-level read security**, and **one button per record**.\n\n```bash\nmkdir my-workspace && cd my-workspace\nvxil init --template team-workspace\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hooks + the archive function; writes vxil.types.ts\nvxil seed # the demo project row\n```\n\n> **Plan note.** The archive function deploys on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n## The five things, and where each one lives\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| Customer companies + memberships | `features.orgs` | `POST /v1/orgs`, members, invitations, a permission check \u2014 no membership table of your own |\n| A `finance` role | **not config** \u2014 `POST /v1/orgs/roles` | roles are rows, so you add one without a deploy |\n| Roles on the session | `features.auth.orgClaims.enabled` | the member\'s active-org role rides the session token; a read can be gated on it with no round-trip |\n| SSO | `features.auth.providers.oidc` (commented) | one block swaps email+password for the customer\'s identity provider |\n| Lockout / breach / redirect fence | `features.auth.security` | the account-security controls, all opt-in, all off until you ask |\n| A 3-device cap | `features.auth.session.maxConcurrent` | a fourth sign-in takes over the oldest session and tells you which |\n| Hiding `budget_usd` | `readRoles: [\'finance\']` on the field | the field is **absent** for everyone else \u2014 and unfilterable, so it cannot be read one bit at a time |\n| Refusing an orphaning delete | `onDelete: \'restrict\'` on the relation | deleting a project that still holds documents is a clean 409, not a cascade you did not ask for |\n| The Archive button | `actions: [{ key, label, fn }]` | one human-initiated step, one function, no workflow engine |\n\n## SSO \u2014 the config swap\n\nThe blueprint ships with email + password so the walkthrough runs with no identity provider.\nTo move a workspace onto its company\'s IdP, uncomment the `providers.oidc` block in\n`vxil.config.ts`, fill in three values, store one secret, and push:\n\n```ts\nproviders: {\n oidc: {\n issuer: \'https://login.example-idp.com\', // https, no query or fragment\n clientId: \'vxil-team-workspace\', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: \'secret:oidc_client_secret\', // a REFERENCE; the value never enters this file\n scopes: [\'email\', \'profile\'], // `openid` is always added\n claims: { email: \'email\', name: \'name\', roles: \'groups\' },\n allowedDomains: [\'example.com\'], // fail-closed: an unlisted domain is refused\n autoLink: true, // link to an existing verified email\n },\n},\n```\n\n```bash\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nvxil push\n```\n\nThen start sign-in from your app. A plain link to `GET /v1/auth/oauth/oidc/start` does not work from a\nbrowser: that route needs your project key in a header, which a navigation cannot carry. Ask for the\nauthorize URL as JSON instead, with a public key that carries `auth:signin`:\n\n1. `const { authorize_url } = await vx.auth.oauth.startUrl(\'oidc\', { redirect_uri: \'https://app.example.com/callback\' })`,\n then `location.assign(authorize_url)`.\n2. The identity provider sends the browser back to `redirect_uri` with `?code=\u2026&state=\u2026`.\n3. Your callback page hands both to `GET /v1/auth/oauth/oidc/callback?code=\u2026&state=\u2026` (same key), which\n answers the session.\n\n`redirect_uri` must match `security.allowedRedirectOrigins`, which this blueprint sets to\n`https://app.example.com` \u2014 change it to your app\'s origin, or the start answers `422\nredirect_not_allowed`. The state expires after `expires_in` seconds (600). The full flow is in\nvxil.com/docs/guide/06-feature-catalog ("Starting social or SSO sign-in from a browser app").\n\nThe authorize endpoint, token endpoint and signing keys are **discovered from the issuer** \u2014 there\nis nothing else to configure and no code change at all. The presence of the block is the opt-in;\nthere is no separate toggle.\n\nTwo claims feed the session\'s role list: the member\'s **active-org role** (from `orgClaims`) and\nwhatever claim you name in `claims.roles` (from the IdP). Either one alone is enough to satisfy\n`readRoles: [\'finance\']`, which is why the same config works before and after SSO.\n\nAny broker that speaks OIDC \u2014 Okta, Entra, Auth0, WorkOS \u2014 puts a SAML customer behind this same\nblock. There is deliberately no separate SAML surface to learn.\n\n## Inviting people: two different invitations\n\nThey are easy to confuse, so name them apart:\n\n- **Your customers\' teammates** \u2192 `POST /v1/orgs/{org_id}/invitations` with `{ email, role }`,\n where `role` is `admin`, `member` or `viewer`. The single-use token comes back **once**, in that\n response \u2014 it is deliberately never emailed, so your app builds its own accept link and controls\n the wording. Accept with `POST /v1/orgs/invitations/accept`; list pending ones with\n `GET /v1/orgs/{org_id}/invitations`; revoke with `DELETE /v1/orgs/invitations/{invite_id}`.\n To land someone on a custom role such as `finance`, invite them as `member` and then\n `POST /v1/orgs/{org_id}/members` with the role. There is no resend \u2014 issue a new invitation and\n revoke the old one.\n- **Your own colleagues, on the vxil project itself** \u2192 the dashboard\'s **Members \u2192 Invite by\n email**. Type an address and it becomes a *pending* row with Resend and Revoke beside it; when\n they accept, they get a dashboard seat on this backend. That one is pure operator flow \u2014 no code,\n nothing in this config.\n\n## The 10-minute walkthrough\n\nEvery response below is the real HTTP shape. `$KEY` is a server key with `cms:read cms:write orgs:read\norgs:write auth:signin auth:write features:read`; `$API` is `https://api.vxil.com`. The project key\nquickstart minted has no `auth:write` (it is the key most likely to end up in a client), so mint this one:\n`vxil login`, then `vxil keys mint --name ws-server --scopes cms:read,cms:write,orgs:read,orgs:write,auth:signin,auth:write,features:read --json`\nand set `KEY` to its `api_key`.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .org_id`, not `jq .data.org_id` \u2014 and an error goes to stderr as\n`code \u2014 message`.\n\n**1. Define the `finance` role.** Roles are rows, so this needs no deploy.\n\n```bash\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["budgets.approve","reports.export"],"rank":2}\'\n# 201 { "data": { "role_key": "finance", "name": "Finance", "permissions": [...], "rank": 2, ... } }\n```\n\nA custom role is a named **permission set**, and the permission strings are yours \u2014 vxil never\ninterprets them, it only answers whether this member holds one. The four built-in roles\n(`owner > admin > member > viewer`) keep working alongside it.\n\n**2. Create a workspace and two members.**\n\n```bash\nvxil api POST /v1/orgs --data \'{"slug":"northwind","name":"Northwind","owner_user_id":"u_owner"}\'\n# 201 { "data": { "org_id": "org_\u2026", "slug": "northwind", "name": "Northwind", "created_at": "\u2026" } }\n```\n\nSign two people up, then seat them \u2014 one plain `member`, one on `finance`:\n\n```bash\nvxil api POST /v1/auth/sign-up --data \'{"email":"alice@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/auth/sign-up --data \'{"email":"dana@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<alice>","role":"member"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<dana>","role":"finance"}\'\n# 201 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "role": "finance" } }\n```\n\nCheck what the session will carry:\n\n```bash\nvxil api GET "/v1/orgs/session-claims?user_id=<dana>"\n# 200 { "data": { "user_id": "\u2026", "org_id": "org_\u2026", "role": "finance", "perms": [...] } }\n```\n\n**3. Sign in \u2014 and watch the device cap.** Sign the same person in four times:\n\n```bash\ncurl -s -X POST "$API/v1/auth/sign-in" -H "authorization: Bearer $KEY" \\\n -H \'content-type: application/json\' \\\n -d \'{"email":"dana@example.com","password":"<a strong one>"}\'\n# 200 { "data": { "user_id": "\u2026",\n# "session": { "token": "\u2026", "refresh_token": "\u2026", "expires_at": "\u2026" },\n# "took_over": [ "sess_\u2026" ] } }\n```\n\nThe fourth sign-in reports the session it revoked in `took_over`. That array only appears because\n`session.maxConcurrent` is set \u2014 leave it out and responses are byte-identical to a backend that\nnever heard of the cap.\n\nRepeated wrong passwords stop being cheap after five: `429 account_locked` with a `Retry-After`\nheader, for fifteen minutes. Because the counter is keyed on a hash of the identifier, an unknown\naddress locks out exactly like a real one \u2014 no probing for which emails exist.\n\n**4. Write a document with a budget.** As the server key (no end-user session):\n\n```bash\nPROJECT=$(vxil api GET \'/v1/cms/items/projects?filter={"code":"NW-2026"}\' | jq -r \'.items[0].item_id\') # the seeded project\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Statement of work","author":"<dana>","project":"\'$PROJECT\'","state":"draft","budget_usd":240000}}\'\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Rollout plan","author":"<alice>","project":"\'$PROJECT\'","state":"draft","budget_usd":18000}}\'\n# 201 { "data": { "item_id": "itm_\u2026", "collection": "documents", "status": "draft",\n# "data": { "title": "\u2026", "budget_usd": 240000, \u2026 }, "version": 1, \u2026 } }\n```\n\nThe server key sees `budget_usd`. That is the point: the gate is for **end users**, not for you.\n\n**5. The gate, live.** Documents are owner-scoped on `author`, so each member reads their own\ndocument, by sending the session\'s `token` in the `X-Vxil-End-User` header (`<id>` is the\nStatement of work):\n\n```bash\n# dana \u2014 role `finance`\ncurl -s "$API/v1/cms/items/documents/<id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 \u2026 "data": { "title": "Statement of work", "budget_usd": 240000, "state": "draft", \u2026 }\n\n# alice \u2014 role `member`\ncurl -s "$API/v1/cms/items/documents/<the Rollout plan id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 200 \u2026 "data": { "title": "\u2026", "state": "draft", \u2026 } \u2190 budget_usd is ABSENT\n```\n\nAbsent, not `null` \u2014 a `null` would itself be an answer. And it cannot be reached sideways either:\n\n```bash\ncurl -s "$API/v1/cms/items/documents?filter=%7B%22budget_usd%22%3A%7B%22%24gt%22%3A0%7D%7D" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 422 { "error": { "code": "invalid_query", "message": "unknown field \'budget_usd\' \u2026" } }\n```\n\nTo a member without the role the field does not exist \u2014 not in the document, not in a filter, not\nin a sort, not through an expanded relation. Promote alice to `finance`, have her sign in again,\nand the field is simply there: the role travels on the session, so a new session is all it takes.\n\n**6. A delete that refuses.** The project still has two documents pointing at it:\n\n```bash\nvxil api DELETE /v1/cms/items/projects/$PROJECT\n# 409 { "error": { "code": "referenced",\n# "message": "this item is still referenced by 2 live item(s) through an on_delete: \'restrict\' relation \u2026; nothing was deleted.",\n# "referencing": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" }, \u2026 ],\n# "has_more": false } }\n```\n\nAsk who is holding it, the same way the 409 did:\n\n```bash\nvxil api GET /v1/cms/items/projects/$PROJECT/backlinks\n# 200 { "data": { "collection": "projects", "item_id": "itm_\u2026",\n# "backlinks": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" }, \u2026 ],\n# "count": 2, "has_more": false, "limit": 25 } }\n```\n\n**7. The button.** One action, one function, one step:\n\n```bash\ncurl -s -X POST "$API/v1/cms/items/documents/<id>/actions/archive" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 { "data": { "collection": "documents", "item_id": "itm_\u2026", "action": "archive",\n# "fn": "archive-document",\n# "result": { "archived": "itm_\u2026", "from": "draft", "state": "archived" } } }\n```\n\nThe function receives `{ collection, item_id, action, actor, item }` and runs with **the pressing\nmember\'s** verified identity, so its write is owner-scoped exactly as if they had made it. Press it\nagain and it answers `already: true` \u2014 a button a human can double-click needs to be idempotent.\n\nTry an illegal jump instead (`archived \u2192 draft`) and the collection\'s lifecycle hook rejects it\ninside the same write, so the button and the API can never disagree:\n\n```bash\nvxil api PATCH /v1/cms/items/documents/<id> --data \'{"data":{"state":"draft"}}\'\n# 422 \u2026 "illegal document state transition"\n```\n\n**8. Real authorization, when advisory is not enough.** The session role is a *snapshot*, refreshed\nwith the session. For anything that must reflect a revocation immediately, ask:\n\n```bash\nvxil api GET "/v1/orgs/org_\u2026/check?user_id=<dana>&permission=budgets.approve"\n# 200 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "permission": "budgets.approve",\n# "role": "finance", "allowed": true, "source": "role" } }\n```\n\n## What to learn from this\n\n- **Roles are data; the gate is config.** `finance` is a row you can create at 4pm on a Friday.\n `readRoles: [\'finance\']` is one field attribute. Neither is a code path you maintain.\n- **Field-level security has to be fail-safe in every direction, or it is theatre.** A gated field\n is removed from the document, from filters, from sorts, from expanded relations, and it is never\n served on a public read lane. The only caller that still sees it is your own backend.\n- **Owner-scoping and role-gating answer different questions.** `ownerField` decides *which rows*\n a member can see. `readRoles` decides *which fields* inside a row they get. You usually want both.\n- **`restrict` beats a cascade you did not think about.** Refusing the delete and naming the\n holders turns a data-loss bug into a 409 your UI can explain.\n- **An action is one step, on purpose.** The moment a button needs conditions or a second step, it\n is a function of yours, not a config entry \u2014 and that boundary is what keeps this from becoming a\n workflow engine.\n\n**Pairs with:** the **crm** blueprint (the same relational spine without the org layer) and\nthe **helpdesk** blueprint (owner-scoped records with a state machine).\n',
19160
19255
  "functions": {
19161
19256
  "archive-document.ts": "// archive-document.ts \u2014 the ARCHIVE BUTTON (a vxil function).\n//\n// Trigger: the per-record action `archive` declared on the `documents`\n// collection. Pressing the button POSTs\n// /v1/cms/items/documents/<id>/actions/archive\n// and the platform invokes THIS function once with the action envelope as its\n// payload:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// The pressing member's verified principal is carried into the scoped token, so\n// the PATCH below is owner-scoped exactly as if the member had written it \u2014 a\n// member can archive their own document and nobody else's, with no check here.\n//\n// It writes ONE transition (\u2192 'archived'). The collection's lifecycle hook is\n// still the authority: an illegal transition is rejected in the same write, and\n// this function reports that rejection instead of pretending it succeeded.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ninterface ActionPayload {\n collection?: string;\n item_id?: string;\n action?: string;\n actor?: { principal?: string; end_user_id?: string };\n item?: { status?: string; version?: number; data?: Record<string, unknown> };\n}\ntype Env = HttpFunctionEnvelope<ActionPayload>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (!cms || !itemId || env.payload?.collection !== 'documents') {\n return Response.json({ skipped: true, reason: 'not a documents action' });\n }\n\n // Already archived \u2192 nothing to do. The action is human-initiated and can be\n // pressed twice; make the second press a no-op rather than an error.\n const was = String(env.payload?.item?.data?.state ?? '');\n if (was === 'archived') {\n return Response.json({ archived: itemId, already: true, state: 'archived' });\n }\n\n const res = await fetch(`${base}/v1/cms/items/documents/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { state: 'archived', updated_at: new Date().toISOString() },\n }),\n });\n\n if (!res.ok) {\n // The lifecycle hook refuses an illegal transition in-transaction (422).\n // Surface the real reason \u2014 the action route relays this class straight\n // back to the caller as `action_failed`.\n const detail = await res.text();\n return Response.json(\n { error: 'archive_refused', from: was, status: res.status, detail: detail.slice(0, 300) },\n { status: res.status === 422 ? 422 : 502 },\n );\n }\n\n return Response.json({ archived: itemId, from: was || 'draft', state: 'archived' });\n },\n};\n"
19162
19257
  }
@@ -19185,7 +19280,7 @@ export default defineConfig({
19185
19280
  "oidc_client_secret"
19186
19281
  ],
19187
19282
  "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// \"Admin console\" \u2014 the back-office every internal-tools team builds first.\n//\n// Staff look up and edit customer accounts and run bulk fixes. Sensitive\n// changes (usage-credit grants, plan overrides, suspensions, data fixes) become\n// REQUESTS that a different person approves. Each account shows its change\n// history and internal notes. Declared end-to-end in ONE typed file:\n//\n// \u2022 auth \u2192 staff only: sign-in through your company identity provider,\n// fenced to your e-mail domain(s); the org role rides the session\n// \u2022 orgs \u2192 ONE staff organization; three custom roles (support, approver,\n// console-admin) are rows you create over the API (README)\n// \u2022 cms \u2192 accounts \xB7 change_requests \xB7 approval_policies, with the\n// approval STATE MACHINE and \"no self-approval\" as write hooks\n// \u2022 comments \u2192 internal notes, one thread per account (`accounts:<id>`),\n// read and written only through the console-api function\n// \u2022 notifications \u2192 \"approval needed\" / \"approval decided\" messages\n// \u2022 functions \u2192 every read AND write a staff member makes goes through one\n// of these, and each one checks the caller's permission first\n// (the browser key holds only functions:invoke)\n//\n// What this is NOT: an approval engine or a visual flow. An approval is a ROW\n// with a state, a hook that guards its transitions, and ONE function that\n// decides it. A second approval step is a second state and a second\n// permission, in data \u2014 never a graph.\n//\n// Credits here are USAGE UNITS your product consumes (API calls, seats-days,\n// exports), never money. Refunds through your own payment provider are out of\n// scope; see the README for the payments-integration functions pattern.\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 // Staff sign in through the company identity provider ONLY: no\n // password, no magic link, so there is no staff password to phish, reset\n // or leak.\n methods: { emailPassword: false, magicLink: false },\n providers: {\n oidc: {\n issuer: 'https://login.example-idp.com', // your IdP's issuer (https, no query/fragment)\n clientId: 'vxil-admin-console', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n scopes: ['email', 'profile'], // `openid` is always added; add 'groups' if your IdP needs it for the claim\n // `groups` from the IdP rides the session as roles, next to the org role.\n // Keep IdP group names distinct from the console's role names: a group\n // called `console-admin` would pass `readRoles` below.\n claims: { email: 'email', name: 'name', roles: 'groups' },\n // fail-closed to your domain(s), but that is EVERYONE at the company,\n // not only staff. Assign the console's app in your IdP to the staff\n // group too; the functions refuse non-staff either way (403).\n allowedDomains: ['example.com'],\n autoLink: false, // link by the IdP's stable subject only\n },\n },\n // The member's org role (support / approver / console-admin) is embedded\n // in the session at sign-in and refresh, so field-level read gates\n // (`readRoles` below) work with no round-trip. The functions still ask\n // the orgs permission check on every write: that is revocation-grade.\n orgClaims: { enabled: true },\n session: { ttlMinutes: 60, refreshTtlDays: 7 },\n security: {\n // Inert while sign-in is IdP-only (it guards password and one-time-code\n // sign-in); kept so a break-glass password login, if you ever turn one\n // on, is rate-limited from the first attempt.\n lockout: { maxFailures: 5, windowMinutes: 15, lockMinutes: 30 },\n // EVERY return URL must be one of these: the console, and a local dev\n // server (plain http is accepted on localhost / 127.0.0.1 only).\n allowedRedirectOrigins: ['https://console.example.com', 'http://localhost:5173'],\n },\n },\n\n // ONE organization holds your staff. Roles are rows (see the README):\n // support \u2192 accounts.read, changes.request, notes.write\n // approver \u2192 accounts.read, changes.approve\n // console-admin \u2192 all of the above + audit.read, policies.manage\n // The org `owner` holds every permission (break-glass).\n orgs: { enabled: true },\n\n // Records are live data, not editorial content: no draft step.\n cms: {\n draftPublish: false,\n hooks: {\n // \u2500\u2500 The approval state machine \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // pending \u2192 approved | rejected | expired ; approved \u2192 applied | failed.\n // Everything else is terminal. A decision is two writes inside ONE\n // transaction (pending \u2192 approved, then approved \u2192 applied), so the\n // hook sees every step and `approved` is never left behind on success.\n request_stage: {\n collection: 'change_requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'pending' && (item.state == 'approved' || item.state == 'rejected' || item.state == 'expired'))\"\n + \" || (before.state == 'approved' && (item.state == 'applied' || item.state == 'failed'))\",\n message: 'illegal change-request state transition',\n },\n // A request is born pending \u2014 or already applied when a policy\n // auto-approved it (then decided_by is 'policy' and auto is true).\n request_born: {\n collection: 'change_requests',\n event: 'beforeCreate',\n kind: 'validate',\n expr:\n \"item.state == 'pending'\"\n + \" || (item.state == 'applied' && item.auto == true && item.decided_by == 'policy')\",\n message: \"a change request starts 'pending' (or 'applied' by policy)\",\n },\n // \u2500\u2500 Self-approval is impossible BY CONSTRUCTION \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Write hooks see the ROW, not the caller, so the human ids are fields:\n // `requested_by` is set by request-change from the verified session,\n // `decided_by` by decide-change from the verified session. Even a buggy\n // function cannot move a request into approved/applied with the same\n // person on both sides.\n no_self_approval: {\n collection: 'change_requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (item.state != 'approved' && item.state != 'applied')\"\n + ' || (not(isNull(item.decided_by)) && item.decided_by != item.requested_by)',\n message: 'self_approval: the requester cannot approve their own change',\n },\n // Credits are usage units and never go negative. (`$inc` writes skip\n // hooks; the `validation.min` on the field guards those, as a 409.)\n credits_nonneg: {\n collection: 'accounts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'coalesce(item.credits, 0) >= 0',\n message: 'credits cannot go below zero',\n },\n // active \u2194 suspended; anything \u2192 closed; closed is terminal.\n account_status: {\n collection: 'accounts',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.status == before.status'\n + \" || item.status == 'closed'\"\n + \" || (before.status == 'active' && item.status == 'suspended')\"\n + \" || (before.status == 'suspended' && item.status == 'active')\",\n message: 'illegal account status transition',\n },\n },\n },\n\n // Internal notes: one thread per account, topic `accounts:<item_id>`.\n // Comments are author-owned (a signed-in caller can only write as itself).\n comments: {},\n\n // Approval messages go to the inbox and by e-mail. `mock` keeps the\n // walkthrough provider-free; switch to your e-mail provider for real mail.\n notifications: { provider: 'mock', fromEmail: 'console@example.com', inboxEnabled: true },\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 accounts: {\n singular: 'account',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' }, // $startsWith search\n email: { type: 'string', required: true, unique: true, indexSlot: 's2' },\n plan: { type: 'string', indexSlot: 's3' },\n status: { type: 'string', indexSlot: 's4', validation: { enum: ['active', 'suspended', 'closed'] } },\n credits: { type: 'int', indexSlot: 'n1', validation: { min: 0 } }, // usage units, never money\n seats: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n created_at: { type: 'datetime', indexSlot: 't1' },\n last_active_at: { type: 'datetime', indexSlot: 't2' },\n // the id of this account in YOUR product database \u2014 equality lookups\n // are index-served without spending a slot\n external_ref: { type: 'string', indexed: true },\n notes_count: { type: 'int', validation: { min: 0 } },\n // \u2500\u2500 FIELD-LEVEL READ SECURITY \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // A signed-in staff member receives these only when the session's\n // role is listed. Everyone else gets the account WITHOUT the field \u2014\n // absent, not null \u2014 and cannot filter or sort on it.\n risk_flags: { type: 'json', readRoles: ['console-admin', 'owner'] },\n billing_contact: { type: 'string', readRoles: ['console-admin', 'approver', 'owner'] },\n },\n // No `actions` buttons: a per-record button writes with the caller's\n // key, and the browser key here holds only functions:invoke by design.\n // Every screen and button in the console calls a function instead.\n },\n\n change_requests: {\n singular: 'change_request',\n fields: {\n kind: { type: 'string', required: true, indexSlot: 's1',\n validation: { enum: ['credit_grant', 'plan_change', 'suspend', 'reactivate', 'data_fix'] } },\n state: { type: 'string', required: true, indexSlot: 's2',\n validation: { enum: ['pending', 'approved', 'rejected', 'expired', 'applied', 'failed'] } },\n requested_by: { type: 'string', required: true, indexSlot: 's3' }, // end-user id, set by the function\n decided_by: { type: 'string', indexSlot: 's4' }, // end-user id, or 'policy'\n amount: { type: 'int', indexSlot: 'n1' }, // credits for credit_grant, else 0\n // `restrict`: an account with change requests cannot be deleted out\n // from under its own history (close it instead).\n account_ref: { type: 'relation', relationTo: 'accounts', onDelete: 'restrict', indexed: true },\n requested_at: { type: 'datetime', indexSlot: 't1' },\n decided_at: { type: 'datetime', indexSlot: 't2' },\n // `<Idempotency-Key>:<account id>` \u2014 unique, so a retried request\n // finds the first row instead of filing (or applying) twice\n request_key: { type: 'string', unique: true },\n payload: { type: 'json' }, // the exact patch to apply (allow-listed per kind)\n reason: { type: 'text', required: true },\n decision_note: { type: 'text' },\n error: { type: 'text' },\n auto: { type: 'bool' },\n },\n },\n\n // Thresholds are ROWS, not code: change \"auto-approve credit grants\n // under 500\" in the dashboard and the next request follows it.\n approval_policies: {\n singular: 'approval_policy',\n fields: {\n kind: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n approver_permission: { type: 'string', indexSlot: 's2' }, // default changes.approve\n auto_approve_below: { type: 'int', indexSlot: 'n1', validation: { min: 0 } }, // absent = never auto\n // the CUMULATIVE caps: a per-grant threshold alone is bypassed by\n // splitting one big grant into many small ones. Rolling 24 h:\n // daily_cap \u2192 SUM of auto-applied amounts, per requester AND per\n // account (absent = the threshold itself);\n // daily_count \u2192 NUMBER of auto-applied grants per requester, held\n // under a lock, so racing calls cannot overshoot the\n // sum cap by more than this many grants (absent = 25).\n // Past either cap, the grant is filed pending for an approver.\n auto_approve_daily_cap: { type: 'int', validation: { min: 0 } },\n auto_approve_daily_count: { type: 'int', validation: { min: 1 } },\n expires_after_hours: { type: 'int', indexSlot: 'n2', validation: { min: 0 } }, // 0 = never expires\n enabled: { type: 'bool' },\n },\n },\n },\n },\n\n functions: {\n // File one change request per target account (1\u201325 = a bulk fix). Small\n // credit grants under the policy threshold apply at once, atomically with\n // their request row; everything else waits for an approver.\n 'request-change': {\n entry: './functions/request-change.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read', 'notifications:send'],\n egressAllow: [],\n signature: {\n input: {\n kind: \"'credit_grant' | 'plan_change' | 'suspend' | 'reactivate' | 'data_fix'\",\n account_ids: 'string[]',\n amount: 'number | undefined',\n payload: 'Record<string, unknown> | undefined',\n reason: 'string',\n },\n output: '{ results: Array<{ account_id: string; request_id?: string; state: string; error?: string }> }',\n },\n },\n\n // Approve or reject ONE pending request. Refuses the requester (403\n // self_approval; the hook is the backstop). An approval applies the change\n // in the same transaction, guarded on `state == pending`.\n 'decide-change': {\n entry: './functions/decide-change.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read', 'notifications:send'],\n egressAllow: [],\n signature: {\n input: { request_id: 'string', decision: \"'approve' | 'reject'\", note: 'string | undefined' },\n output: '{ request_id: string; state: string; error?: string }',\n },\n },\n\n // EVERY staff read (account search, one account, the request queue, the\n // policies, an account's notes) plus writing a note, each op behind the\n // orgs permission check. The browser key holds only functions:invoke, so\n // a signed-in person outside the staff org reads nothing (see the README).\n 'console-api': {\n entry: './functions/console-api.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'orgs:read', 'comments:read', 'comments:write'],\n egressAllow: [],\n signature: {\n input: {\n op: \"'search' | 'account' | 'requests' | 'policies' | 'notes' | 'add_note' | undefined\",\n q: 'string | undefined',\n by: \"'email' | 'external_ref' | 'name' | undefined\",\n status: 'string | undefined',\n plan: 'string | undefined',\n sort: 'string | undefined',\n id: 'string | undefined',\n state: 'string | undefined',\n account_id: 'string | undefined',\n mine: 'boolean | undefined',\n body: 'string | undefined',\n cursor: 'string | undefined',\n limit: 'number | undefined',\n },\n output: 'unknown',\n },\n },\n\n // The change history of one account (or one request): the human-attributed\n // change requests, plus the platform audit rows that name the record.\n 'audit-trail': {\n entry: './functions/audit-trail.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'orgs:read'],\n // An API key of THIS backend holding `features:read`, because the audit\n // stream is not on the function's scoped callback. Be clear about what\n // that key is: `features:read` is NOT audit-only. It also opens every\n // feature's data export (all tenant rows, end users, transcripts,\n // function source), so it is a FULL-PROJECT EXPORT CREDENTIAL. It lives\n // only in this function's secret: never in a browser bundle, a client\n // app, a log line or a second function.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n signature: {\n input: { subject: 'string', cursor: 'string | undefined' },\n output: '{ requests: unknown[]; events: unknown[]; next_cursor: string | null; scanned: number; truncated: boolean }',\n },\n },\n\n // Hourly: pending requests older than their policy's expires_after_hours\n // become `expired`, and the requester is told.\n 'expire-pending': {\n entry: './functions/expire-pending.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n egressAllow: [],\n },\n },\n\n // References only \u2014 values are stored once and never appear in this file.\n secrets: {\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret for your staff identity provider' },\n vxil_read_key: { feature: 'functions', description: 'a vxil API key holding features:read (a full-project export credential, not audit-only) \u2014 the audit-trail function reads the audit stream with it; never put it in a browser' },\n },\n\n seed: {\n cms: [\n {\n collection: 'approval_policies',\n items: [\n // credit grants under 500 units apply at once, up to 5,000 units (and 25\n // grants) a day per requester and 5,000 a day per account; past that, or\n // at 500 and above, they wait 72 h for an approver\n { kind: 'credit_grant', approver_permission: 'changes.approve', auto_approve_below: 500, auto_approve_daily_cap: 5000, auto_approve_daily_count: 25, expires_after_hours: 72, enabled: true },\n // a plan override always needs an approver (no auto_approve_below)\n { kind: 'plan_change', approver_permission: 'changes.approve', expires_after_hours: 48, enabled: true },\n ],\n },\n {\n collection: 'accounts',\n items: [\n { name: 'Acme Analytics', email: 'ops@acme-analytics.example', plan: 'growth', status: 'active', credits: 12000, seats: 25, created_at: '2025-11-03T09:00:00Z', last_active_at: '2026-09-30T16:12:00Z', external_ref: 'cus_1001', notes_count: 0, billing_contact: 'finance@acme-analytics.example', risk_flags: [] },\n { name: 'Bluebird Labs', email: 'admin@bluebird.example', plan: 'starter', status: 'active', credits: 800, seats: 5, created_at: '2026-02-14T10:30:00Z', last_active_at: '2026-10-01T08:45:00Z', external_ref: 'cus_1002', notes_count: 0, billing_contact: 'ap@bluebird.example', risk_flags: [] },\n { name: 'Cobalt Freight', email: 'it@cobalt-freight.example', plan: 'enterprise', status: 'active', credits: 250000, seats: 140, created_at: '2024-06-20T12:00:00Z', last_active_at: '2026-10-02T11:20:00Z', external_ref: 'cus_1003', notes_count: 0, billing_contact: 'procurement@cobalt-freight.example', risk_flags: ['high_usage'] },\n { name: 'Driftwood Studio', email: 'hello@driftwood.example', plan: 'starter', status: 'suspended', credits: 0, seats: 2, created_at: '2026-05-01T15:00:00Z', last_active_at: '2026-08-11T19:05:00Z', external_ref: 'cus_1004', notes_count: 0, billing_contact: 'hello@driftwood.example', risk_flags: ['chargeback_history'] },\n { name: 'Evergreen Health', email: 'platform@evergreen.example', plan: 'growth', status: 'active', credits: 4300, seats: 32, created_at: '2025-08-09T07:30:00Z', last_active_at: '2026-09-29T13:40:00Z', external_ref: 'cus_1005', notes_count: 0, billing_contact: 'billing@evergreen.example', risk_flags: [] },\n ],\n },\n ],\n },\n});\n",
19188
- "readme": "# Admin console (saas)\n\nThe back-office every internal-tools team builds first. Staff sign in through **your company identity\nprovider**, look up and edit customer accounts, and fix up to 25 accounts at once. Sensitive changes\n(usage-credit grants, plan overrides, suspensions, data fixes) become **requests that someone else\napproves**. Each account shows its change history and internal notes.\n\nApprovals here are **rows plus one deciding function**: a state on a record, a hook that guards its\ntransitions, and one function that decides. There is no approval engine and no visual flow.\n\n```bash\nmkdir my-console && cd my-console\nvxil init --template admin-console # init scaffolds into the CURRENT directory\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging`\nprintf '%s' \"$OIDC_SECRET\" | vxil secrets set auth/oidc_client_secret\n# the audit-trail function's key. features:read is a FULL-PROJECT EXPORT credential, not\n# audit-only: it lives in this one function secret and never in a browser (see \"Audit trail\")\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name console-audit-read --scopes features:read --json | jq -r .api_key | vxil secrets set functions/vxil_read_key\nvxil push\n```\n\n`vxil link --key` stores the key you give it in `~/.vxil/credentials.json` (so does `vxil login` with its session): treat that file as a secret \u2014 never commit it, copy it into an image or share it.\n\nBefore you push, edit three values in `vxil.config.ts`: `issuer`, `clientId` and `allowedDomains` in\n`features.auth.providers.oidc`. Also set the console URL in `allowedRedirectOrigins` and in\n`CONSOLE_URL` at the top of `functions/request-change.ts`, `decide-change.ts` and\n`expire-pending.ts`, which is the link in every approval message.\n\n> **Plan note.** Five functions and one hourly cron fit the Free plan's counts (5 functions, 3 cron\n> triggers). The functions deploy on Free when the project's workload is `staging` or `development`\n> (`vxil projects workload <slug> development`). On a Free `production` project, `vxil push` stops\n> before it writes anything and names the ways out: change the workload, upgrade to Developer, or run\n> `vxil push --skip-functions`.\n\n## What it provisions\n\n| Collection | What a row is | Teaches |\n|---|---|---|\n| `accounts` | one customer account: name, e-mail (unique), plan, status, **credits** (usage units, never money), seats, your product's own id (`external_ref`) | list, filter and sort on index slots; a status state machine; two **role-gated fields** |\n| `change_requests` | one requested change to one account: kind, state, who asked, who decided, the exact patch, the reason | approvals as data; self-approval impossible by construction; compare-and-set decisions |\n| `approval_policies` | one row per kind: who may approve it, the auto-approve threshold, when a request expires | thresholds as rows, not code |\n\n| Function | Trigger | What it does |\n|---|---|---|\n| `request-change` | http | files one request per target account (1\u201325), or applies it at once under the policy threshold |\n| `decide-change` | http | approves or rejects one pending request; an approval applies it in the same transaction |\n| `console-api` | http | every staff read (account search and lookup, the request queue, the policies, an account's notes) and writing a note, each behind the permission check |\n| `audit-trail` | http | one account's (or one request's) history: the requests and the platform audit rows |\n| `expire-pending` | cron, hourly | pending requests past their policy's deadline become `expired` |\n\nPlus internal notes (comments, through `console-api`) and approval messages (inbox and e-mail).\n\n## Set up the staff organization (once, over the API)\n\nRoles are rows, so this needs no deploy. Use a server key (`vxil api` uses the CLI's key):\n\n```bash\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"support\",\"name\":\"Support\",\"permissions\":[\"accounts.read\",\"changes.request\",\"notes.write\"]}'\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"approver\",\"name\":\"Approver\",\"permissions\":[\"accounts.read\",\"changes.approve\"]}'\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"console-admin\",\"name\":\"Console admin\",\"permissions\":[\"accounts.read\",\"changes.request\",\"changes.approve\",\"notes.write\",\"audit.read\",\"policies.manage\"]}'\n```\n\nEach person signs in once through the identity provider, which creates their user. Then create the\nstaff organization with its first owner and seat everyone else on a role:\n\n```bash\nvxil api GET \"/v1/users?email=lead@example.com\" # \u2192 the user id\nvxil api POST /v1/orgs --data '{\"slug\":\"staff\",\"name\":\"Staff\",\"owner_user_id\":\"<lead>\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<sam>\",\"role\":\"support\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<ana>\",\"role\":\"approver\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<cat>\",\"role\":\"console-admin\"}'\n```\n\nThe org `owner` holds every permission (`*`). Keep that role for one or two break-glass people.\n\nRun the console in **its own project**, holding only staff. Every function looks up the caller's\nactive organization and asks the orgs permission check inside it, so a project that also holds\nyour customers' organizations would let a customer org's owner pass those checks in their own org.\n\n## Who can sign in, and why the browser reads nothing directly\n\n**`allowedDomains` lets in everyone at your company domain, not only staff.** Any colleague\nwith an `@example.com` identity can complete sign-in and get a session. Two things keep that\nfrom becoming access to customer data:\n\n1. **Assign the console's app in your identity provider to the staff group only** (in most\n providers: the app's assignment or access policy). Then a colleague outside the group is\n stopped at the provider and never gets a session. Do this first.\n2. **The browser key reads nothing on its own.** Even with a session, every read and write goes\n through a function that asks the orgs permission check first, so a signed-in person who is not\n in the staff org gets `403` from every function and sees no account, request or note.\n\nThe console's browser bundle holds two keys:\n\n| Key | Class | Scopes |\n|---|---|---|\n| `console-auth` | server | `auth:signin` (starting the identity-provider sign-in, refreshing the session) |\n| `console-data` | **public / thin-client** (dashboard \u2192 API keys) | `functions:invoke` **only** |\n\n`console-data` is minted as the thin-client class, so the edge refuses any request on it that does\nnot carry a signed-in session. A copy lifted out of the bundle calls nothing.\n\n**Why the browser key holds no `cms:read` or `comments:*`.** None of these collections has an\nowner field: every staff member works on every account. A collection read is checked against the\nkey's scopes, not against the staff org, so a browser key holding `cms:read` would let *anyone who\ncan sign in* (the whole domain, if step 1 is skipped) list every customer account with its e-mail.\nA key holding `comments:read` / `comments:write` would let them read and write internal notes. A key\nholding `cms:write` would let any staff member write any row with no permission check, no approval\nand no request row. So every screen calls `console-api` (reads and notes) or one of the change\nfunctions, and each one asks the orgs permission check (revocation-grade, so a removed role takes\neffect on the next call, not at the next sign-in). For the same reason the collections declare no\nper-record action buttons: a button writes with the caller's key.\n\n**Keep IdP group names distinct from the console's role names.** The `groups` claim rides the\nsession as roles next to the org role. A group literally named `console-admin` or `approver` would\npass the `readRoles` field gates below. The permission checks in the functions read the org\nmembership, not the group, so they are not affected.\n\n## Back-office over your own data\n\n- **List, filter, sort.** Every column a staff screen filters or sorts on is in an index slot:\n `status`, `plan`, `credits`, `seats`, `created_at`, `last_active_at`. For example, \"suspended\n accounts on growth, most credits first\" is\n `console-api` `{ op: 'search', status: 'suspended', plan: 'growth', sort: '-credits' }`, which the\n function runs as one indexed `GET /v1/cms/items/accounts?filter=\u2026&sort=-credits`. Sorted lists page\n with `cursor`, the same as the default order.\n- **Lookup.** `console-api` `{ op: 'search', q, by }`: `email` is an exact match on the unique slot,\n `external_ref` an exact match on an indexed field (your product's own id, without spending a\n slot), and `name` a case-sensitive `$startsWith` prefix. `{ op: 'account', id }` reads one account;\n `{ op: 'requests', state: 'pending' }` is the approver's queue (`account_id` narrows it to one\n account, `mine: true` to your own requests).\n- **Bulk fixes.** `request-change` takes up to 25 `account_ids`, which is one batch call of up to 25\n operations. Each account gets its own request row and its own result: `pending`, `applied`,\n `duplicate`, or `rejected` with `account_not_found`.\n- **Atomic multi-record changes.** An approval writes the request and the account in one atomic\n batch (`POST /v1/cms/batch` with `atomic: true`). Either both change or neither does.\n\n## Approvals as data, plus one function\n\n**The state machine is a hook.** `request_stage` allows only `pending \u2192 approved | rejected |\nexpired` and `approved \u2192 applied | failed`. Every other state is terminal. `request_born` allows a\nrequest to start only as `pending`, or as `applied` when a policy auto-approved it.\n\n**Self-approval is impossible by construction.** Write hooks see the row, not the caller, so the\nhuman ids are fields. `request-change` sets `requested_by` from the verified session, and\n`decide-change` sets `decided_by` the same way. The `no_self_approval` hook refuses any move into\n`approved` or `applied` where the two are equal. `decide-change` refuses the requester first with\n`403 self_approval`. The hook is the backstop, so a bug in the function cannot open the hole.\n\n**Auto-approve thresholds are rows, not code.** The seed has two policies:\n\n| kind | approver_permission | auto_approve_below | expires_after_hours |\n|---|---|---|---|\n| `credit_grant` | `changes.approve` | 500 | 72 |\n| `plan_change` | `changes.approve` | (none: never auto) | 48 |\n\nA credit grant of 499 or fewer units applies at once, as an `applied` request with `auto: true` and\n`decided_by: 'policy'`, written in the same atomic batch as the account change. A grant of 500 or\nmore waits for an approver. Edit the row and the next request follows it. A kind with no row never\nauto-applies and expires after 72 hours. Only `credit_grant` can auto-apply, because only it\ncarries an amount.\n\n**The threshold is also a daily budget, so splitting a grant does not skip the approver.** A\nper-grant threshold on its own is easy to get around: twenty grants of 499 to the same account are\n9,980 credits that nobody approved. So the `credit_grant` row also carries two rolling 24-hour caps:\n\n| field | seed | what it caps |\n|---|---|---|\n| `auto_approve_daily_cap` | 5,000 | the **sum** of auto-applied grants per requester, and separately per account |\n| `auto_approve_daily_count` | 25 | the **number** of auto-applied grants per requester |\n\nBefore it auto-applies anything, `request-change` adds up what was auto-applied in the last 24 hours\n(one aggregate over the requester's `decided_by: 'policy'` rows, one bounded read for the target\naccounts) and keeps a running total across a bulk call. A grant that would cross either sum cap is\nfiled `pending` with `reason: 'daily_auto_cap'` in its result, and the approvers are told as usual.\nIf a total cannot be read, the grant is filed `pending` too (fail closed). Leave\n`auto_approve_daily_cap` out and it defaults to the threshold itself (one threshold's worth a day).\n\nThe totals are a read, so two calls sent at the same instant could both pass them. The count cap is\nthe backstop for that: each auto-applied row is written under one lock per requester with a guard\nthat allows at most `auto_approve_daily_count` such rows in 24 hours. Racing calls can overshoot the\nsum cap by at most that many sub-threshold grants (25 \xD7 499 with the seed), never without bound.\nLower the count if that worst case is too high for you.\n\n**Two approvers can never both apply.** The decision's first write is conditional\n(`if: { state: 'pending' }`). Two approvers pressing at once both read `pending`. The second one's\nbatch finds the state already `approved` and gets a clean `409 already_decided`. The account\nchanges once.\n\n**A refused change is recorded.** When the account itself refuses the write (a closed account, a\ngrant that would take credits below zero, an e-mail another account already uses), the batch rolls\nback. A second batch then records the decision and the refusal: `pending \u2192 approved \u2192 failed`, with\nthe error on the row.\n\n**Expiry.** `expire-pending` runs hourly. Pending requests older than their kind's\n`expires_after_hours` become `expired` (0 means never), and the requester is told. Each write is\nconditional on `pending`, so a request decided in the same instant keeps its decision.\n\n**The payload is allow-listed per kind.** `request-change` refuses (`422 invalid_payload`) any\n`payload` key outside this list, before anything is written. `decide-change` applies the stored\npayload through the same list again.\n\n| kind | payload | applied as |\n|---|---|---|\n| `credit_grant` | none (`amount` 1\u20131,000,000) | `$inc: { credits: amount }` |\n| `plan_change` | `{ plan }` | `{ plan }` |\n| `suspend` / `reactivate` | none | `{ status: 'suspended' }` / `{ status: 'active' }` |\n| `data_fix` | any of `{ name, email, seats }` | those fields |\n\n**Retries.** Send an `Idempotency-Key` header with each button press. The function writes\n`<Idempotency-Key>:<account id>` into the request's unique `request_key` field, so a retried press\nfinds the first row (`state: 'duplicate'`) and never files or applies twice. The cms routes do\n**not** honor the `Idempotency-Key` header themselves. That unique field is the dedupe. The\nnotification sends **do** honor it, so each approver, and each requester per decision, is messaged\nonce. Without the header, every call counts as a new request.\n\n**Multi-step approvals are more data, not a graph.** For \"two approvals above 10,000 credits\", add a\nsecond state (`approved_1`) and a second permission (`changes.approve_large`) to the hook and the\npolicy row. `decide-change` stays one function that moves one state.\n\n## Who sees which field\n\n`risk_flags` (json) declares `readRoles: ['console-admin', 'owner']`, and `billing_contact` declares\n`['console-admin', 'approver', 'owner']`. `console-api` reads with the caller's session, so a support\nmember receives accounts **without** those fields: they are absent, not null, and cannot be used in a\nfilter or a sort. The role comes from the session (`orgClaims`, plus the IdP `groups`; see the\ngroup-name note above), so a role change applies at the member's next sign-in or refresh. Your own\nserver key still sees every field.\n\n## Internal notes\n\nNotes are comments on the topic `accounts:<item_id>`, read and written through `console-api`:\n\n```ts\nconst fn = vx.asEndUser(sessionToken).fn['console-api'];\nawait fn({ op: 'add_note', account_id: id, body: 'Customer asked to pause billing until May.' }); // needs notes.write\nconst { notes } = await fn({ op: 'notes', account_id: id }); // needs accounts.read\n```\n\nThe function writes the note with the caller's session, so the author is always the signed-in staff\nmember (a different `author_id` is refused). `notes_count` is a plain field; bump it from a function\nholding `cms:write` if a screen needs the count without loading the thread.\n\n## Audit trail per record\n\n`audit-trail` takes `{ subject: 'accounts:<id>' }` (or `'change_requests:<id>'`) and needs\n`audit.read`. It returns two halves:\n\n- **requests** are the change requests for the account: who asked, who decided, why, the exact\n patch, and the outcome. This half names the people.\n- **events** are the platform's own audit rows for the record: every create, update (with the\n incremented fields of a credit grant) and delete. They are kept 90 days on Free and 365 days on\n paid plans. Every one of them is recorded as **the project** (actor `tenant`), not as the staff\n member who pressed the button: the audit row names the record, never the person. The person is\n recorded in the request row (`requested_by`, `decided_by`), which is why the request rows exist.\n A note is audited the same way (`comment.created`, as the project); its author is on the note.\n\nThe audit stream is not on the function's scoped callback, so the function reads it with\n`vxil_read_key`, an API key holding `features:read`. It scans the 1,000 most recent tenant\nevents per call. `next_cursor` continues the scan, and `truncated: true` says there is more.\nReads are not audited.\n\n> **`vxil_read_key` is a full-project export credential, not an audit-only key.** `features:read`\n> also opens every feature's data export: all tenant rows, the end-user records, assistant\n> transcripts and function source. Treat it like the database password. It is stored only as this\n> one function's secret and is never sent to the browser, so a staff session cannot see it. Never\n> put it in a client bundle, a log line, or a second function, and rotate it if it ever leaves the\n> secret store. If you do not need the platform half of the trail, delete the `events` half and the\n> secret, and keep the request rows.\n\n## If your customer records live in your own database\n\nKeep the requests, approvals, policies, notes and messages here, and move two things:\n\n1. **`console-api`'s `search` and `account` ops**: keep their request and response shape, and replace the bodies with one HTTPS\n call to your database's query endpoint. The pattern is a function, a secret and one egress host\n (`examples/byo-db`).\n2. **The apply step in `decide-change`**: replace the batch's account write with a call to your own\n API. Then `approved` becomes a resting state (the decision is recorded), and your API's answer\n moves the request to `applied` or `failed`.\n\n## What this is not\n\n- **Not a generic approval engine.** There is no rule language and no designer. The rules are one\n hook, one policy row per kind, and a function you read top to bottom.\n- **Not a visual flow.** A second step is a second state and a second permission, in data.\n- **Not a payments surface.** Credits are usage units your product consumes. To refund money\n through your own payment provider, add a function with `payments:write` that calls\n `POST /v1/payments/refunds` (the payments integration: your provider account, never in the flow of\n funds), and file it as one more request kind.\n\n## Pairs with\n\n- `templates/team-workspace`: the same orgs, roles and `readRoles` machinery for your customers'\n teams.\n- `templates/helpdesk`: the customer-facing side that these staff screens answer.\n- `examples/apps/helpdesk` and `examples/apps/microblog`: a browser app with the same two-key split\n and sign-in shell to build the console UI on.\n",
19283
+ "readme": "# Admin console (saas)\n\nThe back-office every internal-tools team builds first. Staff sign in through **your company identity\nprovider**, look up and edit customer accounts, and fix up to 25 accounts at once. Sensitive changes\n(usage-credit grants, plan overrides, suspensions, data fixes) become **requests that someone else\napproves**. Each account shows its change history and internal notes.\n\nApprovals here are **rows plus one deciding function**: a state on a record, a hook that guards its\ntransitions, and one function that decides. There is no approval engine and no visual flow.\n\n```bash\nmkdir my-console && cd my-console\nvxil init --template admin-console # init scaffolds into the CURRENT directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf '%s' \"$OIDC_SECRET\" | vxil secrets set auth/oidc_client_secret # or run it bare for a hidden prompt\n# the audit-trail function's key: audit:read reads the audit stream and nothing else (see \"Audit trail\")\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name console-audit-read --scopes audit:read --json | jq -r .api_key | vxil secrets set functions/vxil_read_key\nvxil push # AFTER the three OIDC edits below; collections + hooks + five functions\nvxil seed # demo accounts + approval policies\n```\n\nThe mint line needs `jq`: it pipes the new key straight into the secret, so it never shows in your\nterminal. (Without `jq`, run the mint with `--json`, copy `api_key`, and paste it at the hidden prompt of\n`vxil secrets set functions/vxil_read_key`.)\n\n`vxil link --key` stores the key you give it in `~/.vxil/credentials.json` (so does `vxil login` with its session): treat that file as a secret \u2014 never commit it, copy it into an image or share it.\n\nBefore you push, edit three values in `vxil.config.ts`: `issuer`, `clientId` and `allowedDomains` in\n`features.auth.providers.oidc`. Also set the console URL in `allowedRedirectOrigins` and in\n`CONSOLE_URL` at the top of `functions/request-change.ts`, `decide-change.ts` and\n`expire-pending.ts`, which is the link in every approval message.\n\n> **Plan note.** Five functions and one hourly cron fit the Free plan's counts (5 functions, 3 cron\n> triggers). The functions deploy on Free when the project's workload is `staging` or `development`\n> (the `vxil projects workload` step above). On a Free `production` project, `vxil push` stops\n> before it writes anything and names the ways out: change the workload, upgrade to Developer, or run\n> `vxil push --skip-functions`.\n\n## What it provisions\n\n| Collection | What a row is | Teaches |\n|---|---|---|\n| `accounts` | one customer account: name, e-mail (unique), plan, status, **credits** (usage units, never money), seats, your product's own id (`external_ref`) | list, filter and sort on index slots; a status state machine; two **role-gated fields** |\n| `change_requests` | one requested change to one account: kind, state, who asked, who decided, the exact patch, the reason | approvals as data; self-approval impossible by construction; compare-and-set decisions |\n| `approval_policies` | one row per kind: who may approve it, the auto-approve threshold, when a request expires | thresholds as rows, not code |\n\n| Function | Trigger | What it does |\n|---|---|---|\n| `request-change` | http | files one request per target account (1\u201325), or applies it at once under the policy threshold |\n| `decide-change` | http | approves or rejects one pending request; an approval applies it in the same transaction |\n| `console-api` | http | every staff read (account search and lookup, the request queue, the policies, an account's notes) and writing a note, each behind the permission check |\n| `audit-trail` | http | one account's (or one request's) history: the requests and the platform audit rows |\n| `expire-pending` | cron, hourly | pending requests past their policy's deadline become `expired` |\n\nPlus internal notes (comments, through `console-api`) and approval messages (inbox and e-mail).\n\n## Set up the staff organization (once, over the API)\n\nRoles are rows, so this needs no deploy. Use a server key (`vxil api` uses the CLI's key):\n\n```bash\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"support\",\"name\":\"Support\",\"permissions\":[\"accounts.read\",\"changes.request\",\"notes.write\"]}'\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"approver\",\"name\":\"Approver\",\"permissions\":[\"accounts.read\",\"changes.approve\"]}'\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"console-admin\",\"name\":\"Console admin\",\"permissions\":[\"accounts.read\",\"changes.request\",\"changes.approve\",\"notes.write\",\"audit.read\",\"policies.manage\"]}'\n```\n\nEach person signs in once through the identity provider, which creates their user. Then create the\nstaff organization with its first owner and seat everyone else on a role:\n\n```bash\nvxil api GET \"/v1/users?email=lead@example.com\" # \u2192 the user id\nvxil api POST /v1/orgs --data '{\"slug\":\"staff\",\"name\":\"Staff\",\"owner_user_id\":\"<lead>\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<sam>\",\"role\":\"support\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<ana>\",\"role\":\"approver\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<cat>\",\"role\":\"console-admin\"}'\n```\n\nThe org `owner` holds every permission (`*`). Keep that role for one or two break-glass people.\n\nRun the console in **its own project**, holding only staff. Every function looks up the caller's\nactive organization and asks the orgs permission check inside it, so a project that also holds\nyour customers' organizations would let a customer org's owner pass those checks in their own org.\n\n## Who can sign in, and why the browser reads nothing directly\n\n**`allowedDomains` lets in everyone at your company domain, not only staff.** Any colleague\nwith an `@example.com` identity can complete sign-in and get a session. Two things keep that\nfrom becoming access to customer data:\n\n1. **Assign the console's app in your identity provider to the staff group only** (in most\n providers: the app's assignment or access policy). Then a colleague outside the group is\n stopped at the provider and never gets a session. Do this first.\n2. **The browser key reads nothing on its own.** Even with a session, every read and write goes\n through a function that asks the orgs permission check first, so a signed-in person who is not\n in the staff org gets `403` from every function and sees no account, request or note.\n\nThe console's browser bundle holds two keys:\n\n| Key | Class | Scopes |\n|---|---|---|\n| `console-auth` | server | `auth:signin` (starting the identity-provider sign-in, refreshing the session) |\n| `console-data` | **public / thin-client** (dashboard \u2192 API keys) | `functions:invoke` **only** |\n\n`console-data` is minted as the thin-client class, so the edge refuses any request on it that does\nnot carry a signed-in session. A copy lifted out of the bundle calls nothing.\n\n**Why the browser key holds no `cms:read` or `comments:*`.** None of these collections has an\nowner field: every staff member works on every account. A collection read is checked against the\nkey's scopes, not against the staff org, so a browser key holding `cms:read` would let *anyone who\ncan sign in* (the whole domain, if step 1 is skipped) list every customer account with its e-mail.\nA key holding `comments:read` / `comments:write` would let them read and write internal notes. A key\nholding `cms:write` would let any staff member write any row with no permission check, no approval\nand no request row. So every screen calls `console-api` (reads and notes) or one of the change\nfunctions, and each one asks the orgs permission check (revocation-grade, so a removed role takes\neffect on the next call, not at the next sign-in). For the same reason the collections declare no\nper-record action buttons: a button writes with the caller's key.\n\n**Keep IdP group names distinct from the console's role names.** The `groups` claim rides the\nsession as roles next to the org role. A group literally named `console-admin` or `approver` would\npass the `readRoles` field gates below. The permission checks in the functions read the org\nmembership, not the group, so they are not affected.\n\n## Back-office over your own data\n\n- **List, filter, sort.** Every column a staff screen filters or sorts on is in an index slot:\n `status`, `plan`, `credits`, `seats`, `created_at`, `last_active_at`. For example, \"suspended\n accounts on growth, most credits first\" is\n `console-api` `{ op: 'search', status: 'suspended', plan: 'growth', sort: '-credits' }`, which the\n function runs as one indexed `GET /v1/cms/items/accounts?filter=\u2026&sort=-credits`. Sorted lists page\n with `cursor`, the same as the default order.\n- **Lookup.** `console-api` `{ op: 'search', q, by }`: `email` is an exact match on the unique slot,\n `external_ref` an exact match on an indexed field (your product's own id, without spending a\n slot), and `name` a case-sensitive `$startsWith` prefix. `{ op: 'account', id }` reads one account;\n `{ op: 'requests', state: 'pending' }` is the approver's queue (`account_id` narrows it to one\n account, `mine: true` to your own requests).\n- **Bulk fixes.** `request-change` takes up to 25 `account_ids`, which is one batch call of up to 25\n operations. Each account gets its own request row and its own result: `pending`, `applied`,\n `duplicate`, or `rejected` with `account_not_found`.\n- **Atomic multi-record changes.** An approval writes the request and the account in one atomic\n batch (`POST /v1/cms/batch` with `atomic: true`). Either both change or neither does.\n\n## Approvals as data, plus one function\n\n**The state machine is a hook.** `request_stage` allows only `pending \u2192 approved | rejected |\nexpired` and `approved \u2192 applied | failed`. Every other state is terminal. `request_born` allows a\nrequest to start only as `pending`, or as `applied` when a policy auto-approved it.\n\n**Self-approval is impossible by construction.** Write hooks see the row, not the caller, so the\nhuman ids are fields. `request-change` sets `requested_by` from the verified session, and\n`decide-change` sets `decided_by` the same way. The `no_self_approval` hook refuses any move into\n`approved` or `applied` where the two are equal. `decide-change` refuses the requester first with\n`403 self_approval`. The hook is the backstop, so a bug in the function cannot open the hole.\n\n**Auto-approve thresholds are rows, not code.** The seed has two policies:\n\n| kind | approver_permission | auto_approve_below | expires_after_hours |\n|---|---|---|---|\n| `credit_grant` | `changes.approve` | 500 | 72 |\n| `plan_change` | `changes.approve` | (none: never auto) | 48 |\n\nA credit grant of 499 or fewer units applies at once, as an `applied` request with `auto: true` and\n`decided_by: 'policy'`, written in the same atomic batch as the account change. A grant of 500 or\nmore waits for an approver. Edit the row and the next request follows it. A kind with no row never\nauto-applies and expires after 72 hours. Only `credit_grant` can auto-apply, because only it\ncarries an amount.\n\n**The threshold is also a daily budget, so splitting a grant does not skip the approver.** A\nper-grant threshold on its own is easy to get around: twenty grants of 499 to the same account are\n9,980 credits that nobody approved. So the `credit_grant` row also carries two rolling 24-hour caps:\n\n| field | seed | what it caps |\n|---|---|---|\n| `auto_approve_daily_cap` | 5,000 | the **sum** of auto-applied grants per requester, and separately per account |\n| `auto_approve_daily_count` | 25 | the **number** of auto-applied grants per requester |\n\nBefore it auto-applies anything, `request-change` adds up what was auto-applied in the last 24 hours\n(one aggregate over the requester's `decided_by: 'policy'` rows, one bounded read for the target\naccounts) and keeps a running total across a bulk call. A grant that would cross either sum cap is\nfiled `pending` with `reason: 'daily_auto_cap'` in its result, and the approvers are told as usual.\nIf a total cannot be read, the grant is filed `pending` too (fail closed). Leave\n`auto_approve_daily_cap` out and it defaults to the threshold itself (one threshold's worth a day).\n\nThe totals are a read, so two calls sent at the same instant could both pass them. The count cap is\nthe backstop for that: each auto-applied row is written under one lock per requester with a guard\nthat allows at most `auto_approve_daily_count` such rows in 24 hours. Racing calls can overshoot the\nsum cap by at most that many sub-threshold grants (25 \xD7 499 with the seed), never without bound.\nLower the count if that worst case is too high for you.\n\n**Two approvers can never both apply.** The decision's first write is conditional\n(`if: { state: 'pending' }`). Two approvers pressing at once both read `pending`. The second one's\nbatch finds the state already `approved` and gets a clean `409 already_decided`. The account\nchanges once.\n\n**A refused change is recorded.** When the account itself refuses the write (a closed account, a\ngrant that would take credits below zero, an e-mail another account already uses), the batch rolls\nback. A second batch then records the decision and the refusal: `pending \u2192 approved \u2192 failed`, with\nthe error on the row.\n\n**Expiry.** `expire-pending` runs hourly. Pending requests older than their kind's\n`expires_after_hours` become `expired` (0 means never), and the requester is told. Each write is\nconditional on `pending`, so a request decided in the same instant keeps its decision.\n\n**The payload is allow-listed per kind.** `request-change` refuses (`422 invalid_payload`) any\n`payload` key outside this list, before anything is written. `decide-change` applies the stored\npayload through the same list again.\n\n| kind | payload | applied as |\n|---|---|---|\n| `credit_grant` | none (`amount` 1\u20131,000,000) | `$inc: { credits: amount }` |\n| `plan_change` | `{ plan }` | `{ plan }` |\n| `suspend` / `reactivate` | none | `{ status: 'suspended' }` / `{ status: 'active' }` |\n| `data_fix` | any of `{ name, email, seats }` | those fields |\n\n**Retries.** Send an `Idempotency-Key` header with each button press. The function writes\n`<Idempotency-Key>:<account id>` into the request's unique `request_key` field, so a retried press\nfinds the first row (`state: 'duplicate'`) and never files or applies twice. The cms routes do\n**not** honor the `Idempotency-Key` header themselves. That unique field is the dedupe. The\nnotification sends **do** honor it, so each approver, and each requester per decision, is messaged\nonce. Without the header, every call counts as a new request.\n\n**Multi-step approvals are more data, not a graph.** For \"two approvals above 10,000 credits\", add a\nsecond state (`approved_1`) and a second permission (`changes.approve_large`) to the hook and the\npolicy row. `decide-change` stays one function that moves one state.\n\n## Who sees which field\n\n`risk_flags` (json) declares `readRoles: ['console-admin', 'owner']`, and `billing_contact` declares\n`['console-admin', 'approver', 'owner']`. `console-api` reads with the caller's session, so a support\nmember receives accounts **without** those fields: they are absent, not null, and cannot be used in a\nfilter or a sort. The role comes from the session (`orgClaims`, plus the IdP `groups`; see the\ngroup-name note above), so a role change applies at the member's next sign-in or refresh. Your own\nserver key still sees every field.\n\n## Internal notes\n\nNotes are comments on the topic `accounts:<item_id>`, read and written through `console-api`:\n\n```ts\nconst fn = vx.asEndUser(sessionToken).fn['console-api'];\nawait fn({ op: 'add_note', account_id: id, body: 'Customer asked to pause billing until May.' }); // needs notes.write\nconst { notes } = await fn({ op: 'notes', account_id: id }); // needs accounts.read\n```\n\nThe function writes the note with the caller's session, so the author is always the signed-in staff\nmember (a different `author_id` is refused). `notes_count` is a plain field; bump it from a function\nholding `cms:write` if a screen needs the count without loading the thread.\n\n## Audit trail per record\n\n`audit-trail` takes `{ subject: 'accounts:<id>' }` (or `'change_requests:<id>'`) and needs\n`audit.read`. It returns two halves:\n\n- **requests** are the change requests for the account: who asked, who decided, why, the exact\n patch, and the outcome. This half names the people.\n- **events** are the platform's own audit rows for the record: every create, update (with the\n incremented fields of a credit grant) and delete. They are kept 90 days on Free and 365 days on\n paid plans. Every one of them is recorded as **the project** (actor `tenant`), not as the staff\n member who pressed the button: the audit row names the record, never the person. The person is\n recorded in the request row (`requested_by`, `decided_by`), which is why the request rows exist.\n A note is audited the same way (`comment.created`, as the project); its author is on the note.\n\nThe shipped function reads the audit stream (`GET /v1/audit`) with `vxil_read_key`, an API key holding\n`audit:read`. That key is a holdover, not a platform limit: a function that declares the `audit:read`\nscope reaches `/v1/audit` through its own scoped callback, with no stored key, and you can switch\n`functions/audit-trail.ts` over to that. It scans the 1,000 most recent tenant\nevents per call. `next_cursor` continues the scan, and `truncated: true` says there is more.\nReads are not audited.\n\n> **Mint `vxil_read_key` with `audit:read`, not `features:read`.** Both open `/v1/audit`, but\n> `features:read` also opens every feature's data export: all tenant rows, the end-user records,\n> assistant transcripts and function source. (The comments in `vxil.config.ts` and the function still\n> name `features:read`; `audit:read` is the narrower scope that does the same job here.) Whichever you\n> use, it is stored only as this one function's secret and is never sent to the browser, so a staff\n> session cannot see it. Never put it in a client bundle, a log line, or a second function, and rotate\n> it if it ever leaves the secret store. If you do not need the platform half of the trail, delete the\n> `events` half and the secret, and keep the request rows.\n\n## If your customer records live in your own database\n\nKeep the requests, approvals, policies, notes and messages here, and move two things:\n\n1. **`console-api`'s `search` and `account` ops**: keep their request and response shape, and replace the bodies with one HTTPS\n call to your database's query endpoint. The pattern is a function, a secret and one egress host\n (vxil.com/docs/guide/08-running-your-code-functions, \"Your own database from a function\").\n2. **The apply step in `decide-change`**: replace the batch's account write with a call to your own\n API. Then `approved` becomes a resting state (the decision is recorded), and your API's answer\n moves the request to `applied` or `failed`.\n\n## What this is not\n\n- **Not a generic approval engine.** There is no rule language and no designer. The rules are one\n hook, one policy row per kind, and a function you read top to bottom.\n- **Not a visual flow.** A second step is a second state and a second permission, in data.\n- **Not a payments surface.** Credits are usage units your product consumes. To refund money\n through your own payment provider, add a function with `payments:write` that calls\n `POST /v1/payments/refunds` (the payments integration: your provider account, never in the flow of\n funds), and file it as one more request kind.\n\n## Pairs with\n\n- the **team-workspace** blueprint: the same orgs, roles and `readRoles` machinery for your customers'\n teams.\n- the **helpdesk** blueprint: the customer-facing side that these staff screens answer.\n- the `helpdesk` and `microblog` showcase apps (https://vxil.com/showcase, downloadable as zips): a browser app with the same two-key split\n and sign-in shell to build the console UI on.\n",
19189
19284
  "functions": {
19190
19285
  "audit-trail.ts": "// audit-trail.ts \u2014 THE HISTORY OF ONE RECORD (a vxil function, http, end-user mode).\n//\n// POST /v1/fn/audit-trail { subject: 'accounts:<id>' | 'change_requests:<id>', cursor? }\n//\n// Two halves, because they answer different questions:\n//\n// requests \u2014 WHO asked for what, WHO decided, WHY. The change_requests rows\n// for the account (or the one request): requester, approver,\n// reason, decision note, the exact patch, the outcome. Every\n// change made through this console has one, so this is the\n// human-attributed trail.\n// events \u2014 WHAT the platform recorded on the record itself: every create,\n// update (with the incremented fields for a credit grant) and\n// delete, from the tenant audit stream, kept 90 days on Free and\n// 365 days on paid plans. These rows name the record but not the\n// human: every write is audited as the project (actor `tenant`),\n// not as the staff member, which is exactly why the request rows\n// above (requested_by / decided_by) exist.\n//\n// The audit stream is not reachable through the function's scoped callback, so\n// it is read with `vxil_read_key`: an API key of THIS backend holding\n// `features:read`. That scope is NOT audit-only: it also opens every feature's\n// data export, so the key is a FULL-PROJECT EXPORT CREDENTIAL. It lives only in\n// this function's secret; it is never returned, logged, or sent to a browser.\n// The stream is filtered here (newest first, a bounded number\n// of pages per call; `next_cursor` continues the scan, `truncated` says there\n// is more). Reads are not audited.\n//\n// Requires `audit.read` (console-admin).\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst AUDIT_PAGE = 200; // the audit list's own maximum page\nconst MAX_AUDIT_PAGES = 5; // \u22641,000 most recent tenant events scanned per call\nconst MAX_REQUESTS = 50;\nconst COLLECTIONS = new Set(['accounts', 'change_requests']);\n\ninterface Payload { subject?: unknown; cursor?: unknown }\ntype Env = HttpFunctionEnvelope<Payload>;\ninterface AuditRow { id: number | string; event: string; actor: string; created_at: string; payload?: Record<string, unknown> | null }\n\nconst hdrs = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst fail = (status: number, error: string, extra: Record<string, unknown> = {}) =>\n Response.json({ error, ...extra }, { status });\n\nasync function requirePermission(base: string, orgs: string, userId: string, permission: string): Promise<string | Response> {\n const c = await fetch(`${base}/v1/orgs/session-claims?user_id=${encodeURIComponent(userId)}`, { headers: hdrs(orgs) });\n if (!c.ok) return fail(502, 'orgs_unavailable', { status: c.status });\n const orgId = ((await c.json()) as { data?: { org_id?: string | null } }).data?.org_id;\n if (!orgId) return fail(403, 'forbidden', { permission, message: 'you are not a member of the staff organization' });\n const r = await fetch(\n `${base}/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(userId)}&permission=${encodeURIComponent(permission)}`,\n { headers: hdrs(orgs) },\n );\n if (!r.ok) return fail(502, 'orgs_unavailable', { status: r.status });\n const allowed = ((await r.json()) as { data?: { allowed?: boolean } }).data?.allowed === true;\n return allowed ? orgId : fail(403, 'forbidden', { permission, message: `you need the '${permission}' permission` });\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const { cms, orgs } = env.scoped_jwts ?? {};\n const readKey = env.secrets?.vxil_read_key;\n const me = env.end_user?.id;\n if (!me) return fail(401, 'sign_in_required', { message: 'call this with a signed-in staff session' });\n if (!cms || !orgs) return fail(500, 'missing_scopes');\n if (!readKey) return fail(409, 'missing_secret', { message: 'store an API key holding features:read as functions/vxil_read_key (a full-project export credential: keep it only in this secret)' });\n\n const m = typeof env.payload?.subject === 'string' ? /^([a-z_]+):([A-Za-z0-9_-]{1,64})$/.exec(env.payload.subject) : null;\n if (!m || !COLLECTIONS.has(m[1]!)) {\n return fail(422, 'invalid_payload', { message: \"subject must be 'accounts:<id>' or 'change_requests:<id>'\" });\n }\n const [, collection, itemId] = m as unknown as [string, string, string];\n\n const orgId = await requirePermission(base, orgs, me, 'audit.read');\n if (orgId instanceof Response) return orgId;\n\n // \u2500\u2500 half 1: the human-attributed change requests \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 let requests: unknown[] = [];\n if (collection === 'accounts') {\n const r = await fetch(\n `${base}/v1/cms/items/change_requests?filter=${enc({ account_ref: itemId })}&limit=${MAX_REQUESTS}`,\n { headers: hdrs(cms) },\n );\n if (r.ok) {\n const items = ((await r.json()) as { data?: { items?: Array<{ item_id: string; data: Record<string, unknown> }> } }).data?.items ?? [];\n requests = items.map((i) => ({ request_id: i.item_id, ...i.data }));\n }\n } else {\n const r = await fetch(`${base}/v1/cms/items/change_requests/${itemId}`, { headers: hdrs(cms) });\n if (r.ok) {\n const i = ((await r.json()) as { data?: { item_id: string; data: Record<string, unknown> } }).data;\n if (i) requests = [{ request_id: i.item_id, ...i.data }];\n }\n }\n\n // \u2500\u2500 half 2: the platform audit rows that name this record \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const events: Array<{ id: string; event: string; actor: string; at: string; fields: string[] | null }> = [];\n let cursor = typeof env.payload?.cursor === 'string' && /^\\d+$/.test(env.payload.cursor) ? env.payload.cursor : null;\n let scanned = 0;\n for (let page = 0; page < MAX_AUDIT_PAGES; page++) {\n const r = await fetch(`${base}/v1/audit?limit=${AUDIT_PAGE}${cursor ? `&cursor=${cursor}` : ''}`, {\n headers: { authorization: `Bearer ${readKey}` },\n });\n if (!r.ok) return fail(502, 'audit_unavailable', { status: r.status });\n const body = ((await r.json()) as { data?: { events?: AuditRow[]; next_cursor?: string | null } }).data;\n const rows = body?.events ?? [];\n scanned += rows.length;\n for (const row of rows) {\n const pl = row.payload ?? {};\n if (pl.collection !== collection || pl.item_id !== itemId) continue;\n const inc = pl.inc && typeof pl.inc === 'object' ? Object.keys(pl.inc as object) : null;\n events.push({ id: String(row.id), event: row.event, actor: row.actor, at: row.created_at, fields: inc });\n }\n cursor = body?.next_cursor ?? null;\n if (!cursor) break;\n }\n\n return Response.json({ subject: `${collection}:${itemId}`, requests, events, next_cursor: cursor, scanned, truncated: cursor !== null });\n },\n};\n",
19191
19286
  "console-api.ts": "// console-api.ts \u2014 EVERY STAFF READ, PLUS INTERNAL NOTES (a vxil function, http, end-user mode).\n//\n// POST /v1/fn/console-api { op, ... }\n//\n// op needs does\n// search accounts.read { q?, by?: 'email'|'external_ref'|'name', status?, plan?, sort?, cursor?, limit? }\n// account accounts.read { id } \u2192 one account\n// requests accounts.read { state?, account_id?, mine?, cursor?, limit? } \u2192 change requests, newest first\n// policies accounts.read {} \u2192 the approval policy rows\n// notes accounts.read { account_id, cursor? } \u2192 the account's internal notes\n// add_note notes.write { account_id, body } \u2192 one note, authored as the caller\n//\n// WHY ONE FUNCTION AND NOT A BROWSER KEY WITH cms:read. Sign-in is open to\n// everyone your identity provider lets through `allowedDomains`: the whole\n// company domain, not only the staff org. A browser key holding `cms:read` or\n// `comments:read` would let ANY of those people list every customer account\n// (e-mails included) and read or write internal notes, because collection\n// reads are not checked against the staff org. So the console's browser key\n// holds only `functions:invoke`, and every read comes through here, where the\n// orgs permission check runs first (revocation-grade: a removed role is\n// refused on the next call). A signed-in person outside the staff org gets\n// 403 on every op.\n//\n// The reads run with the CALLER's session, so field-level gates hold here too:\n// `risk_flags` reaches console-admins only, `billing_contact` approvers and\n// console-admins only.\n//\n// Lookups are index-served: e-mail = the unique `email` slot (exact); your\n// product's id = the indexed `external_ref` (exact); name = a `$startsWith`\n// prefix on the `name` slot (case-sensitive). With no `by`, a `q` holding an\n// '@' is an e-mail, otherwise a name prefix.\n//\n// This is also the seam for the day accounts move to YOUR database: keep the\n// request/response shape of `search` and `account` and swap their bodies for\n// one HTTPS call to your SQL endpoint (see examples/byo-db \u2014 a function + a\n// secret + one egress host). The console does not change.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_LIMIT = 100;\nconst MAX_NOTE = 4000;\nconst READ_OPS = new Set(['search', 'account', 'requests', 'policies', 'notes']);\n/** Sortable account columns: each one is on an index slot. A leading '-' sorts descending. */\nconst SORTS = new Set(['name', 'email', 'plan', 'status', 'credits', 'seats', 'created_at', 'last_active_at']);\nconst STATES = new Set(['pending', 'approved', 'rejected', 'expired', 'applied', 'failed']);\n\ninterface Payload {\n op?: unknown; q?: unknown; by?: unknown; status?: unknown; plan?: unknown; sort?: unknown; cursor?: unknown; limit?: unknown;\n id?: unknown; state?: unknown; account_id?: unknown; mine?: unknown; body?: unknown;\n}\ntype Env = HttpFunctionEnvelope<Payload>;\ntype Item = { item_id: string; data: Record<string, unknown> };\n\nconst hdrs = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst fail = (status: number, error: string, extra: Record<string, unknown> = {}) =>\n Response.json({ error, ...extra }, { status });\nconst str = (v: unknown, max = 200): string | null =>\n typeof v === 'string' && v.trim() && v.length <= max ? v.trim() : null;\nconst itemId = (v: unknown): string | null =>\n typeof v === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : null;\n\nasync function requirePermission(base: string, orgs: string, userId: string, permission: string): Promise<string | Response> {\n const c = await fetch(`${base}/v1/orgs/session-claims?user_id=${encodeURIComponent(userId)}`, { headers: hdrs(orgs) });\n if (!c.ok) return fail(502, 'orgs_unavailable', { status: c.status });\n const orgId = ((await c.json()) as { data?: { org_id?: string | null } }).data?.org_id;\n if (!orgId) return fail(403, 'forbidden', { permission, message: 'you are not a member of the staff organization' });\n const r = await fetch(\n `${base}/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(userId)}&permission=${encodeURIComponent(permission)}`,\n { headers: hdrs(orgs) },\n );\n if (!r.ok) return fail(502, 'orgs_unavailable', { status: r.status });\n const allowed = ((await r.json()) as { data?: { allowed?: boolean } }).data?.allowed === true;\n return allowed ? orgId : fail(403, 'forbidden', { permission, message: `you need the '${permission}' permission` });\n}\n\n/** One cms list call, answered as { items, next_cursor }. */\nasync function list(base: string, cms: string, collection: string, params: string[]): Promise<Response> {\n const r = await fetch(`${base}/v1/cms/items/${collection}?${params.filter(Boolean).join('&')}`, { headers: hdrs(cms) });\n if (!r.ok) return fail(r.status === 422 ? 422 : 502, 'read_failed', { detail: (await r.text()).slice(0, 300) });\n const page = ((await r.json()) as { data?: { items?: Item[]; next_cursor?: string | null } }).data;\n return Response.json({\n items: (page?.items ?? []).map((i) => ({ item_id: i.item_id, data: i.data })),\n next_cursor: page?.next_cursor ?? null,\n });\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const { cms, orgs, comments } = env.scoped_jwts ?? {};\n const me = env.end_user?.id;\n if (!me) return fail(401, 'sign_in_required', { message: 'call this with a signed-in staff session' });\n if (!cms || !orgs || !comments) return fail(500, 'missing_scopes');\n\n const p = env.payload ?? {};\n const op = typeof p.op === 'string' ? p.op : 'search';\n if (!READ_OPS.has(op) && op !== 'add_note') {\n return fail(422, 'invalid_payload', { message: \"op must be one of search, account, requests, policies, notes, add_note\" });\n }\n const limit = Math.min(Math.max(Number.isInteger(p.limit) ? (p.limit as number) : 25, 1), MAX_LIMIT);\n const cursor = typeof p.cursor === 'string' && p.cursor ? `cursor=${encodeURIComponent(p.cursor)}` : '';\n\n // \u2500\u2500 the permission check comes before ANY read \u2500\u2500\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 const orgId = await requirePermission(base, orgs, me, op === 'add_note' ? 'notes.write' : 'accounts.read');\n if (orgId instanceof Response) return orgId;\n\n switch (op) {\n case 'search': {\n const q = str(p.q);\n const by = p.by ?? (q?.includes('@') ? 'email' : 'name');\n if (by !== 'email' && by !== 'external_ref' && by !== 'name') {\n return fail(422, 'invalid_payload', { message: \"by must be 'email', 'external_ref' or 'name'\" });\n }\n const filter: Record<string, unknown> = {};\n if (q) {\n if (by === 'email') filter.email = q;\n else if (by === 'external_ref') filter.external_ref = q;\n else filter.name = { $startsWith: q };\n }\n const status = str(p.status, 32);\n const plan = str(p.plan, 64);\n if (status) filter.status = status;\n if (plan) filter.plan = plan;\n let sort = '';\n if (p.sort !== undefined) {\n if (typeof p.sort !== 'string' || !SORTS.has(p.sort.replace(/^-/, ''))) {\n return fail(422, 'invalid_payload', { message: `sort must be one of ${[...SORTS].join(', ')} (with an optional leading '-')` });\n }\n sort = `sort=${encodeURIComponent(p.sort)}`;\n }\n return list(base, cms, 'accounts', [Object.keys(filter).length ? `filter=${enc(filter)}` : '', sort, `limit=${limit}`, cursor]);\n }\n\n case 'account': {\n const id = itemId(p.id);\n if (!id) return fail(422, 'invalid_payload', { message: 'id must be an account id' });\n const r = await fetch(`${base}/v1/cms/items/accounts/${id}`, { headers: hdrs(cms) });\n if (r.status === 404) return fail(404, 'account_not_found');\n if (!r.ok) return fail(502, 'read_failed', { status: r.status });\n const i = ((await r.json()) as { data?: Item }).data;\n return Response.json({ item_id: i?.item_id ?? id, data: i?.data ?? {} });\n }\n\n case 'requests': {\n const filter: Record<string, unknown> = {};\n if (p.state !== undefined) {\n if (typeof p.state !== 'string' || !STATES.has(p.state)) return fail(422, 'invalid_payload', { message: 'state is not a request state' });\n filter.state = p.state;\n }\n if (p.account_id !== undefined) {\n const a = itemId(p.account_id);\n if (!a) return fail(422, 'invalid_payload', { message: 'account_id must be an account id' });\n filter.account_ref = a;\n }\n if (p.mine === true) filter.requested_by = me;\n return list(base, cms, 'change_requests', [\n Object.keys(filter).length ? `filter=${enc(filter)}` : '', 'sort=-requested_at', `limit=${limit}`, cursor,\n ]);\n }\n\n case 'policies':\n return list(base, cms, 'approval_policies', ['limit=100']);\n\n case 'notes':\n case 'add_note': {\n const accountId = itemId(p.account_id);\n if (!accountId) return fail(422, 'invalid_payload', { message: 'account_id must be an account id' });\n const topic = `accounts:${accountId}`;\n if (op === 'notes') {\n const r = await fetch(`${base}/v1/comments?topic=${encodeURIComponent(topic)}&limit=${limit}${cursor ? `&${cursor}` : ''}`, { headers: hdrs(comments) });\n if (!r.ok) return fail(502, 'read_failed', { status: r.status });\n const page = ((await r.json()) as { data?: { comments?: unknown[]; next_cursor?: string | null } }).data;\n return Response.json({ notes: page?.comments ?? [], next_cursor: page?.next_cursor ?? null });\n }\n const body = typeof p.body === 'string' ? p.body.trim() : '';\n if (!body || body.length > MAX_NOTE) return fail(422, 'invalid_payload', { message: `body is required (\u2264${MAX_NOTE} chars)` });\n // a note on an account that does not exist is refused, not orphaned\n const a = await fetch(`${base}/v1/cms/items/accounts/${accountId}`, { headers: hdrs(cms) });\n if (a.status === 404) return fail(404, 'account_not_found');\n if (!a.ok) return fail(502, 'read_failed', { status: a.status });\n // the author is the verified caller: a signed-in session cannot write as anyone else\n const w = await fetch(`${base}/v1/comments`, {\n method: 'POST',\n headers: hdrs(comments),\n body: JSON.stringify({ topic, body, author_id: me }),\n });\n if (!w.ok) return fail(w.status === 422 ? 422 : 502, 'note_failed', { status: w.status });\n const note = ((await w.json()) as { data?: unknown }).data;\n return Response.json({ note }, { status: 201 });\n }\n }\n return fail(422, 'invalid_payload');\n },\n};\n",
@@ -19217,7 +19312,7 @@ export default defineConfig({
19217
19312
  "hasFunctions": true,
19218
19313
  "byoKeys": [],
19219
19314
  "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",
19220
- "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 --env staging --no-push # or `vxil link <slug> --env staging` for an existing backend\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hooks + the three functions + the declared API state\nvxil gen # typed SDK: vx.from('funnel_sessions'), vx.fn['start-brief']\n```\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**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`vx.auth.sessions.ensureFresh(pair)` rotates it a few minutes before it expires (`POST /v1/auth/sessions/refresh`\nunderneath). So a server-rendered app needs one root middleware, not a redesign. The full recipe, with its\n`refreshFailure()` classifier and the `markRefreshFailed()` marker, is in\nvxil.com/docs/guide/09-security-and-multitenancy (\"Keeping the end-user token fresh behind a long-lived cookie\"):\n\n```ts\n// once per request, before any loader runs\nlet asUser = null;\nconst pair = readSessionCookie(request); // { token, refresh_token, expires_at }\nif (pair) {\n try {\n const { session, refreshed } = await vx.auth.sessions.ensureFresh(pair);\n if (refreshed) setSessionCookie(response, session); // rotated: store BOTH halves again\n asUser = vx.asEndUser(session.token); // every per-visitor read below is scoped by the platform\n } catch (e) {\n // NOT \"sign in again\" on any failure \u2014 see below. refreshFailure() is guide 09's classifier.\n const failure = refreshFailure(e, pair, readCookie(request, 'vx_refresh_failed'));\n if (failure === 'dead') clearSessionCookie(response); // revoked or expired: sign in again\n if (failure === 'race') markRefreshFailed(response, pair); // keep the cookie: the winner's is on its way\n // 'transient': keep the cookie; this one request runs signed out\n }\n}\n```\n\n**A failed refresh is not always a dead session.** Two requests carrying the same pair can reach two server\ninstances: one rotates, the other re-sends the refresh token that was revoked a moment ago and gets\n`401 invalid_refresh` \u2014 the same answer a truly revoked or expired token gets. Clearing the cookie on *any*\nfailure lets that loser's response overwrite the winner's new cookie, and a signed-in visitor is signed out for\nnothing. So the classifier keeps the cookie for anything that is not `invalid_refresh` (a network failure, a\ntimeout, a `429` or `5xx`), treats the first `invalid_refresh` of a pair as a lost race (a short-lived marker names\nthe pair), and clears the cookie only when the *same* pair fails again after a 15-second grace, or the cookie holds\nno refresh token at all.\n\nStore the refresh token only in the `HttpOnly` cookie; refresh *early* rather than on `401`, so parallel\nloaders in one process share one rotation; 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",
19315
+ "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\nmkdir my-funnel && cd my-funnel\nvxil init --template funnel-saas\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hooks + the three functions + the declared API state;\n # also writes the typed SDK: vx.from('funnel_sessions'), vx.fn['start-brief']\n```\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\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**: `grant-pass` and\n `settle-brief` declare no `retry` on their triggers, so the dispatcher ACKs each delivery with a `200`\n *whatever the function answered*, and a `5xx` from the API is never re-delivered. (That is this\n blueprint's choice, not a platform rule: add `retry: { maxAttempts: n }` to a webhook, queue or\n cmsHook trigger and a non-2xx answer *is* tried again \u2014 the fan-in blueprint does. Here a retry would\n only repeat a read the next request makes anyway, so the row, not the delivery, carries recovery.) It \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 (with no `retry` declared, nothing re-delivers it) but to be *seen* through\n `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`vx.auth.sessions.ensureFresh(pair)` rotates it a few minutes before it expires (`POST /v1/auth/sessions/refresh`\nunderneath). So a server-rendered app needs one root middleware, not a redesign. The full recipe, with its\n`refreshFailure()` classifier and the `markRefreshFailed()` marker, is in\nvxil.com/docs/guide/09-security-and-multitenancy (\"Keeping the end-user token fresh behind a long-lived cookie\"):\n\n```ts\n// once per request, before any loader runs\nlet asUser = null;\nconst pair = readSessionCookie(request); // { token, refresh_token, expires_at }\nif (pair) {\n try {\n const { session, refreshed } = await vx.auth.sessions.ensureFresh(pair);\n if (refreshed) setSessionCookie(response, session); // rotated: store BOTH halves again\n asUser = vx.asEndUser(session.token); // every per-visitor read below is scoped by the platform\n } catch (e) {\n // NOT \"sign in again\" on any failure \u2014 see below. refreshFailure() is guide 09's classifier.\n const failure = refreshFailure(e, pair, readCookie(request, 'vx_refresh_failed'));\n if (failure === 'dead') clearSessionCookie(response); // revoked or expired: sign in again\n if (failure === 'race') markRefreshFailed(response, pair); // keep the cookie: the winner's is on its way\n // 'transient': keep the cookie; this one request runs signed out\n }\n}\n```\n\n**A failed refresh is not always a dead session.** Two requests carrying the same pair can reach two server\ninstances: one rotates, the other re-sends the refresh token that was revoked a moment ago and gets\n`401 invalid_refresh` \u2014 the same answer a truly revoked or expired token gets. Clearing the cookie on *any*\nfailure lets that loser's response overwrite the winner's new cookie, and a signed-in visitor is signed out for\nnothing. So the classifier keeps the cookie for anything that is not `invalid_refresh` (a network failure, a\ntimeout, a `429` or `5xx`), treats the first `invalid_refresh` of a pair as a lost race (a short-lived marker names\nthe pair), and clears the cookie only when the *same* pair fails again after a 15-second grace, or the cookie holds\nno refresh token at all.\n\nStore the refresh token only in the `HttpOnly` cookie; refresh *early* rather than on `401`, so parallel\nloaders in one process share one rotation; 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, **by name** \u2014 the name is unique\nper project and survives a delete-and-recreate, so there is no id to look up at startup:\n\n```ts\n// before forwarding a step write\nawait vx.rateLimits.check({ policy: 'funnel-step', 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: 'claim-request', key_values: { email_hash: sha256(email) } });\n```\n\nA policy created or renamed in the last minute can briefly answer `404` by name; after that first\nminute (and after any later push), the name resolves like an id.\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",
19221
19316
  "functions": {
19222
19317
  "grant-pass.ts": "// grant-pass.ts \u2014 GRANT A PASS ON A SUCCESSFUL CHARGE (a vxil function).\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",
19223
19318
  "settle-brief.ts": "// settle-brief.ts \u2014 SETTLE THE BRIEF ONTO ITS ROW (a vxil function).\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",
@@ -19240,7 +19335,7 @@ export default defineConfig({
19240
19335
  "hasFunctions": false,
19241
19336
  "byoKeys": [],
19242
19337
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Leaderboard\" \u2014 game scores + live rankings, declared end-to-end in ONE typed\n// file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 players + scores. THE POINT: the leaderboards themselves are\n// never stored \u2014 `POST /v1/cms/items/scores/rank` and\n// `\u2026/aggregate` are bounded, config-free read-models over the\n// raw score rows (vxil.com/docs/api: the aggregate route), and\n// `?count=true` gives an exact \"entries ahead of me\" (guide ch. 4, querying).\n// \u2022 rate-limits \u2192 the abuse guard for PUBLIC score submission. Policies are\n// data on the feature's own REST surface (POST\n// /v1/rate-limits/policies + \u2026/check), not config leaves \u2014\n// see the README for the exact bodies.\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {}, // defaults suffice \u2014 rankings are ad-hoc bounded reads, not config\n 'rate-limits': {}, // enabled with defaults; the submission policy is REST data\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n players: {\n singular: 'player',\n fields: {\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n country: { type: 'string', indexSlot: 's3' },\n },\n },\n // One row per RUN. Per-player \"best\" is derived at read time by\n // rank/aggregate (`max(score)` grouped by `player`) \u2014 nothing to keep\n // consistent on write.\n scores: {\n singular: 'score',\n fields: {\n // The ranked entity: rank/aggregate group by this field's s1 slot,\n // i.e. by the STORED value (for real submissions, the player item_id).\n player: { type: 'relation', relationTo: 'players', indexSlot: 's1' },\n game_mode: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['classic', 'blitz'] } },\n // n1 slot \u21D2 max/avg aggregates + `$gt` range (\"players ahead of me\").\n score: { type: 'int', required: true, indexSlot: 'n1', validation: { min: 0 } },\n // t1 slot \u21D2 `window: { field: 'achieved_at', sinceDays: 7 }` = weekly boards.\n achieved_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n // `vxil seed` POSTs these verbatim, in order \u2014 it cannot capture the\n // item_ids the player rows are assigned. The demo scores therefore carry\n // the players' stable HANDLES as the relation value (type-valid: a\n // relation stores an opaque string, and grouping ranks by the stored\n // value either way). Real submissions should store the item_id that\n // `create` returns \u2014 that is what makes `$expand` and dotted join\n // filters (e.g. `player.country`) resolve.\n cms: [\n {\n collection: 'players',\n items: [\n { handle: 'nova', display_name: 'Nova', country: 'JO' },\n { handle: 'rex', display_name: 'Rex', country: 'DE' },\n { handle: 'zed', display_name: 'Zed', country: 'JP' },\n ],\n },\n {\n collection: 'scores',\n items: [\n { player: 'nova', game_mode: 'classic', score: 12500, achieved_at: '2026-07-01T10:00:00Z' },\n { player: 'zed', game_mode: 'classic', score: 11200, achieved_at: '2026-07-02T09:15:00Z' },\n { player: 'rex', game_mode: 'classic', score: 9800, achieved_at: '2026-07-03T11:30:00Z' },\n { player: 'nova', game_mode: 'blitz', score: 4200, achieved_at: '2026-07-03T18:00:00Z' },\n { player: 'rex', game_mode: 'blitz', score: 5100, achieved_at: '2026-07-04T20:45:00Z' },\n ],\n },\n ],\n },\n});\n",
19243
- "readme": '# Game Leaderboard template\n\nGame scores + live rankings in one typed `vxil.config.ts`. The leaderboards themselves are **never stored** \u2014\ntop-N, per-mode stats, and "players ahead of me" are all bounded reads over the raw score rows, so there are no\ncounters, crons, or denormalized tables to keep consistent.\n\n**Provisions:**\n- `players` \u2014 unique `handle`, `display_name`, `country`.\n- `scores` \u2014 `player` relation (the ranked entity), `game_mode` (enum), `score` (int, `min: 0`), `achieved_at`.\n One row per run; per-player "best" is derived at read time.\n- `rate-limits` \u2014 enabled as the abuse guard for public score submission (the policy is REST data, see below).\n\n**Apply it:**\n\n```bash\nvxil init --template leaderboard\nvxil quickstart # or: vxil link\nvxil push\nvxil seed # 3 players + 5 demo scores across 2 game modes\nvxil gen\n```\n\n**What to learn from this:**\n1. **Config-free leaderboards.** `POST /v1/cms/items/scores/rank` and `\u2026/aggregate` are bounded read-models\n (\u2264500 groups over a \u226450k-row scan; an over-wide scan fails `422 window_too_large` instead of silently\n truncating \u2014 vxil.com/docs/api, the aggregate route). `window: { field: \'achieved_at\', sinceDays: 7 }` = weekly boards.\n2. **`?count=true` is uncapped.** List pages clamp at the page-size limit, but a count is one\n `SELECT count(*)` under the same compiled filter (guide ch. 4, querying) \u2014 an exact "entries ahead of me" even at\n position 40,000. It counts score *rows*; for strictly "players ahead" keep one row per (player, mode)\n (patch on a new best), or read the rank endpoint (within its documented bounds).\n3. **rate-limits as the abuse guard.** Public score submission is the classic spam target: create a named\n policy once, then check per player before accepting a run (vxil.com/docs/guide/06-feature-catalog: rate-limits).\n\n**Top-10 by best score per player in a mode** (`VXIL_BASE` = your edge, e.g. `https://api.vxil.com`):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/cms/items/scores/rank" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"groupBy":"player","metric":{"fn":"max","field":"score"},\n "rank":"rank","direction":"desc","filter":{"game_mode":"classic"},"limit":10}\'\n# \u2192 { "data": { "entries": [ { "key": { "player": "nova" }, "metric": 12500, "rank": 1 }, \u2026 ], "scanned": 3 } }\n```\n\n**Per-mode stats + a player\'s position** (typed SDK, after `vxil gen`):\n\n```ts\nconst { groups } = await vx.from(\'scores\').aggregate({\n aggregates: [{ fn: \'count\', as: \'runs\' }, { fn: \'max\', field: \'score\', as: \'top\' }],\n groupBy: [\'game_mode\'],\n});\nconst ahead = await vx.from(\'scores\').count({ game_mode: \'classic\', score: { $gt: 11200 } });\n// position = ahead + 1\n```\n\n**The submission guard** (once, from your server):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/rate-limits/policies" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"name":"scores.submit","key_template":"{player_id}","limit":10,"window_seconds":60,"behavior":"block"}\'\n# per run: POST /v1/rate-limits/check {"policy_id":"rl_\u2026","key_values":{"player_id":"<player item_id>"},"cost":1} \u2192 429 when exhausted\n```\n\n> The demo seed stores player *handles* as the `scores.player` relation value (a seed can\'t know generated\n> item_ids); real submissions should store the item_id `create` returns so `$expand`/dotted joins resolve.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/api (count, aggregate/rank) \xB7\nvxil.com/docs/guide/06-feature-catalog (rate-limits: policies, checks, per-identifier overrides) \xB7\n`examples/ecommerce/` (a bigger cms composition, with functions).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
19338
+ "readme": '# Game Leaderboard template\n\nGame scores + live rankings in one typed `vxil.config.ts`. The leaderboards themselves are **never stored** \u2014\ntop-N, per-mode stats, and "players ahead of me" are all bounded reads over the raw score rows, so there are no\ncounters, crons, or denormalized tables to keep consistent.\n\n**Provisions:**\n- `players` \u2014 unique `handle`, `display_name`, `country`.\n- `scores` \u2014 `player` relation (the ranked entity), `game_mode` (enum), `score` (int, `min: 0`), `achieved_at`.\n One row per run; per-player "best" is derived at read time.\n- `rate-limits` \u2014 enabled as the abuse guard for public score submission (declare the policy in the\n config, below).\n\n**Apply it:**\n\n```bash\nmkdir my-leaderboard && cd my-leaderboard\nvxil init --template leaderboard\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\n # (or `vxil link <slug> --env staging` + `vxil push` for an existing backend)\nvxil seed # 3 players + 5 demo scores across 2 game modes\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_API_KEY + VXIL_BASE_URL for the curl examples below\n```\n\nAfter you edit `vxil.config.ts`, `vxil push` applies it and regenerates the types.\n\n**What to learn from this:**\n1. **Config-free leaderboards.** `POST /v1/cms/items/scores/rank` and `\u2026/aggregate` are bounded read-models\n (\u2264500 groups over a \u226450k-row scan; an over-wide scan fails `422 window_too_large` instead of silently\n truncating \u2014 vxil.com/docs/api, the aggregate route). `window: { field: \'achieved_at\', sinceDays: 7 }` = weekly boards.\n2. **`?count=true` is uncapped.** List pages clamp at the page-size limit, but a count is one\n counting query under the same compiled filter (guide ch. 4, querying) \u2014 an exact "entries ahead of me" even at\n position 40,000. It counts score *rows*; for strictly "players ahead" keep one row per (player, mode)\n (patch on a new best), or read the rank endpoint (within its documented bounds).\n3. **rate-limits as the abuse guard.** Public score submission is the classic spam target: declare a\n named policy in the config, then check per player, by name, before accepting a run\n (vxil.com/docs/guide/06-feature-catalog: rate-limits).\n\n**Top-10 by best score per player in a mode** (`VXIL_BASE_URL` = your API base, `https://api.vxil.com`):\n\n```bash\ncurl -sX POST "$VXIL_BASE_URL/v1/cms/items/scores/rank" \\\n -H "Authorization: Bearer $VXIL_API_KEY" -H "content-type: application/json" \\\n -d \'{"groupBy":"player","metric":{"fn":"max","field":"score"},\n "rank":"rank","direction":"desc","filter":{"game_mode":"classic"},"limit":10}\'\n# \u2192 { "data": { "entries": [ { "key": { "player": "nova" }, "metric": 12500, "rank": 1 }, \u2026 ], "scanned": 3 } }\n```\n\n**Per-mode stats + a player\'s position** (typed SDK, after `vxil gen`):\n\n```ts\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! })\nconst { groups } = await vx.from(\'scores\').aggregate({\n aggregates: [{ fn: \'count\', as: \'runs\' }, { fn: \'max\', field: \'score\', as: \'top\' }],\n groupBy: [\'game_mode\'],\n});\nconst ahead = await vx.from(\'scores\').count({ game_mode: \'classic\', score: { $gt: 11200 } });\n// position = ahead + 1\n```\n\n**The submission guard.** Declare the policy in `vxil.config.ts`, replacing the empty `\'rate-limits\': {}`;\n`vxil push` creates it, patches it when you change it, and reports a live policy you did not declare\n(declared API state \u2014 the funnel-saas blueprint has the whole table):\n\n```ts\n\'rate-limits\': {\n enabled: true,\n policies: [\n { name: \'scores.submit\', key_template: \'{player_id}\', limit: 10, window_seconds: 60, behavior: \'block\' },\n ],\n},\n```\n\nThen check it **by name** from your server before you accept each run:\n\n```bash\ncurl -sX POST "$VXIL_BASE_URL/v1/rate-limits/check" \\\n -H "Authorization: Bearer $VXIL_API_KEY" -H "content-type: application/json" \\\n -d \'{"policy":"scores.submit","key_values":{"player_id":"<player item_id>"},"cost":1}\'\n# \u2192 { "data": { "allowed": true, "remaining": 9, "reset_seconds": \u2026 } } \u2014 the eleventh run in a minute answers 429\n```\n\n(`await vx.rateLimits.check({ policy: \'scores.submit\', key_values: { player_id }, cost: 1 })` in the SDK.)\nA policy created or renamed in the last minute can briefly answer `404` by name. Creating it over\n`POST /v1/rate-limits/policies` still works, as a row you manage by hand; per-player overrides stay rows.\n\n> The demo seed stores player *handles* as the `scores.player` relation value (a seed can\'t know generated\n> item_ids); real submissions should store the item_id `create` returns so `$expand`/dotted joins resolve.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/api (count, aggregate/rank) \xB7\nvxil.com/docs/guide/06-feature-catalog (rate-limits: policies, checks, per-identifier overrides) \xB7\nthe **storefront** blueprint (a bigger cms composition, with functions).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
19244
19339
  "functions": {}
19245
19340
  },
19246
19341
  {
@@ -19261,7 +19356,7 @@ export default defineConfig({
19261
19356
  "hasFunctions": true,
19262
19357
  "byoKeys": [],
19263
19358
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"IoT Fleet\" \u2014 device fleet telemetry + alerting, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 devices \u2192 readings / alerts (resolved by relation)\n// \u2022 functions \u2192 the typed HTTP ingest endpoint + the daily aggregate rollup\n// \u2022 webhooks \u2192 fan alert writes out to the tenant's ops endpoint (outbound\n// subscriptions over the audit/event stream (guide ch. 6, webhooks))\n// Cross-row writes (create the reading + heartbeat the device + raise a\n// threshold alert) are exactly why ingest is a FUNCTION, not a Lane-A hook \u2014\n// hooks are pure single-row expressions. Everything here is DATA the tenant\n// owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // Every reading needs a metric name \u2014 a pure function of the row (Lane-A validate).\n reading_metric: {\n collection: 'readings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.metric) > 0',\n message: 'a reading needs a metric name',\n },\n },\n // The DECLARATIVE rollup alternative to functions/daily-rollup.ts: a\n // `readModels` config entry (guide ch. 4, read models) can materialize the same\n // per-device aggregate on cron into a dedicated rollup collection. This\n // blueprint keeps the imperative function so you can read the endpoint\n // it rides on (POST /v1/cms/items/readings/aggregate, guide ch. 4, aggregates).\n },\n\n // Outbound subscriptions are RUNTIME rows, not config keys:\n // POST /v1/webhooks/subscriptions { target_url, event_prefixes: ['cms.item.'] }\n // fans every cms write (including new alert rows) to your ops endpoint as a\n // signed jobs callback \u2014 verify X-Vxil-Jobs-Signature, filter for alerts.\n webhooks: {},\n\n functions: { enabled: true }, // the functions crossing: opt-in, egress-guarded\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n devices: {\n singular: 'device',\n fields: {\n serial: { type: 'string', required: true, indexSlot: 's1', unique: true },\n model: { type: 'string', indexSlot: 's2' },\n site: { type: 'string', indexSlot: 's3' },\n status: { type: 'string', indexSlot: 's4', validation: { enum: ['active', 'maintenance', 'retired'] } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // heartbeat, PATCHed by ingest-reading\n battery_pct: { type: 'int', indexSlot: 'n1', validation: { min: 0, max: 100 } },\n },\n },\n readings: {\n singular: 'reading',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n metric: { type: 'string', required: true, indexSlot: 's2' }, // e.g. battery_pct, temp_c\n value: { type: 'float', required: true, indexSlot: 'n1' }, // n-slot \u2192 min/max/avg aggregates\n recorded_at: { type: 'datetime', indexSlot: 't1' }, // t-slot \u2192 the 24h aggregate window\n },\n },\n alerts: {\n singular: 'alert',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n kind: { type: 'string', required: true, indexSlot: 's2' }, // low_battery | daily_rollup | \u2026\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['info', 'warning', 'critical'] } },\n raised_at: { type: 'datetime', indexSlot: 't1' },\n note: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (guide ch. 8) \u2500\u2500\n functions: {\n // The typed device endpoint: POST /v1/fn/ingest-reading { device_id, metric, value }\n // \u2192 create the reading + heartbeat the device + threshold-alert, in one call.\n // The alert write is deduped with a `lock` + `guard` WRITE BODY (guide ch. 4, preconditions and concurrency):\n // at most ONE live low_battery alert per device \u2014 never a config key.\n 'ingest-reading': {\n entry: './functions/ingest-reading.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // The daily rollup: ONE bounded aggregate call (per-device min/max/avg\n // over the last 24h of battery_pct readings) \u2192 one info summary row per device\n // per UTC day (lock+guard-deduped \u2014 cron delivery is at-least-once).\n 'daily-rollup': {\n entry: './functions/daily-rollup.ts',\n trigger: { kind: 'cron', schedule: '0 6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'devices',\n items: [\n { serial: 'SN-0001', model: 'env-sensor-2', site: 'plant-a', status: 'active', battery_pct: 87 },\n { serial: 'SN-0002', model: 'env-sensor-2', site: 'plant-b', status: 'active', battery_pct: 42 },\n ],\n },\n ],\n },\n});\n",
19264
- "readme": '# IoT Fleet / Telemetry template\n\nA device-fleet backend \u2014 devices, telemetry readings, and alerts \u2014 declared end-to-end in one typed\n`vxil.config.ts`, with the domain logic that isn\'t config (ingest, thresholds, rollups) as two tenant\nfunctions.\n\n**What it provisions:**\n- `devices` \u2014 unique serial, model, site, lifecycle status, `last_seen` heartbeat, `battery_pct`.\n- `readings` \u2014 `device` relation, metric name, float `value` (n-slot \u2192 aggregates), `recorded_at` (t-slot \u2192 windows).\n- `alerts` \u2014 `device` relation, kind, severity, `raised_at`, free-text note.\n- Features: `cms` (+ a Lane-A validate hook), `webhooks` (outbound fan-out), `functions`\n (`ingest-reading` on http, `daily-rollup` on a daily cron).\n\n**Apply it:**\n\n```bash\nvxil init --template iot-fleet\nvxil quickstart # or `vxil link` an existing tenant\nvxil push\nvxil gen\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n- **HTTP ingest functions as typed device endpoints** \u2014 `functions/ingest-reading.ts` turns one\n `POST /v1/fn/ingest-reading` (the key needs the `functions:invoke` scope) into three cross-row\n writes: create the reading, PATCH the device heartbeat, raise a threshold alert. Cross-row work\n is exactly why this is a function, not a hook.\n- **Guard-deduped threshold alerting** \u2014 the alert POST carries `lock` + `guard` in the WRITE BODY\n (vxil.com/docs/api: the item write routes): at most one live `low_battery` alert per device, race-safe. Deleting\n the alert row resolves it and frees the guard.\n- **Bounded aggregates for rollups** \u2014 `functions/daily-rollup.ts` calls\n `POST /v1/cms/items/readings/aggregate` (guide ch. 4, aggregates: \u2264500 groups over a \u226450k scan) for per-device\n daily min/max/avg. Cron delivery is at-least-once, so each summary write is itself lock+guard-deduped\n to one per (device, day); the declarative sibling is a `readModels` config entry (guide ch. 4, read models).\n- **Webhook fan-out of alerts** \u2014 `POST /v1/webhooks/subscriptions` with\n `{ "target_url": "https://ops.example.com/hook", "event_prefixes": ["cms.item."] }` delivers every\n cms write (including new alerts) as a signed jobs callback \u2014 verify `X-Vxil-Jobs-Signature`.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ingest-reading \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"device_id":"itm_...","metric":"battery_pct","value":12}\'\n```\n\nTyped SDK query for the ops dashboard: `vx.from(\'devices\').query({ filter: { battery_pct: { $lt: 20 } }, sort: \'-last_seen\' })`.\n\n**Go deeper:** vxil.com/docs/api (lock/guard, aggregates, read-models),\nvxil.com/docs/guide/08-running-your-code-functions, vxil.com/docs/guide/06-feature-catalog (webhooks), and `examples/ecommerce/` for a larger\nfunction saga. The config is yours after `init` \u2014 nothing is locked.\n',
19359
+ "readme": '# IoT Fleet / Telemetry template\n\nA device-fleet backend \u2014 devices, telemetry readings, and alerts \u2014 declared end-to-end in one typed\n`vxil.config.ts`, with the domain logic that isn\'t config (ingest, thresholds, rollups) as two tenant\nfunctions.\n\n**What it provisions:**\n- `devices` \u2014 unique serial, model, site, lifecycle status, `last_seen` heartbeat, `battery_pct`.\n- `readings` \u2014 `device` relation, metric name, float `value` (n-slot \u2192 aggregates), `recorded_at` (t-slot \u2192 windows).\n- `alerts` \u2014 `device` relation, kind, severity, `raised_at`, free-text note.\n- Features: `cms` (+ a Lane-A validate hook), `webhooks` (outbound fan-out), `functions`\n (`ingest-reading` on http, `daily-rollup` on a daily cron).\n\n**Apply it:**\n\n```bash\nmkdir my-fleet && cd my-fleet\nvxil init --template iot-fleet\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hook + both functions; writes vxil.types.ts\nvxil seed # two devices, SN-0001 (battery 87) and SN-0002 (battery 42)\n```\n\n**See it work.** Send a low battery reading for a seeded device and watch it raise exactly one alert:\n\n```bash\nDEV=$(vxil api GET \'/v1/cms/items/devices?filter={"serial":"SN-0002"}\' | jq -r \'.items[0].item_id\')\nvxil functions invoke ingest-reading --data \'{"device_id":"\'$DEV\'","metric":"battery_pct","value":12}\'\n# \u2192 { "reading_id": "itm_\u2026", "alert_id": "itm_\u2026" } (below 15 % raises a low_battery alert)\nvxil functions invoke ingest-reading --data \'{"device_id":"\'$DEV\'","metric":"battery_pct","value":11}\'\n# \u2192 { "reading_id": "itm_\u2026" } (no alert_id: an open alert already exists \u2014 the guard held)\nvxil api GET /v1/cms/items/alerts # one low_battery alert\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n- **HTTP ingest functions as typed device endpoints** \u2014 `functions/ingest-reading.ts` turns one\n `POST /v1/fn/ingest-reading` (the key needs the `functions:invoke` scope) into three cross-row\n writes: create the reading, PATCH the device heartbeat, raise a threshold alert. Cross-row work\n is exactly why this is a function, not a hook.\n- **Guard-deduped threshold alerting** \u2014 the alert POST carries `lock` + `guard` in the WRITE BODY\n (vxil.com/docs/api: the item write routes): at most one live `low_battery` alert per device, race-safe. Deleting\n the alert row resolves it and frees the guard.\n- **Bounded aggregates for rollups** \u2014 `functions/daily-rollup.ts` calls\n `POST /v1/cms/items/readings/aggregate` (guide ch. 4, aggregates: \u2264500 groups over a \u226450k scan) for per-device\n daily min/max/avg. Cron delivery is at-least-once, so each summary write is itself lock+guard-deduped\n to one per (device, day); the declarative sibling is a `readModels` config entry (guide ch. 4, read models).\n- **Webhook fan-out of alerts** \u2014 `POST /v1/webhooks/subscriptions` with\n `{ "target_url": "https://ops.example.com/hook", "event_prefixes": ["cms.item."] }` delivers every\n cms write (including new alerts) as a signed jobs callback \u2014 verify `X-Vxil-Jobs-Signature`.\n\nThe same call from a device or gateway, with a key that carries `functions:invoke`\n(`vxil env pull` puts your project key in `.env.local` as `VXIL_API_KEY`; mint a narrower one for\nhardware with `vxil keys mint --name gateway --scopes functions:invoke --json`):\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ingest-reading \\\n -H "Authorization: Bearer $VXIL_API_KEY" -H "Content-Type: application/json" \\\n -d \'{"device_id":"itm_...","metric":"battery_pct","value":12}\'\n```\n\nTyped SDK query for the ops dashboard (`vx` = `Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! })`):\n`vx.from(\'devices\').query({ filter: { battery_pct: { $lt: 20 } }, sort: \'-last_seen\' })`.\n\n**Go deeper:** vxil.com/docs/api (lock/guard, aggregates, read-models),\nvxil.com/docs/guide/08-running-your-code-functions, vxil.com/docs/guide/06-feature-catalog (webhooks), and the **storefront** blueprint for a larger\nfunction saga. The config is yours after `init` \u2014 nothing is locked.\n',
19265
19360
  "functions": {
19266
19361
  "daily-rollup.ts": "// daily-rollup.ts \u2014 BOUNDED AGGREGATES FOR ROLLUPS (a vxil function, cron trigger).\n//\n// Runs daily (cron 0 6 * * *). ONE call to the real group-by aggregate endpoint \u2014\n// POST /v1/cms/items/readings/aggregate (guide ch. 4, aggregates) \u2014 computes per-device\n// min/max/avg/count of battery_pct over the last 24h (grouped by the slot-bound\n// `device` relation; hard-bounded server-side: \u2264500 groups over a \u226450k-row scan,\n// else a clean 422 window_too_large), then writes one `daily_rollup` info row into\n// `alerts` per device. Cron delivery is at-least-once and cms does NOT dedupe on\n// the Idempotency-Key header, so exactly-once-per-day is enforced with the lock + guard\n// lock+guard WRITE BODY: at most ONE daily_rollup per (device, UTC day) \u2014 a\n// redelivered run cleanly 409s. The DECLARATIVE alternative is a `readModels`\n// config entry materializing on cron (guide ch. 4, read models).\n\n// cron-walk: single-read \u2014 one bounded server-side aggregate is the whole job; nothing to page.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Group { key: { device?: string }; count: number; min?: number; max?: number; avg?: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 1. the bounded aggregate: min/max/avg need `value` on an n-slot (n1);\n // the 24h window rides the t-slot (t1) on `recorded_at`.\n const agg = await fetch(`${base}/v1/cms/items/readings/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'min', field: 'value', as: 'min' },\n { fn: 'max', field: 'value', as: 'max' },\n { fn: 'avg', field: 'value', as: 'avg' },\n { fn: 'count' },\n ],\n groupBy: ['device'],\n filter: { metric: 'battery_pct', $status: 'published' },\n window: { field: 'recorded_at', sinceDays: 1 },\n limit: 500,\n }),\n });\n if (!agg.ok) return json({ error: 'aggregate_failed', status: agg.status }, 502);\n const groups = ((await agg.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n\n // 2. one published summary row per (device, UTC day) \u2014 the guard counts today's\n // live daily_rollup rows for the device under the lock, so a redelivered\n // cron run 409s instead of duplicating (guide ch. 4, preconditions and concurrency).\n const day = now.slice(0, 10);\n let written = 0;\n for (const g of groups) {\n if (!g.key.device) continue;\n const r = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': `${env.idempotency_key}:${g.key.device}` } : H,\n body: JSON.stringify({\n status: 'published',\n lock: `rollup:${g.key.device}`,\n guard: {\n filter: { device: g.key.device, kind: 'daily_rollup', raised_at: { $gte: `${day}T00:00:00.000Z` } },\n max: 1,\n },\n data: {\n device: g.key.device, kind: 'daily_rollup', severity: 'info', raised_at: now,\n note: `battery_pct last 24h \u2014 min ${g.min} \xB7 max ${g.max} \xB7 avg ${round1(g.avg)} over ${g.count} readings`,\n },\n }),\n });\n if (r.ok) written += 1;\n }\n return json({ devices: groups.length, written }, 200);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\nconst round1 = (n?: number) => (typeof n === 'number' ? Math.round(n * 10) / 10 : n);\n",
19267
19362
  "ingest-reading.ts": "// ingest-reading.ts \u2014 THE TYPED DEVICE ENDPOINT (a vxil function, http trigger).\n//\n// POST /v1/fn/ingest-reading with { device_id, metric, value } \u2014 one call from a\n// device (or gateway) does the three cross-row writes no Lane-A hook may do:\n// 1. create the `readings` row (published, so the daily aggregate window sees it;\n// the envelope idempotency key is passed through as the write's Idempotency-Key\n// header \u2014 the platform convention, guide ch. 8)\n// 2. PATCH the device: last_seen = now (+ battery_pct when the metric carries it)\n// 3. THRESHOLD ALERTING: battery below 15 \u2192 create a low_battery alert, deduped\n// by a lock+guard WRITE BODY (at most ONE live alert per device \u2014 guide ch. 4, preconditions and concurrency).\n// The guard is the dedupe that matters \u2014 a retried reading create is telemetry noise.\n// Resolve an alert by deleting its row; that frees the guard for the next one.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload` (guide ch. 8)\ntype Env = HttpFunctionEnvelope<{ device_id?: string; metric?: string; value?: number }>;\ninterface Created { data?: { item_id?: string } } // cms responses are { data: {\u2026}, meta }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const { device_id, metric } = env.payload ?? {};\n const value = Number(env.payload?.value);\n if (!device_id || !metric || !Number.isFinite(value)) {\n return json({ error: 'device_id, metric and numeric value required' }, 400);\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 0. the device must exist (404 from cms = unknown or deleted device)\n const dev = await fetch(`${base}/v1/cms/items/devices/${device_id}`, { headers: H });\n if (!dev.ok) return json({ error: 'unknown_device' }, 404);\n\n // 1. create the reading \u2014 published so the daily aggregate window sees it\n const rd = await fetch(`${base}/v1/cms/items/readings`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': env.idempotency_key } : H,\n body: JSON.stringify({\n status: 'published',\n data: { device: device_id, metric, value, recorded_at: now },\n }),\n });\n if (!rd.ok) return json({ error: 'reading_create_failed', status: rd.status }, 502);\n const reading = ((await rd.json()) as Created).data ?? {};\n\n // 2. heartbeat the device (merge-patch; battery only when this metric carries it)\n const patch: Record<string, unknown> = { last_seen: now };\n if (metric === 'battery_pct') patch.battery_pct = Math.max(0, Math.min(100, Math.round(value)));\n await fetch(`${base}/v1/cms/items/devices/${device_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: patch }),\n });\n\n // 3. threshold alert \u2014 `guard` counts live rows under the ONE per-key `lock`,\n // so N racing low-battery ingests raise exactly one alert (the rest 409).\n let alert_id: string | undefined;\n if (metric === 'battery_pct' && value < 15) {\n const al = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n status: 'published',\n lock: `dev:${device_id}:low_battery`,\n guard: { filter: { device: device_id, kind: 'low_battery' }, max: 1 },\n data: { device: device_id, kind: 'low_battery', severity: 'warning', raised_at: now, note: `battery at ${value}%` },\n }),\n });\n if (al.ok) alert_id = ((await al.json()) as Created).data?.item_id;\n // 409 guard_failed \u2192 an open low_battery alert already exists; nothing to do.\n }\n\n return json({ reading_id: reading.item_id, alert_id }, 201);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
@@ -19285,7 +19380,7 @@ export default defineConfig({
19285
19380
  "hasFunctions": true,
19286
19381
  "byoKeys": [],
19287
19382
  "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// \"Push notifications\" \u2014 DEVICE TOKENS DONE PROPERLY.\n//\n// Sending a push is the easy part: one HTTPS POST to your push service. What\n// actually breaks in production is the REGISTRY \u2014 the mapping from a user to\n// the devices they are currently holding. Tokens rotate, phones get sold, users\n// sign out and a colleague signs in on the same handset, and a token that once\n// belonged to Alice starts delivering Alice's notifications to Bob. That is a\n// privacy incident, not a bug.\n//\n// So this blueprint is a registry with three invariants and a small amount of\n// sending bolted on \u2014 and it is THE supported Expo path on vxil (guide 17):\n// 1. ONE TOKEN, ONE USER \u2014 `token` is `unique`, enforced by the platform.\n// 2. RE-OWN ON CHANGE \u2014 a token that reappears under a different user is\n// REASSIGNED, never duplicated. From your backend (server mode) that is\n// immediate; from the app itself (end-user mode, which cannot see another\n// user's row) it is ASYNC: a claim row + a server-mode cmsHook.\n// 3. PRUNE STALE \u2014 the push service tells you when a token is dead; a cron\n// collects those receipts and deletes the rows, so your registry shrinks.\n// It walks the WHOLE registry over successive ticks (a persisted cursor).\n// Plus the two things every consumer app ends up writing:\n// \u2022 ERASURE \u2014 a user erased under GDPR (auth.user.erased / user.erased) or\n// deleted (user.deleted) loses every device row, claim and preferences\n// row \u2014 never on session.revoked, which fires on every refresh.\n// \u2022 A REMINDER LADDER \u2014 an hourly cron nudging inactive users (1 d, 3 d, 7 d),\n// honouring each user's opt-out and quiet hours in THEIR time zone.\n//\n// \u2022 cms \u2192 `push_tokens` (owner-scoped, `token` unique), `push_token_claims`\n// (an app's async re-own request), `push_prefs` (reminders,\n// time zone, quiet hours, last activity), `push_state` (cursors)\n// \u2022 functions \u2192 `register-token` (the registry: http + the claim cmsHook + the\n// erasure webhooks), `send-push` (http send + the hourly ladder\n// cron), `prune-receipts` (the receipts cron)\n//\n// Three functions and two crons (the fastest hourly) on purpose: that is inside\n// the Free plan's function limits for staging and development projects (guide\n// 12 has the per-plan table), so the whole kit runs on a free project while\n// you build the app. Every tick is bounded and resumable, so a tick the plan\n// sheds is harmless: the next one picks up where the last stopped.\n//\n// Written against Expo's push service because it fronts BOTH APNs and FCM with\n// one token format. Swapping in raw FCM or APNs is a change to two constants\n// and the message body \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 // Fail-safe: a verified end-user may only touch collections that declare\n // an ownerField. `push_tokens` does, so a signed-in device can read and\n // refresh its OWN rows and structurally cannot enumerate anyone else's.\n strictEndUserScope: true,\n hooks: {\n // A row with no token is a row that can never receive anything.\n token_present: {\n collection: 'push_tokens',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.token) > 0',\n message: 'a push token is required',\n },\n // The ladder walk filters on `ladder_step < 3` (a slot compare, which\n // never matches an absent value), so every prefs row carries one: a\n // write that leaves it out gets 0.\n ladder_step_default: {\n collection: 'push_prefs',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'ladder_step',\n expr: 'coalesce(item.ladder_step, 0)',\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n push_tokens: {\n singular: 'push_token',\n // Owner-scoped: an end-user session sees only its own devices.\n ownerField: 'end_user',\n fields: {\n // \u2605 INVARIANT 1. `unique` is the TOP-LEVEL field attribute (there is no\n // `validation.unique`). vxil enforces it with its uniqueness check, so a\n // second registration of the same token is a 409 `unique_violation` \u2014\n // atomically, under concurrency, without a read-then-write race.\n // NOTE: a unique value is capped at 256 characters. Expo tokens\n // (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside that;\n // if you switch to raw FCM registration tokens, store a hash here and\n // keep the full token in an unindexed `text` field.\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' }, // the owner\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n // The device's language, captured at registration \u2014 this is what makes\n // a localized push possible without a second lookup per device.\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'fr', \u2026\n // The receipt id returned by the LAST send. `prune-receipts` trades it\n // for a verdict and deletes the row when the service says the device\n // is gone.\n receipt_id: { type: 'string' },\n // When that receipt id was parked. The push service keeps a receipt\n // for a limited time; past RECEIPT_MAX_AGE the id is cleared as\n // unknowable instead of being asked about forever.\n receipt_at: { type: 'datetime' },\n // Consecutive delivery failures \u2014 a soft signal for your own dashboards\n // (the authoritative kill signal is the receipt, not this counter).\n failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // last registration/refresh\n checked_at: { type: 'datetime', indexSlot: 't2' }, // last receipt check\n last_error: { type: 'text' },\n },\n },\n\n // \u2605 INVARIANT 2, the app's half. An end-user session cannot see \u2014 let\n // alone re-own \u2014 a row that belongs to someone else, so when the app\n // registers a token another user holds, `register-token` files a CLAIM\n // here (owned by the caller) and answers 202. The `register-token`\n // cmsHook binding then runs in SERVER mode, moves the token, and deletes\n // the claim. Usually done within seconds.\n push_token_claims: {\n singular: 'push_token_claim',\n ownerField: 'end_user',\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1' },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n locale: { type: 'string', indexSlot: 's4' },\n },\n },\n\n // The reminder ladder's per-user state. Owner-scoped: the app reads and\n // writes its OWN row (opt out, set quiet hours, stamp `last_active` and\n // reset `ladder_step` to 0 whenever the app comes to the foreground).\n push_prefs: {\n singular: 'push_pref',\n ownerField: 'end_user',\n fields: {\n end_user: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // IANA zone ('Asia/Riyadh'); quiet hours are evaluated in it. An\n // unknown zone is treated as UTC.\n timezone: { type: 'string', indexSlot: 's2' },\n // false = no reminders at all (absent = reminders on).\n reminders: { type: 'bool' },\n // Local hours [quiet_start, quiet_end) during which nothing is sent;\n // the window may wrap midnight (22 \u2192 8). Equal values = no quiet hours.\n quiet_start: { type: 'int', validation: { min: 0, max: 23 } },\n quiet_end: { type: 'int', validation: { min: 0, max: 23 } },\n last_active: { type: 'datetime', indexSlot: 't1' },\n // The next rung (0..3) since `last_active` (the app resets it to 0; the\n // `ladder_step_default` derive writes 0 when a write leaves it out).\n ladder_step: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_reminded_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n\n // Where each cron left off (`key` = the job). No ownerField, so with\n // `strictEndUserScope` an end-user session cannot touch it at all.\n push_state: {\n singular: 'push_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // the last item id walked; '' = start again from the newest row\n cursor: { type: 'string' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n functions: {\n // THE REGISTRY \u2014 one bundle, five front doors (a function is code + a SET\n // of trigger bindings):\n // \u2022 http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n // server mode (your backend) re-owns at once; end-user mode\n // (the app) files a claim for a token someone else holds \u2192 202.\n // \u2022 cmsHook a new `push_token_claims` row \u2192 re-own it in SERVER mode,\n // delete the claim. `retry` re-delivers a failed attempt.\n // \u2022 webhook `auth.user.erased` (the auth erase), `user.erased` (the\n // admin erase) and `user.deleted` (the admin delete) \u2192 delete\n // every device row, claim and preferences row of that user.\n // NOT `auth.session.revoked`: that fires on every refresh-token\n // rotation, and would sign devices out of push at random.\n 'register-token': {\n entry: './functions/register-token.ts',\n trigger: { kind: 'http' },\n triggers: [\n { kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'auth.user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.deleted', retry: { maxAttempts: 3 } },\n ],\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal\n },\n\n // THE SENDER \u2014 two front doors:\n // \u2022 http POST /v1/fn/send-push { user_ids, messages, data? } \u2014 resolves\n // each user's live devices, picks the message for each device's\n // locale, and posts to the push service in batches.\n // \u2022 cron hourly: THE REMINDER LADDER. Users inactive 1 d / 3 d / 7 d get\n // at most one nudge per step (a user found already a week idle gets\n // only the 7-day one), never during their quiet hours, never after\n // opting out. Each step is claimed (If-Match) BEFORE the send, so\n // an overlapping tick can never send it twice.\n 'send-push': {\n entry: './functions/send-push.ts',\n trigger: { kind: 'http' },\n triggers: [{ kind: 'cron', schedule: '0 * * * *' }],\n scopes: ['cms:read', 'cms:write'],\n // Deny-by-default egress. `exp.host` is Expo's push host; `api.expo.dev`\n // is its newer alias. For raw FCM use 'fcm.googleapis.com'; for APNs,\n // 'api.push.apple.com' (and see README.md on the token-auth swap).\n egressAllow: ['exp.host', 'api.expo.dev'],\n // NO `secrets` here on purpose. Expo's push API needs no credential in its\n // default configuration, and secret resolution is FAIL-CLOSED: a declared\n // `secret:<name>` ref with no stored value makes every invocation\n // `409 secret_missing` before your code runs. Declare a secret only once\n // you will actually set it \u2014 see README.md \xA7\"If your push service needs a key\".\n },\n\n // \u2605 INVARIANT 3. THE GARBAGE COLLECTOR. A push service accepts a send and\n // only later tells you the device was gone \u2014 that verdict arrives as a\n // RECEIPT, not as the send response. Every 6 hours this cron trades parked\n // receipt ids for verdicts and deletes the rows the service declared dead.\n // It resumes where the last tick stopped (`push_state`), so a registry of\n // any size is walked end to end.\n 'prune-receipts': {\n entry: './functions/prune-receipts.ts',\n trigger: { kind: 'cron', schedule: '30 */6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: ['exp.host', 'api.expo.dev'],\n },\n },\n});\n",
19288
- "readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus the sending, the erasure clean-up and the\nre-engagement ladder every consumer app ends up writing. It is **the supported Expo path on vxil** \u2014\nvxil holds no push credential and runs no push service; your functions call Expo (which fronts APNs and\nFCM) through the egress allowlist. The whole mobile picture is in\n[guide 17 \u2014 vxil for Expo apps](../../docs/guide/17-mobile-apps-with-expo.md).\n\n```bash\nvxil init --template push-notifications\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging` for an existing backend\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # four collections + three functions (two of them crons)\n```\n\nThree functions and two crons (the fastest hourly) on purpose: that is inside the Free plan's function\nlimits for staging and development projects (the per-plan table is in guide 12), so a free project runs\nall of it while you build the app. Every cron tick is bounded and resumable, so a tick the plan sheds is\nharmless \u2014 the next one picks up where the last stopped.\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, enforced by vxil's uniqueness check. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. From the app itself (end-user mode) the move is **async**: a claim row + a server-mode `cmsHook` (below). |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a cron every 6 hours trades parked receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. It resumes where the last tick stopped, so the **whole** registry is walked, whatever its size. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n receipt_at: { type: 'datetime' }, // when it was parked (48 h \u2192 cleared)\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\nBeside it: `push_token_claims` (the app's async re-own requests, owner-scoped), `push_prefs` (one row per\nuser for the reminder ladder, owner-scoped, `end_user` unique) and `push_state` (each cron's cursor \u2014 no\n`ownerField`, so no end-user session can touch it).\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Registering \u2014 from your backend or from the app\n\n`POST /v1/fn/register-token { token, user_id?, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap. So there are two paths:\n\n| Called from | `user_id` | A token another user holds | Response |\n|---|---|---|---|\n| **your backend** (server key) | from the body | re-owned at once (`If-Match`) | `200 {action:'reowned', previous_owner}` |\n| **the app** (end-user session) | the **verified** session id \u2014 a body `user_id` is ignored | a **claim** row in `push_token_claims`, owned by the caller | `202 {action:'reown_queued', claim_id}` |\n\nEither way a new token is `201 {action:'created'}` and an unchanged owner is `200 {action:'refreshed'}`.\nCalled from the app, the app's thin-client key needs `functions:invoke` (it is allowed on a public key).\n\n**The async re-own.** `register-token` declares a second binding,\n`{ kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } }`.\nA platform-delivered hook carries **no** end-user, so it runs in server mode: it re-reads the claim,\nre-owns the token row for the claimant (or creates it, if the old holder deleted it meanwhile), and\ndeletes the claim. It usually lands within seconds; a failed attempt answers non-2xx and the `retry`\nre-delivers it, and every step is idempotent (a claim that is already gone is done).\n\n**What a claim can and cannot do.** A claim can only name its caller as the new owner (the platform\nstamps the verified owner on the row), and a push token is a bearer credential only the device itself is\nhanded by the OS. Claiming a token you do not hold moves **your** notifications to that device \u2014 no data\nreaches you. The trade-off to know about is availability: a signed-in user who somehow learned another\nuser's Expo token could move that device away from its owner, who then stops receiving pushes (security\nalerts included) until their app starts again and re-registers it. Tokens are not shown to other users\nby anything in this kit, so that needs the token to have leaked. If your app treats pushes as critical,\nadd a `validate` hook on `push_token_claims` that caps open claims, or have the app re-register on every\nforeground (it is idempotent \u2014 `200 {action:'refreshed'}` costs one write).\n\n**Sign-out.** A device that signs out should stop receiving the old user's pushes before anyone else\nsigns in: delete the device's own row from the app (`DELETE /v1/cms/items/push_tokens/{item_id}` with\nthe session \u2014 an owner may delete its own rows), or just let the next sign-in re-own it.\n\n## Erasure and deletion \u2014 `auth.user.erased`, not `session.revoked`\n\n`register-token` also binds `{ kind: 'webhook', source: 'auth.user.erased' }`,\n`{ kind: 'webhook', source: 'user.erased' }` (the admin `DELETE /v1/users/{id}?erase=true`) and\n`{ kind: 'webhook', source: 'user.deleted' }` (the admin delete without erase). That user loses every\n`push_tokens` row, pending claim and their `push_prefs` row (time zone, quiet hours, activity) \u2014 device\ntokens and preferences are personal data, vxil's erasure scrubs its own tables, not your collections,\nand a deleted account must stop receiving reminder nudges.\n\nA **merge or rekey** (`auth.user.merged` / `auth.user.rekeyed` \u2014 a guest becoming a registered user)\nneeds no binding: the app's next start registers its token under the new id, which re-owns the device.\nThe old id's prefs row goes quiet on its own (it has no devices left to nudge).\n\nDo **not** clean up on `auth.session.revoked`: it fires on every refresh-token rotation as well as on\nsign-out, so a cleanup bound to it would unregister devices at random.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push`\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\n \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n },\n \"data\": { \"screen\": \"billing\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 */6 * * *`. It resumes the walk of `push_tokens` where the previous tick\nstopped (its cursor lives in `push_state`), collects the rows carrying a `receipt_id`, asks the push\nservice for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered` deletes the\nrow, anything else counts a failure and keeps the device. A receipt that is not ready yet is simply\nabsent from the response, which is not an error; it is asked again on the next walk. A receipt id parked\nfor more than 48 hours with no answer is cleared \u2014 the service no longer keeps it, and asking forever\nwould pin the row.\n\nEverything is bounded: one tick reads at most 2,000 rows and acts on at most 300, so its outbound calls\nstay in the low hundreds. Because the cursor persists, a registry of **any** size is walked end to end\nover successive ticks; when the walk reaches the oldest row the cursor resets and the next tick starts\nagain from the newest (rows registered during a walk are covered by the next one).\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. In the\n> default order (newest first) the cursor is a plain item id, so it stays valid across ticks \u2014 persist it\n> and the walk resumes exactly where it stopped.\n\n**Never clears a newer receipt.** Each clear is a `PATCH` with `If-Match` on the row version the walk\nread. If `send-push` parked a newer receipt id on the same device in between, the clear is refused and\ncounted as `raced`; the newer id is checked on the next walk.\n\n## The reminder ladder \u2014 re-engagement that respects the user\n\n`send-push` has a second binding, `{ kind: 'cron', schedule: '0 * * * *' }`. Every hour it nudges users\nwho stopped opening the app: once after **1 day**, once after **3**, once after **7** \u2014 then silence until\nthey come back. The copy lives in the `LADDER` constant at the top of `functions/send-push.ts` (per\nlocale, picked per device like any send).\n\nIt reads `push_prefs`, a row per user **the app writes itself** (owner-scoped):\n\n| Field | Meaning |\n|---|---|\n| `reminders` | `false` = never. Absent = on. |\n| `timezone` | IANA zone (`Asia/Riyadh`); quiet hours are evaluated in it. Unknown = UTC. |\n| `quiet_start`, `quiet_end` | local hours `[start, end)` with nothing sent; may wrap midnight (`22` \u2192 `8`). Equal = none. |\n| `last_active` | stamp it whenever the app comes to the foreground\u2026 |\n| `ladder_step` | \u2026and reset this to `0` at the same time. The cron advances it. Left out on a write, a `derive` hook stores `0`. |\n\n```ts\n// in the app, on AppState 'active' (end-user session)\nawait vx.from('push_prefs').patch(prefsId, { last_active: new Date().toISOString(), ladder_step: 0 });\n```\n\nThree guarantees:\n\n- **Only idle users are read.** The walk filters on `last_active` and `ladder_step < 3` (both indexed),\n so active users and users who finished the ladder cost nothing; it resumes where the last tick stopped\n (`push_state`), reading at most 2,000 rows and nudging at most 100 users per tick. Users who opted out\n are still read (an absent `reminders` means on, which a filter cannot express) and skipped.\n- **Never twice, never a burst.** Each step is claimed on the prefs row with `If-Match` **before** the\n send \u2014 two overlapping ticks cannot both send it. A send that fails after the claim is a missed nudge,\n never a duplicate one. When several rungs are due at once (the kit added to a live app, reminders\n turned back on, a cron that was held), only the **highest** is sent and the ones below are skipped; and\n a rung waits its own gap after the previous nudge (`last_reminded_at`), so the 3-day and 7-day nudges\n stay two and four days after the one before.\n- **Never at night.** A user inside their quiet hours is skipped and picked up by a later tick once the\n window has passed (the next one, unless the walk spans more than one tick).\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Outbound subscriptions are runtime rows, not config:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $VXIL_API_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in [`templates/alerts-to-slack`](../alerts-to-slack) reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n- **What an end-user cannot do, a server-mode hook can \u2014 on the user's request.** The claim row is the\n request; the `cmsHook` is the authority. No backend of your own is needed for it.\n- **A cron that cannot finish in one tick must remember where it stopped.** Restarting from the top of\n a newest-first list means the oldest rows are never reached.\n- **One function, several front doors.** `triggers: [...]` puts an http door, a hook and a cron on the\n same bundle \u2014 the code that owns a job keeps all of its entry points.\n\n**Pairs with:** [`templates/alerts-to-slack`](../alerts-to-slack) for the audit-stream drain used in\nexample (b).\n",
19383
+ "readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus the sending, the erasure clean-up and the\nre-engagement ladder every consumer app ends up writing. It is **the supported Expo path on vxil** \u2014\nvxil holds no push credential and runs no push service; your functions call Expo (which fronts APNs and\nFCM) through the egress allowlist. The whole mobile picture is in\nguide 17 \u2014 vxil for Expo apps (vxil.com/docs/guide/17-mobile-apps-with-expo).\n\n```bash\nmkdir my-push && cd my-push\nvxil init --template push-notifications\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # four collections + three functions (two of them crons); writes vxil.types.ts\n```\n\nThree functions and two crons (the fastest hourly) on purpose: that is inside the Free plan's function\nlimits for staging and development projects (the per-plan table is in guide 12), so a free project runs\nall of it while you build the app. Every cron tick is bounded and resumable, so a tick the plan sheds is\nharmless \u2014 the next one picks up where the last stopped.\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, enforced by vxil's uniqueness check. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. From the app itself (end-user mode) the move is **async**: a claim row + a server-mode `cmsHook` (below). |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a cron every 6 hours trades parked receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. It resumes where the last tick stopped, so the **whole** registry is walked, whatever its size. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n receipt_at: { type: 'datetime' }, // when it was parked (48 h \u2192 cleared)\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\nBeside it: `push_token_claims` (the app's async re-own requests, owner-scoped), `push_prefs` (one row per\nuser for the reminder ladder, owner-scoped, `end_user` unique) and `push_state` (each cron's cursor \u2014 no\n`ownerField`, so no end-user session can touch it).\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Registering \u2014 from your backend or from the app\n\n`POST /v1/fn/register-token { token, user_id?, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap. So there are two paths:\n\n| Called from | `user_id` | A token another user holds | Response |\n|---|---|---|---|\n| **your backend** (server key) | from the body | re-owned at once (`If-Match`) | `200 {action:'reowned', previous_owner}` |\n| **the app** (end-user session) | the **verified** session id \u2014 a body `user_id` is ignored | a **claim** row in `push_token_claims`, owned by the caller | `202 {action:'reown_queued', claim_id}` |\n\nEither way a new token is `201 {action:'created'}` and an unchanged owner is `200 {action:'refreshed'}`.\nCalled from the app, the app's thin-client key needs `functions:invoke` (it is allowed on a public key).\n\n**The async re-own.** `register-token` declares a second binding,\n`{ kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } }`.\nA platform-delivered hook carries **no** end-user, so it runs in server mode: it re-reads the claim,\nre-owns the token row for the claimant (or creates it, if the old holder deleted it meanwhile), and\ndeletes the claim. It usually lands within seconds; a failed attempt answers non-2xx and the `retry`\nre-delivers it, and every step is idempotent (a claim that is already gone is done).\n\n**What a claim can and cannot do.** A claim can only name its caller as the new owner (the platform\nstamps the verified owner on the row), and a push token is a bearer credential only the device itself is\nhanded by the OS. Claiming a token you do not hold moves **your** notifications to that device \u2014 no data\nreaches you. The trade-off to know about is availability: a signed-in user who somehow learned another\nuser's Expo token could move that device away from its owner, who then stops receiving pushes (security\nalerts included) until their app starts again and re-registers it. Tokens are not shown to other users\nby anything in this kit, so that needs the token to have leaked. If your app treats pushes as critical,\nadd a `validate` hook on `push_token_claims` that caps open claims, or have the app re-register on every\nforeground (it is idempotent \u2014 `200 {action:'refreshed'}` costs one write).\n\n**Sign-out.** A device that signs out should stop receiving the old user's pushes before anyone else\nsigns in: delete the device's own row from the app (`DELETE /v1/cms/items/push_tokens/{item_id}` with\nthe session \u2014 an owner may delete its own rows), or just let the next sign-in re-own it.\n\n## Erasure and deletion \u2014 `auth.user.erased`, not `session.revoked`\n\n`register-token` also binds `{ kind: 'webhook', source: 'auth.user.erased' }`,\n`{ kind: 'webhook', source: 'user.erased' }` (the admin `DELETE /v1/users/{id}?erase=true`) and\n`{ kind: 'webhook', source: 'user.deleted' }` (the admin delete without erase). That user loses every\n`push_tokens` row, pending claim and their `push_prefs` row (time zone, quiet hours, activity) \u2014 device\ntokens and preferences are personal data, vxil's erasure scrubs its own tables, not your collections,\nand a deleted account must stop receiving reminder nudges.\n\nA **merge or rekey** (`auth.user.merged` / `auth.user.rekeyed` \u2014 a guest becoming a registered user)\nneeds no binding: the app's next start registers its token under the new id, which re-owns the device.\nThe old id's prefs row goes quiet on its own (it has no devices left to nudge).\n\nDo **not** clean up on `auth.session.revoked`: it fires on every refresh-token rotation as well as on\nsign-out, so a cleanup bound to it would unregister devices at random.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push`\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\n \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n },\n \"data\": { \"screen\": \"billing\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 */6 * * *`. It resumes the walk of `push_tokens` where the previous tick\nstopped (its cursor lives in `push_state`), collects the rows carrying a `receipt_id`, asks the push\nservice for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered` deletes the\nrow, anything else counts a failure and keeps the device. A receipt that is not ready yet is simply\nabsent from the response, which is not an error; it is asked again on the next walk. A receipt id parked\nfor more than 48 hours with no answer is cleared \u2014 the service no longer keeps it, and asking forever\nwould pin the row.\n\nEverything is bounded: one tick reads at most 2,000 rows and acts on at most 300, so its outbound calls\nstay in the low hundreds. Because the cursor persists, a registry of **any** size is walked end to end\nover successive ticks; when the walk reaches the oldest row the cursor resets and the next tick starts\nagain from the newest (rows registered during a walk are covered by the next one).\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. In the\n> default order (newest first) the cursor is a plain item id, so it stays valid across ticks \u2014 persist it\n> and the walk resumes exactly where it stopped.\n\n**Never clears a newer receipt.** Each clear is a `PATCH` with `If-Match` on the row version the walk\nread. If `send-push` parked a newer receipt id on the same device in between, the clear is refused and\ncounted as `raced`; the newer id is checked on the next walk.\n\n## The reminder ladder \u2014 re-engagement that respects the user\n\n`send-push` has a second binding, `{ kind: 'cron', schedule: '0 * * * *' }`. Every hour it nudges users\nwho stopped opening the app: once after **1 day**, once after **3**, once after **7** \u2014 then silence until\nthey come back. The copy lives in the `LADDER` constant at the top of `functions/send-push.ts` (per\nlocale, picked per device like any send).\n\nIt reads `push_prefs`, a row per user **the app writes itself** (owner-scoped):\n\n| Field | Meaning |\n|---|---|\n| `reminders` | `false` = never. Absent = on. |\n| `timezone` | IANA zone (`Asia/Riyadh`); quiet hours are evaluated in it. Unknown = UTC. |\n| `quiet_start`, `quiet_end` | local hours `[start, end)` with nothing sent; may wrap midnight (`22` \u2192 `8`). Equal = none. |\n| `last_active` | stamp it whenever the app comes to the foreground\u2026 |\n| `ladder_step` | \u2026and reset this to `0` at the same time. The cron advances it. Left out on a write, a `derive` hook stores `0`. |\n\n```ts\n// in the app, on AppState 'active' (end-user session)\nawait vx.from('push_prefs').patch(prefsId, { last_active: new Date().toISOString(), ladder_step: 0 });\n```\n\nThree guarantees:\n\n- **Only idle users are read.** The walk filters on `last_active` and `ladder_step < 3` (both indexed),\n so active users and users who finished the ladder cost nothing; it resumes where the last tick stopped\n (`push_state`), reading at most 2,000 rows and nudging at most 100 users per tick. Users who opted out\n are still read (an absent `reminders` means on, which a filter cannot express) and skipped.\n- **Never twice, never a burst.** Each step is claimed on the prefs row with `If-Match` **before** the\n send \u2014 two overlapping ticks cannot both send it. A send that fails after the claim is a missed nudge,\n never a duplicate one. When several rungs are due at once (the kit added to a live app, reminders\n turned back on, a cron that was held), only the **highest** is sent and the ones below are skipped; and\n a rung waits its own gap after the previous nudge (`last_reminded_at`), so the 3-day and 7-day nudges\n stay two and four days after the one before.\n- **Never at night.** A user inside their quiet hours is skipped and picked up by a later tick once the\n window has passed (the next one, unless the walk spans more than one tick).\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Declare the outbound subscription under `features` in `vxil.config.ts`, and `vxil push`\nconverges it by `target_url` (declared API state \u2014 the funnel-saas blueprint has the whole table):\n\n```ts\nwebhooks: {\n enabled: true,\n subscriptions: [\n { target_url: 'https://your-app.example.com/hooks/vxil', event_prefixes: ['payments.entitlement.'] },\n ],\n},\n```\n\nor create it at runtime as a row you manage yourself.\n\nEither way the key needs `webhooks:read` and `webhooks:write`. The project key quickstart minted for\nthis blueprint (cms, functions) has neither, so `vxil push` reports `webhooks: 403 insufficient_scope`\nand the curl is refused. Start from `vxil quickstart --features cms,functions,webhooks`, or (after\n`vxil login`) mint one with `vxil keys mint --name hooks --scopes webhooks:read,webhooks:write --json`\nand use it as `$HOOKS_KEY`:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $HOOKS_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in the **alerts-to-slack** blueprint reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n- **What an end-user cannot do, a server-mode hook can \u2014 on the user's request.** The claim row is the\n request; the `cmsHook` is the authority. No backend of your own is needed for it.\n- **A cron that cannot finish in one tick must remember where it stopped.** Restarting from the top of\n a newest-first list means the oldest rows are never reached.\n- **One function, several front doors.** `triggers: [...]` puts an http door, a hook and a cron on the\n same bundle \u2014 the code that owns a job keeps all of its entry points.\n\n**Pairs with:** the **alerts-to-slack** blueprint for the audit-stream drain used in\nexample (b).\n",
19289
19384
  "functions": {
19290
19385
  "prune-receipts.ts": "// prune-receipts.ts \u2014 THE GARBAGE COLLECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `30 */6 * * *` (every 6 hours).\n//\n// A push service accepts a message and answers \"ok\" long before it knows whether\n// the device still exists. The real verdict arrives later, as a RECEIPT keyed by\n// the id the send returned. Almost nobody collects them, which is why push\n// registries only ever grow: dead tokens are re-sent to forever, quota is spent\n// on handsets that were wiped a year ago, and delivery rates look worse than\n// they are.\n//\n// This cron closes that loop:\n// 1. resume the walk of `push_tokens` where the previous tick stopped (the\n// cursor lives in `push_state`), collecting rows that carry a `receipt_id`,\n// 2. ask the push service for those receipts, in batches,\n// 3. `DeviceNotRegistered` \u2192 DELETE the row (\u2605 invariant 3),\n// any other error \u2192 count it and keep the device,\n// ok \u2192 clear the receipt id; there is nothing left to check,\n// no receipt and older than RECEIPT_MAX_AGE_H \u2192 clear it (the service no\n// longer has it; asking forever would pin the row),\n// Every clear is a PATCH with If-Match on the row version that was read:\n// if `send-push` parked a NEWER receipt id on the device meanwhile, the\n// clear is refused (409) and the newer id stays for the next walk,\n// 4. save the cursor \u2014 or '' when the walk reached the oldest row, so the\n// next tick starts again from the newest.\n//\n// Everything is bounded: one tick reads at most MAX_ROWS rows and acts on at\n// most MAX_ACTIONS of them, so its outbound calls stay in the low hundreds.\n// Because the cursor persists, a registry of ANY size is walked end to end over\n// successive ticks \u2014 MAX_ROWS \xD7 4 ticks a day. (The walk follows the default\n// newest-first order; rows registered while a walk is in progress are covered\n// by the next walk.)\n\n// cron-walk: persisted-cursor \u2014 the walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst RECEIPTS_URL = 'https://exp.host/--/api/v2/push/getReceipts';\n/** Receipt ids per request. */\nconst RECEIPT_BATCH = 300;\n/** Token rows examined per cron tick. */\nconst MAX_ROWS = 2000;\n/** Rows with a pending receipt acted on per tick (each costs one PATCH or DELETE). */\nconst MAX_ACTIONS = 300;\n/** A receipt id parked longer than this is cleared as unknowable. */\nconst RECEIPT_MAX_AGE_H = 48;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** This cron's row in `push_state`. */\nconst STATE_KEY = 'prune-receipts';\n\ntype Env = CronFunctionEnvelope;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; receipt_id?: string; receipt_at?: string; failures?: number };\n}\ninterface Receipt { status?: string; message?: string; details?: { error?: string } }\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // \u2500\u2500 1. Resume the walk; collect pending receipts. \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 const state = await readState(base, H, STATE_KEY);\n if (state === 'error') return Response.json({ error: 'state_read_failed' }, { status: 502 });\n const startedAt = state?.data.cursor || null;\n const pending = new Map<string, TokenRow>(); // receipt_id \u2192 row\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n while (scanned < MAX_ROWS && pending.size < MAX_ACTIONS) {\n // NOTE: no `sort=` here on purpose. In the DEFAULT order (newest first)\n // a cursor is a plain item id, which is what we persist between ticks\n // and resume `item_id <` from. Paging beats ordering here.\n const url = `${base}/v1/cms/items/push_tokens?limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');\n const res = await fetch(url, { headers: H });\n if (!res.ok) return Response.json({ error: 'list_failed', status: res.status }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: TokenRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n let stoppedEarly = false;\n for (const it of items) {\n scanned++;\n if (it.data.receipt_id) pending.set(it.data.receipt_id, it);\n cursor = it.item_id; // resume AFTER the last row actually looked at\n if (pending.size >= MAX_ACTIONS || scanned >= MAX_ROWS) { stoppedEarly = true; break; }\n }\n if (stoppedEarly) break;\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n // Reached the oldest row \u2192 the next tick starts again from the newest.\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // \u2500\u2500 2 + 3. Ask for verdicts and act on them. \u2500\u2500\u2500\u2500\u2500\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 const ids = [...pending.keys()];\n let checked = 0;\n let pruned = 0;\n let cleared = 0;\n let expired = 0;\n let raced = 0;\n const errors: string[] = [];\n const now = Date.now();\n\n for (let i = 0; i < ids.length; i += RECEIPT_BATCH) {\n const slice = ids.slice(i, i + RECEIPT_BATCH);\n const res = await fetch(RECEIPTS_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify({ ids: slice }),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`receipts request returned ${res?.status ?? 'network error'}`);\n continue;\n }\n // Receipts come back keyed by id \u2014 a receipt that is not ready yet is\n // simply ABSENT, which is not an error: it is checked again next walk.\n const map = (((await res.json().catch(() => ({}))) as { data?: Record<string, Receipt> }).data) ?? {};\n for (const id of slice) {\n const receipt = map[id];\n const row = pending.get(id)!;\n if (!receipt) {\n // Not ready \u2014 or no longer kept by the service. Past the age bound\n // the id can never be answered: clear it so the row is not pinned.\n const at = row.data.receipt_at ? Date.parse(row.data.receipt_at) : NaN;\n if (Number.isFinite(at) && now - at > RECEIPT_MAX_AGE_H * 3_600_000) {\n if (await patch(base, H, row, { receipt_id: '', checked_at: iso() })) expired++; else raced++;\n }\n continue;\n }\n checked++;\n if (receipt.status === 'ok') {\n // Delivered. Nothing more to check for this device.\n if (await patch(base, H, row, { receipt_id: '', failures: 0, checked_at: iso() })) cleared++; else raced++;\n continue;\n }\n const reason = receipt.details?.error ?? receipt.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // \u2605 INVARIANT 3. The device is gone \u2014 the row goes with it. This is\n // also the ONLY safe automatic delete: every other error is transient\n // or about the message, not about the device's existence.\n await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, { method: 'DELETE', headers: H })\n .catch(() => null);\n pruned++;\n continue;\n }\n if (!await patch(base, H, row, {\n receipt_id: '',\n failures: (row.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n checked_at: iso(),\n })) raced++;\n errors.push(reason);\n }\n }\n\n // \u2500\u2500 4. Save where the walk stopped (If-Match: a concurrent tick wins). \u2500\u2500\u2500\u2500\n const saved = await writeState(base, H, STATE_KEY, state, nextCursor);\n\n return Response.json({\n scanned, pending: pending.size, checked, pruned, cleared, expired, raced,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved, errors: errors.slice(0, 10),\n });\n },\n};\n\n/** Clear/record against the version that was READ. false = the row changed\n * meanwhile (409: a newer receipt is parked \u2014 leave it) or the write failed. */\nasync function patch(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** This cron's `push_state` row, null when it does not exist yet. */\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** Persist the cursor. First tick creates the row (a racing tick's 409 on the\n * unique `key` is fine \u2014 that tick saved its own); later ticks PATCH with\n * If-Match so two overlapping ticks cannot both move it. */\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: iso() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\nconst iso = () => new Date().toISOString();\n",
19291
19386
  "register-token.ts": "// register-token.ts \u2014 THE DEVICE REGISTRY (a vxil function with five bindings).\n//\n// http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n// cmsHook a new `push_token_claims` row (the app's async re-own request)\n// webhook `auth.user.erased` / `user.erased` / `user.deleted` (forget the user)\n//\n// One bundle, because all three are the same job: keeping \"which user holds\n// which device\" true. The envelope's `trigger` says which door was used.\n//\n// \u2500\u2500 http: register \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Call it every time your app starts and every time the OS hands you a new push\n// token. It is idempotent, and it enforces the two registry invariants:\n//\n// 1. ONE TOKEN, ONE USER. `token` is declared `unique`, so the platform \u2014\n// not this code \u2014 decides who wins a race. Two devices registering the\n// same token at the same instant produce exactly one row; the loser sees\n// 409 `unique_violation`.\n// 2. RE-OWN ON CHANGE. A token that comes back under a DIFFERENT user is\n// REASSIGNED, not duplicated. This is the case that matters: a resold\n// phone, a shared tablet, a sign-out/sign-in on the same handset. Without\n// it, the previous owner's notifications keep arriving on someone else's\n// lock screen.\n//\n// IDENTITY. If the call carries a verified end-user session, `envelope.end_user`\n// is populated by the platform and THAT id wins \u2014 a body field can never\n// impersonate a signed-in user. In server mode (your backend, a server key)\n// there is no verified principal and `user_id` from the body is used.\n//\n// SERVER MODE re-owns immediately (a tenant-wide read finds the row whoever\n// owns it). END-USER MODE cannot see another user's row \u2014 the owner scope is\n// the fail-safe working \u2014 so the app's call files a CLAIM in\n// `push_token_claims` (owned by the caller) and answers 202\n// `{ action: 'reown_queued' }`; the cmsHook below finishes the move.\n//\n// \u2500\u2500 cmsHook: finish an async re-own \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Runs in SERVER mode (a platform-delivered hook carries no end-user), so it can\n// read and move any row: re-read the claim, re-own (or create) the token row for\n// the claimant, delete the claim. At-least-once and re-delivered on failure\n// (`retry`), so every step is idempotent: a claim that is already gone is done.\n//\n// \u2500\u2500 webhook: erasure and deletion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// `auth.user.erased { user_id }`, `user.erased { id }` and `user.deleted { id }`\n// (the admin delete without erase) delete every device row, pending claim and\n// the preferences row of that user \u2014 device tokens, time zone and activity are\n// personal data, and a deleted account must stop getting reminder nudges.\n// Deliberately NOT `auth.session.revoked`: that event fires on every\n// refresh-token rotation, not only on sign-out. A MERGE or REKEY\n// (`auth.user.merged` / `.rekeyed`, guest \u2192 registered) needs nothing here: the\n// app's next start registers its token under the new id, which re-owns it.\n\nimport type { CmsHookFunctionEnvelope, HttpFunctionEnvelope, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype RegisterBody = { token?: string; user_id?: string; platform?: string; locale?: string };\ntype Env =\n | HttpFunctionEnvelope<RegisterBody>\n | CmsHookFunctionEnvelope\n | WebhookFunctionEnvelope<{ user_id?: string; id?: string }>;\ninterface TokenRow { item_id: string; version?: number; data: { token?: string; end_user?: string } }\ninterface ClaimRow { item_id: string; data: { token?: string; end_user?: string; platform?: string; locale?: string } }\n\nconst PLATFORMS = new Set(['ios', 'android', 'web']);\n/** Rows deleted per erasure attempt \u2014 a person has a handful of devices; a\n * bigger set is finished by the next attempt (each deletes the next pages). */\nconst ERASE_MAX = 200;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n if (env.trigger === 'cms-hook') return finishClaim(base, H, env);\n if (env.trigger === 'webhook') return forgetUser(base, H, env);\n return register(base, H, env as HttpFunctionEnvelope<RegisterBody>);\n },\n};\n\n// \u2500\u2500 http \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function register(base: string, H: Record<string, string>, env: HttpFunctionEnvelope<RegisterBody>): Promise<Response> {\n const p = env.payload ?? {};\n const token = typeof p.token === 'string' ? p.token.trim() : '';\n // A VERIFIED principal always beats the body. Never trust a client-supplied\n // user id when the platform has already told you who is calling.\n const endUser = env.end_user?.id;\n const owner = endUser ?? (typeof p.user_id === 'string' ? p.user_id.trim() : '');\n if (!token) return json({ error: 'token is required' }, 400);\n if (!owner) return json({ error: 'user_id is required (or invoke with an end-user session)' }, 400);\n // The unique claim is capped at 256 chars; refuse early with a clear message\n // rather than letting the write fail deep in the stack.\n if (token.length > 256) return json({ error: 'token longer than 256 characters \u2014 store a hash instead' }, 400);\n\n const data = tokenData(token, owner, p.platform, p.locale);\n\n // \u2500\u2500 1. Try to create. The common case is one round-trip. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n if (created.ok) {\n const body = (await created.json()) as { data?: { item_id?: string } };\n return json({ registered: true, action: 'created', item_id: body.data?.item_id }, 201);\n }\n if (created.status !== 409) return json({ error: 'register_failed', status: created.status }, 502);\n\n // \u2500\u2500 2. 409 \u21D2 the token already exists. Find it and decide. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // In SERVER mode this read is tenant-wide and finds the row whoever owns it.\n // In end-user mode it is owner-scoped, so a row belonging to someone else is\n // invisible \u2014 that is the fail-safe working as designed.\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502);\n if (!row) {\n if (!endUser) {\n // Server mode sees every row, so \"exists but not found\" is a race with a\n // concurrent delete. Retrying the call settles it.\n return json({ registered: false, error: 'conflict', hint: 'retry the call' }, 409);\n }\n // \u2605 The async re-own. File a claim (owned by the caller \u2014 the platform\n // stamps the verified owner); the server-mode cmsHook moves the token.\n const claim = await fetch(`${base}/v1/cms/items/push_token_claims`, {\n method: 'POST', headers: H,\n body: JSON.stringify({ status: 'published', data: claimData(token, owner, p.platform, p.locale) }),\n });\n if (!claim.ok) return json({ error: 'claim_failed', status: claim.status }, 502);\n const c = (await claim.json()) as { data?: { item_id?: string } };\n return json({ registered: false, action: 'reown_queued', claim_id: c.data?.item_id }, 202);\n }\n\n const moved = await reown(base, H, row, data);\n if (!moved.ok) {\n // 409 version_conflict = a concurrent registration already moved it. The\n // registry is still correct; the caller can simply retry.\n return json({ registered: false, error: 'conflict', status: moved.status }, 409);\n }\n const previous = row.data.end_user;\n return json({\n registered: true,\n action: previous && previous !== owner ? 'reowned' : 'refreshed',\n item_id: row.item_id,\n ...(previous && previous !== owner ? { previous_owner: previous } : {}),\n }, 200);\n}\n\n// \u2500\u2500 cmsHook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function finishClaim(base: string, H: Record<string, string>, env: CmsHookFunctionEnvelope): Promise<Response> {\n const { collection, item_id: claimId } = env.payload ?? ({} as CmsHookFunctionEnvelope['payload']);\n // The platform filters on the binding's collection; this guard is belt and braces.\n if (collection !== 'push_token_claims' || !claimId) return json({ skipped: true }, 200);\n\n const got = await fetch(`${base}/v1/cms/items/push_token_claims/${claimId}`, { headers: H });\n if (got.status === 404) return json({ done: true, reason: 'claim already handled' }, 200);\n if (!got.ok) return json({ error: 'claim_read_failed', status: got.status }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const claim = ((await got.json()) as { data?: ClaimRow }).data;\n const token = claim?.data.token;\n const owner = claim?.data.end_user;\n if (!token || !owner) {\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, reason: 'empty claim dropped' }, 200);\n }\n\n const data = tokenData(token, owner, claim.data.platform, claim.data.locale);\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n let action: string;\n if (!row) {\n // The holder deleted it in the meantime: the claimant simply registers it.\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n // 409 = it was registered again concurrently; the next delivery re-reads.\n if (!created.ok) return json({ error: 'create_failed', status: created.status }, 502);\n action = 'created';\n } else {\n const moved = await reown(base, H, row, data);\n if (!moved.ok) return json({ error: 'reown_failed', status: moved.status }, 502); // another attempt re-reads (maxAttempts: 3)\n action = row.data.end_user === owner ? 'refreshed' : 'reowned';\n }\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, action, claim_id: claimId }, 200);\n}\n\n// \u2500\u2500 webhook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function forgetUser(\n base: string, H: Record<string, string>, env: WebhookFunctionEnvelope<{ user_id?: string; id?: string }>,\n): Promise<Response> {\n const event = env.payload?.event;\n const d = env.payload?.data;\n const fields = d && !('truncated' in d) ? d : null;\n // auth.user.erased carries `user_id`; the admin erase and delete carry `id`.\n const userId = event === 'auth.user.erased' ? fields?.user_id\n : event === 'user.erased' || event === 'user.deleted' ? fields?.id : undefined;\n if (typeof userId !== 'string' || !userId) return json({ skipped: true, event }, 200);\n\n let deleted = 0;\n let failed = 0;\n for (const coll of ['push_tokens', 'push_token_claims', 'push_prefs']) {\n const filter = encodeURIComponent(JSON.stringify({ end_user: userId }));\n while (deleted + failed < ERASE_MAX) {\n // Always the FIRST page: what was deleted is gone, so the next read\n // returns the next rows. A row that refuses to delete ends the loop.\n const res = await fetch(`${base}/v1/cms/items/${coll}?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status, deleted }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const items = ((await res.json()) as { data?: { items?: Array<{ item_id: string }> } }).data?.items ?? [];\n if (items.length === 0) break;\n let progress = 0;\n for (const it of items) {\n if (await deleteItem(base, H, coll, it.item_id)) { deleted++; progress++; } else failed++;\n }\n if (progress === 0) break;\n }\n }\n // A failed delete \u2014 or a set larger than one attempt's ERASE_MAX \u2014 answers\n // non-2xx: the binding declares maxAttempts: 3, so the jobs ladder hands it\n // back for another attempt, which deletes the rest.\n const unfinished = failed > 0 || deleted >= ERASE_MAX;\n return json({ user_id: userId, deleted, failed, done: !unfinished }, unfinished ? 502 : 200);\n}\n\n// \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction tokenData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner, last_seen: new Date().toISOString(), failures: 0, last_error: '',\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nfunction claimData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner,\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nasync function findToken(base: string, H: Record<string, string>, token: string): Promise<TokenRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ token }));\n const found = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return 'error';\n return ((await found.json()) as { data?: { items?: TokenRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** \u2605 INVARIANT 2: re-own (or just refresh, when the owner is unchanged).\n * If-Match makes the reassignment atomic against a concurrent registration. */\nfunction reown(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<Response> {\n return fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n });\n}\n\n/** true when the row is gone afterwards (a 404 counts: someone else deleted it). */\nasync function deleteItem(base: string, H: Record<string, string>, coll: string, itemId: string): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/${coll}/${itemId}`, { method: 'DELETE', headers: H }).catch(() => null);\n return !!res && (res.ok || res.status === 404);\n}\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
@@ -19310,7 +19405,7 @@ export default defineConfig({
19310
19405
  "vxil_read_key"
19311
19406
  ],
19312
19407
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Alerts to Slack\" \u2014 failure events \u2192 one chat message per state crossing.\n//\n// There is no vxil \"connectors\" feature and no alert-routing engine, on purpose:\n// every feature already writes its lifecycle to your tenant's audit stream, and\n// a function can read that stream. This blueprint is DISTRIBUTION over that\n// spine \u2014 one cron function, one cms collection as its memory, one BYO webhook:\n// \u2022 cms \u2192 `alert_state`: the watermark + per-condition state (ok|stale|\n// broken|could_not_check) that makes alerts once-per-crossing\n// \u2022 functions \u2192 `alerts`: drains the audit stream past the watermark every\n// 5 minutes, matches an allow-list of failure events, posts\n// to Slack (or Discord) through the egress allowlist\n// A permanently-red condition therefore produces ONE message when it turns red,\n// ONE when it recovers, and (optionally) a reminder every REPEAT_AFTER_HOURS.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The function's memory. One row per alert KEY (e.g. `payments:ingest:stripe`,\n // `jobs:dead-letter:notifications.deliver`, `functions:checkout`) plus the\n // reserved `__cursor__` row that holds the audit-stream watermark.\n alert_state: {\n singular: 'alert_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // unique \u21D2 409 on a racing duplicate\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n event: { type: 'string' }, // the last audit event name seen for this key\n detail: { type: 'text' }, // last reason/error text (truncated)\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n last_event_id: { type: 'string' },\n last_alert_at: { type: 'datetime', indexSlot: 't1' },\n updated_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // The whole blueprint is this one function. Cron every 5 minutes; also\n // invocable by hand (`vxil functions invoke alerts --data '{\"test\":true}'`)\n // to prove the webhook is wired without touching the watermark.\n alerts: {\n entry: './functions/alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n 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",
19313
- "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 --env staging --no-push # or `vxil link <slug> --env staging` for an existing backend\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\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> **On the Free plan** (staging or development projects) the fastest function cron is every 15 minutes:\n> set `schedule: '*/15 * * * *'` in `vxil.config.ts` before `vxil push`, or the deploy answers\n> `402 plan_limit` (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n> **Plan note.** The `alerts` function deploys on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n**The 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; 15 on the Free plan) 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",
19408
+ "readme": "# Alerts to Slack (ops)\n\nFailure events from your backend \u2192 **one Slack (or Discord) message per state crossing**, with a\n\"resolved\" message when the condition clears. No connectors registry, no alert-routing feature \u2014 one\ncron function that drains your tenant's **audit stream** past a watermark, an allow-list of failure\nevents it cares about, and a `cms` collection as its memory so a permanently-red condition never\nspams the channel.\n\n```bash\nmkdir my-alerts && cd my-alerts\nvxil init --template alerts-to-slack\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: set the cron to '*/15 * * * *' in vxil.config.ts now (note below)\nvxil secrets set functions/slack_webhook_url # your webhook URL (hidden prompt)\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name alerts-audit --scopes audit:read --json | jq -r .api_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\nThe mint line needs `jq`; it pipes the new key straight into the secret, so the key never shows in your\nterminal. (Without `jq`: run the mint with `--json`, copy `api_key`, and paste it at the hidden prompt of\n`vxil secrets set functions/vxil_read_key`. Or create the key on the dashboard's API keys page.)\n\n> **On the Free plan** (staging or development projects) the fastest function cron is every 15 minutes:\n> set `schedule: '*/15 * * * *'` in `vxil.config.ts` before `vxil push`, or the deploy answers\n> `402 plan_limit` (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n> **Plan note.** The `alerts` function deploys on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n**The 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 project** holding **only `audit:read`** | the `vxil keys mint \u2026 --scopes audit:read` line above, or dashboard \u2192 API keys \u2192 create, tick `audit:read` and nothing else |\n\nWhy a key at all? The shipped function reads the audit stream (`GET /v1/audit/export`) with this\nsecret. That is a holdover, not a platform limit: a function that declares the `audit:read` scope can\nread `/v1/audit` and `/v1/audit/export` through its own scoped callback, with no stored key. Until you\nchange `functions/alerts.ts` to do that, keep the key as narrow as it can be: `audit:read` is read-only,\nreads the audit stream and nothing else, and is revocable in one click. The comments in\n`vxil.config.ts` still name `features:read`, which works too but also opens the whole-project export, so\nprefer `audit:read`.\nFor Discord, add `'discord.com'` to `egressAllow` \u2014 the function sends both Slack's `text` and Discord's\n`content` field, so one message body works on either.\nFor Google Chat, add `'chat.googleapis.com'` to `egressAllow` \u2014 a space webhook takes `text` alone, and the\nfunction sends only that field to that host.\n\n## What it does, every 5 minutes\n\n1. **Watermark.** Reads the `__cursor__` row in `alert_state`. On the very first run it stores the\n *newest* audit id and stops \u2014 history never becomes an alert storm.\n2. **Drain.** `GET /v1/audit/export?after_id=<cursor>&limit=500` (ascending NDJSON), up to 5 pages a tick.\n3. **Match.** Each row is looked up in `RULES` (the allow-list in `functions/alerts.ts`). Anything not\n listed is ignored. A match becomes `(key, state, level)` \u2014 e.g. a `payments.webhook_event.failed`\n row with `provider: stripe, state: broken` \u2192 key `payments:ingest:stripe`, state `broken`.\n4. **Compare + decide.** One `alert_state` row per key. Only a **crossing** posts:\n - not-ok while the row said ok (or no row yet) \u2192 \u{1F534}/\u{1F7E0} alert\n - ok while the row said not-ok \u2192 \u{1F7E2} \"RESOLVED\"\n - same state again \u2192 silence (a \u23F0 reminder after `REPEAT_AFTER_HOURS`, default 24; `0` disables)\n5. **Advance** the watermark with `If-Match` on the cursor row's version. Cron deliveries are\n at-least-once; if two ticks overlap, the first to move the watermark wins, and the `unique` key plus\n `If-Match` on every state row mean a racing run gets a 409 and stands down instead of double-posting.\n\n## The events it listens for\n\nEvery name in the table below is an audit event a vxil feature writes **today**. Keys are what collapse\nrepeats: ten dead letters of the same job are one key, one row, one alert.\n\n| Audit event | Level | Key (one row each) | State |\n|---|---|---|---|\n| `payments.webhook_event.failed` | from payload (`error`) | `payments:ingest:<provider>` | from payload (`broken`) |\n| `payments.webhook.rejected` | from payload (`warn`) | `payments:webhook-rejected:<provider>` | from payload (`broken`) |\n| `payments.grant.failed` | from payload (`error`) | `payments:grant:<credit_type>:<source>` | from payload (`broken`) |\n| `payments.subscription.past_due` | warn | `payments:subscription:<end_user_id>` | broken |\n| `payments.subscription.active` | info | `payments:subscription:<end_user_id>` | **ok** \u2192 posts RESOLVED |\n| `payments.charge.disputed` | warn | `payments:disputes:<provider>` | broken |\n| `job.dead_lettered` | error | `jobs:dead-letter:<job_name>` | broken |\n| `job.dead_letter_quota_exceeded` | error | `jobs:dead-letter-quota` | broken |\n| `job.generation.failed` | error | `jobs:generation:<error_class>` | broken |\n| `functions.quarantined` | error | `functions:<name>` | broken |\n| `functions.quarantine.cleared` | info | `functions:<name>` | **ok** \u2192 posts RESOLVED |\n| `functions.deploy.denied` | warn | `functions:deploy-denied` | broken |\n| `notifications.delivery.dead_lettered` | from payload (`error`) | `notifications:dead-letter:<template_id>` | from payload (`broken`) |\n| `webhooks.delivery.dead_lettered` | from payload (`error`) | `webhooks:delivery:<subscription_id>` | from payload (`broken`) |\n| `webhooks.delivery.recovered` | from payload (`info`) | `webhooks:delivery:<subscription_id>` | **ok** \u2192 posts RESOLVED |\n| `webhooks.inbound.rejected` | from payload (`warn`) | `webhooks:inbound:<source_id>:<reason>` | from payload (`broken`) |\n| `jobs.schedule.missed` | from payload (`warn`) | `jobs:schedule:<schedule_id>` | from payload (**`stale`** \u2014 late, not broken) |\n| `jobs.schedule.recovered` | from payload (`info`) | `jobs:schedule:<schedule_id>` | **ok** \u2192 posts RESOLVED |\n| `functions.run.failed` | from payload (`error`) | `functions:run:<name>` | from payload (`broken`) |\n| `functions.run.recovered` | from payload (`info`) | `functions:run:<name>` | **ok** \u2192 posts RESOLVED |\n\n**Levels differ by event, and the table respects that.** The payments failure events and every\nlifecycle failure event (the last nine rows) carry their own `level` and `state` in the payload, so those\nrules defer to the event. The older `job.*` and `functions.quarantine*` events supply both from the rule.\n\n**Five pairs close their own alerts.** `past_due` \u2192 `active`, `quarantined` \u2192 `quarantine.cleared`,\n`webhooks.delivery.dead_lettered` \u2192 `.recovered`, `jobs.schedule.missed` \u2192 `.recovered` and\n`functions.run.failed` \u2192 `.recovered` share a key, so a recovery posts RESOLVED with no extra rule. Events with no natural \"ok\" \u2014 a dead\nletter \u2014 stay red until the reminder window, which is what `REPEAT_AFTER_HOURS` is for.\n\n**The same failure can arrive twice, on purpose.** Notification sends and outbound webhook deliveries\nride the jobs substrate, so a dead letter also arrives as `job.dead_lettered` with `job_name` set to\n`notifications.deliver` / `webhooks.deliver`. The dedicated `*.dead_lettered` events carry the\nfeature-level context (template id, subscription id, target host) that the generic job row lacks; keep\nboth rules, or drop `job.dead_lettered` if you only want the feature-level view.\n\n**Own the table.** `RULES` is data: add an event name, decide its key and level, push.\n\n## The message\n\n```\n\u{1F534} [ERROR] payments ingest (stripe) \u2192 BROKEN \u2014 signature verification failed (at 2026-09-10T06:00:12Z)\n\u{1F534} [ERROR] job dead-lettered: notifications.deliver \u2192 BROKEN (at 2026-09-10T06:03:44Z)\n\u23F0 STILL BROKEN: function quarantined: checkout (since 2026-09-09T06:01:02Z)\n\u{1F7E2} RESOLVED: payments ingest (stripe) is back to ok (was broken)\n```\n\n## What to learn from this\n\n- **The audit stream is the event spine.** Every feature writes its lifecycle there; a webhook\n subscription fans it out, and a function can drain it. Nothing here needed a new primitive.\n- **Once-per-crossing suppression needs memory \u2014 a cms collection is that memory.** `unique` on `key`\n and `If-Match` on every write make it correct under the at-least-once cron, not just usually right.\n- **Least privilege has a shape.** The function talks to cms through its scoped callback, reads the\n audit stream with an `audit:read`-only key it holds as a secret (or, once you switch it over, through\n its callback with the `audit:read` scope), and can reach exactly one external host. Rotate either\n secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes; 15 on the Free plan) of the audit row landing.\n**Pairs with:** the **payments-heartbeat** blueprint (the silence detector that emits its own crossings)\nand the **push-notifications** blueprint.\n",
19314
19409
  "functions": {
19315
19410
  "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\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst REPEAT_AFTER_HOURS = 24; // remind about a still-red condition this often; 0 = never\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 audit rows \u2014 bounds one cron tick\nconst MAX_POSTS_PER_RUN = 20; // a burst of distinct failures collapses into one summary line\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\ntype Payload = Record<string, unknown>;\n\ninterface Rule {\n /** A literal level, or 'payload' to take `payload.level` (the payments failure\n * events carry one; the jobs/functions ones do not). */\n level: Level | 'payload';\n key: (p: Payload) => string; // one state row per key \u2014 this is what suppresses repeats\n title: (p: Payload) => string;\n /** A literal state, or omitted to take `payload.state` (falling back to 'broken'). */\n state?: State;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\n\n/** THE ALLOW-LIST. Every name below is an audit event a vxil feature writes;\n * anything not listed is ignored by the drain. The KEY is what collapses\n * repeats: ten dead letters of the same job are one key, one row, one alert.\n *\n * Every name below is emitted today (2026-09-10). A rule for an event nobody\n * emits would simply never match \u2014 so if you add one, verify the emitter first.\n */\nconst RULES: Record<string, Rule> = {\n // \u2500\u2500 payments \u2014 the money path. A silent failure here is lost revenue. \u2500\u2500\u2500\u2500\u2500\u2500\n // These three carry `level` (info|warn|error) and `state` (ok|stale|broken|\n // could_not_check) in their payload, so the rule defers to the event.\n 'payments.webhook_event.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:ingest:${str(p.provider, 'all')}`,\n title: (p) => `payments ingest (${str(p.provider, 'all providers')})`,\n },\n 'payments.webhook.rejected': {\n level: 'payload', // emitted as warn/broken\n key: (p) => `payments:webhook-rejected:${str(p.provider)}`,\n title: (p) => `payments webhook rejected (${str(p.provider)})`,\n },\n // NOTE: grant.failed carries NO `provider` \u2014 it is keyed on the credit type\n // and the grant source instead (end_user_id/credit_type/amount/grant_key/\n // source/error). Keying on a field an event does not carry would collapse\n // every provider into one bucket named \"unknown\".\n 'payments.grant.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:grant:${str(p.credit_type, 'credits')}:${str(p.source, 'webhook')}`,\n title: (p) => `entitlement grant failed (${str(p.credit_type, 'credits')} via ${str(p.source, 'webhook')})`,\n },\n // Dunning. `past_due` is the red crossing and `active` is its RESOLVED twin \u2014\n // the same key, so a recovered subscription closes its own alert. Both are\n // emitted at level info, so the rule forces the level it wants.\n 'payments.subscription.past_due': {\n level: 'warn',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'broken',\n },\n 'payments.subscription.active': {\n level: 'info',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'ok',\n },\n // A dispute is a chargeback in progress \u2014 money already collected, now at risk.\n 'payments.charge.disputed': {\n level: 'warn',\n key: (p) => `payments:disputes:${str(p.provider)}`,\n title: (p) => `charge disputed (${str(p.provider)})`,\n state: 'broken',\n },\n\n // \u2500\u2500 jobs \u2014 the delivery substrate. Notification and outbound-webhook sends\n // ride jobs, so THEIR dead letters arrive here too, under `job_name`\n // ('notifications.deliver', 'webhooks.deliver', 'fn-cron:<name>', \u2026).\n // These payloads carry run_id/attempt/job_name and NO level or state, so\n // the rule supplies both. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n title: (p) => `job dead-lettered: ${str(p.job_name)}`,\n state: 'broken',\n },\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n title: () => 'daily dead-letter quota exceeded',\n state: 'broken',\n },\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'failed')}`,\n title: (p) => `generation job failed (${str(p.error_class, 'unclassified')})`,\n state: 'broken',\n },\n\n // \u2500\u2500 functions \u2014 the breaker. Consecutive failures quarantine a function;\n // clearing the quarantine is the ok crossing on the SAME key, so this pair\n // produces exactly one \u{1F534} and one \u{1F7E2}. Payload: name (+ consecutive_failures).\n 'functions.quarantined': {\n level: 'error',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'broken',\n },\n 'functions.quarantine.cleared': {\n level: 'info',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'ok',\n },\n 'functions.deploy.denied': {\n level: 'warn',\n key: () => 'functions:deploy-denied',\n title: (p) => `function deploy denied (needs the ${str(p.required_tier)} tier)`,\n state: 'broken',\n },\n\n // \u2500\u2500 The lifecycle failure events (live since 2026-09-10). Every one of these\n // carries `level` + `state` in its payload (the platform's failure-event\n // vocabulary gate enforces it), so the rules defer to the event. Each\n // failure/recovery pair shares a key, so a recovery posts RESOLVED. \u2500\u2500\u2500\u2500\u2500\u2500\n 'notifications.delivery.dead_lettered': {\n level: 'payload', // emitted as error/broken; payload: delivery_id, template_id, error_class, attempts\n key: (p) => `notifications:dead-letter:${str(p.template_id, 'all')}`,\n title: (p) => `notification delivery dead-lettered (${str(p.template_id, 'all templates')})`,\n },\n 'webhooks.delivery.dead_lettered': {\n level: 'payload', // error/broken; payload: subscription_id, target_host, attempts, error_class\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.delivery.recovered': {\n level: 'payload', // info/ok \u2014 same key as the dead-letter \u2192 RESOLVED\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.inbound.rejected': {\n level: 'payload', // warn/broken; payload: source_id, provider, reason, fingerprint (never the body)\n key: (p) => `webhooks:inbound:${str(p.source_id)}:${str(p.reason)}`,\n title: (p) => `inbound webhook rejected (${str(p.provider, 'source')} ${str(p.source_id)}: ${str(p.reason)})`,\n },\n 'jobs.schedule.missed': {\n level: 'payload', // warn/STALE \u2014 a late tick is stale, not broken; payload: schedule_id, job_name, late_seconds\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n 'jobs.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED on the next on-time fire\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n // A function's cron trigger that produced NO run for 2\xD7 its interval \u2014 the\n // platform's silent-schedule watch (error/stale), and the recovery on the same\n // key. Payload: function, schedule_id, cron, last_run_at, expected_by.\n 'functions.schedule.missed': {\n level: 'payload',\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))} (last run ${str(p.last_run_at, 'never')})`,\n },\n 'functions.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED when a run appears again\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))}`,\n },\n 'functions.run.failed': {\n level: 'payload', // error/broken; payload: name, trigger, run_id, error_class\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n 'functions.run.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n};\n\n// the schedule tick, or a hand invoke (`POST /v1/fn/alerts`) carrying `{ test: true }`\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ test?: boolean }>;\ninterface AuditRow { id: string | number; event: string; payload?: Payload; created_at?: string }\ninterface StateData {\n key: string; state?: State; level?: Level; event?: string; detail?: string;\n cursor?: string; last_event_id?: string; last_alert_at?: string; updated_at?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n const readKey = env.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"
19316
19411
  }
@@ -19333,7 +19428,7 @@ export default defineConfig({
19333
19428
  "slack_webhook_url"
19334
19429
  ],
19335
19430
  "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",
19336
- "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> **Plan note.** The heartbeat function deploys on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n> \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",
19431
+ "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\nmkdir my-heartbeat && cd my-heartbeat\nvxil init --template payments-heartbeat\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend \u2014 read the overlay warning below first)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil secrets set functions/slack_webhook_url # your Slack incoming-webhook URL (hidden prompt)\nvxil secrets set payments/stripe_secret # fresh backend only: the payments block\'s two keys\nvxil secrets set payments/stripe_webhook\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**What you will see first.** A fresh backend has never processed a production payments event, so every\nprovider is skipped (there is no cadence to be silent against) and the first hand invoke answers\n`{ "checked": [], "crossings": 0, "posted": 0 }`. That is the expected answer, not a failure. The\nfunction needs the Slack secret even then (without it the invoke answers `409`). Once a provider has\ndelivered events, you can watch a crossing early: temporarily lower `FLOOR_DAYS` and `ALERT_MULTIPLE` at\nthe top of `functions/heartbeat.ts` until the threshold is below the days since its last event, push,\ninvoke, then put the constants back.\n\n**Try it on Stripe test mode (10 minutes).** Test-mode deliveries are stored as `environment: sandbox`, and\nthe function reads only `environment=production`, so on test keys it never sees an event. To watch the\nwhole loop before you have live traffic:\n\n1. In Stripe\'s test dashboard, add the webhook endpoint\n `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenant id>` (the `tenant:` line quickstart\n printed) and store its signing secret as `payments/stripe_webhook` (above).\n2. Make a few test payments (card `4242 4242 4242 4242`), or run `stripe trigger payment_intent.succeeded`\n a few times with the Stripe CLI.\n3. In `functions/heartbeat.ts`, change `environment=production` to `environment=sandbox` in the\n `webhook-events` URL, and set `FLOOR_DAYS = 0`. With fewer than five gaps there is no median, so the\n threshold is 0 days and any event older than a few minutes reads as `broken`.\n4. `vxil push`, then `vxil functions invoke heartbeat` \u2014 the verdict lists `stripe`, and the first unhealthy\n sighting posts one \u{1F534} message to Slack. Invoke again: no second message (no crossing).\n5. Put `environment=production` and `FLOOR_DAYS = 7` back, and push. Delete the `stripe` row in\n `heartbeat_state` (`vxil api GET /v1/cms/items/heartbeat_state`, then `vxil api DELETE\n /v1/cms/items/heartbeat_state/<item_id>`) so the production check starts from a clean record.\n\n> **Plan note.** The heartbeat function deploys on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n> \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 the **alerts-to-slack** blueprint 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:** the **alerts-to-slack** blueprint \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',
19337
19432
  "functions": {
19338
19433
  "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\n// cron-walk: single-read \u2014 a sample of the newest 100 events per provider is the measurement itself.\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"
19339
19434
  }
@@ -19371,7 +19466,7 @@ export default defineConfig({
19371
19466
  "oidc_client_secret"
19372
19467
  ],
19373
19468
  "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// \"Ops dashboard\" \u2014 a PRIVATE dashboard for one team, wired to the team's own\n// services and stores: a payments provider, a shop or any REST API, the team's\n// other vxil projects, and inbound webhooks. Live KPI tiles, hourly and daily\n// trends, one updates feed, threshold alerts that open incidents, team roles.\n//\n// DISTRIBUTION OVER SHIPPED PRIMITIVES \u2014 there is no connectors feature: a\n// connector is a function + a secret + egressAllow. There is no dashboard\n// engine either: the tiles are cms rows, the trends are cms rows, the feed is\n// cms rows, and your own frontend renders them (examples/apps/team-dashboard).\n//\n// pull \u2192 functions `collect` polls each integration on a cron and writes\n// `metric_latest` (one row per series) + one `metric_points` row\n// per CLOSED hour; `drain-inbound` turns received webhooks into\n// `events_feed` rows\n// push \u2192 `drain-inbound` also binds `payments.` platform events, so the\n// payments integration's lifecycle lands in the feed in seconds\n// evaluate \u2192 `evaluate-alerts` checks `alert_rules` against `metric_latest`\n// and opens/resolves `incidents` once per crossing (+ chat + mail)\n// show \u2192 realtime change-data on three channels; staff read everything\n// with a read-only browser key; every staff WRITE goes through\n// `ops-action`, which checks the caller's org permission first\n//\n// Plan cadence: as shipped this is a Developer-plan config (4 crons, the\n// fastest every minute). The Free plan allows 5 functions and 3 crons, the\n// fastest every 15 minutes: set the two 5-minute schedules to '*/15 * * * *'\n// and drop drain-inbound's cron trigger (its `payments.` trigger still works),\n// or fold compact-daily into evaluate-alerts. See the README's limits table.\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\n/** Org roles allowed to READ the restricted (revenue-grade) numbers. `owner` is\n * the built-in god role; `finance` and `ops-admin` are custom roles you define\n * once over the API (README step 3). An identity-provider group with the same\n * name works too: the IdP's `groups` claim and the org role travel together. */\nconst MONEY_READERS = ['finance', 'ops-admin', 'owner'];\n\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // \u2500\u2500 Staff-only sign-in \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // OIDC-ONLY + allowedDomains IS THE STAFF GATE. Never turn magic link or\n // email sign-up back on in a project holding company data: the collections\n // below are shared (no owner field) and every signed-in person reads all of\n // them, so an open sign-up would let ANY address read every number here.\n auth: {\n methods: { emailPassword: false, magicLink: false },\n providers: {\n oidc: {\n issuer: 'https://login.example-idp.com', // your identity provider (https, no query)\n clientId: 'ops-dashboard', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: 'secret:oidc_client_secret',\n claims: { email: 'email', name: 'name', roles: 'groups' }, // IdP groups \u2192 session roles\n allowedDomains: ['example.com'], // fail-closed: any other domain is refused\n autoLink: true,\n },\n },\n // The member's org role rides the session, so `readRoles` gates without a\n // round-trip. It is a snapshot: revocation-grade checks call orgs (the\n // functions do, before every write).\n orgClaims: { enabled: true },\n session: { ttlMinutes: 60, refreshTtlDays: 7 },\n security: { allowedRedirectOrigins: ['https://ops.example.com', 'http://localhost:5173'] },\n },\n\n // Roles are rows, not config \u2014 the README defines ops-viewer, operator,\n // finance and ops-admin with `POST /v1/orgs/roles` and creates the one org\n // (slug `ops`) every staff member joins.\n orgs: { enabled: true },\n\n realtime: {},\n\n // One alert channel besides chat: an e-mail + in-app message to every org\n // member holding operator, ops-admin or owner, through the built-in\n // `transactional` template (subject / paragraph / call-to-action). The mock\n // provider delivers nothing; switch to your mail provider before relying on it.\n notifications: { provider: 'mock', fromEmail: 'ops@ops-dashboard.example', inboxEnabled: true },\n\n // Inbound webhook sources (one per external service that pushes to you)\n // are runtime rows: `POST /v1/webhooks/sources` \u2014 see the README.\n webhooks: {},\n\n functions: { enabled: true },\n\n cms: {\n // OFF on purpose: these collections have no owner \u2014 staff read all of\n // them \u2014 and the browser key holds NO cms:write, so an end-user session\n // can read but never write. Every write is a function.\n strictEndUserScope: false,\n // An hourly series is 8,760 rows a year; compact-daily prunes hour rows\n // after 90 days. Raise this if you track more than ~150 series.\n limits: { maxItemsPerCollection: 200_000 },\n\n hooks: {\n // The time bucket is derived from the point's own timestamp \u2014 the\n // writer cannot disagree with it. Hour: '2026-10-04T13'; day: '2026-10-04'.\n point_bucket: {\n collection: 'metric_points',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'bucket',\n expr: \"item.grain == 'day' ? substr(item.ts, 0, 10) : substr(item.ts, 0, 13)\",\n },\n // One row per (series, grain, bucket): the unique point_key makes a\n // re-delivered cron tick a clean 409 instead of a duplicate point.\n // Derives run in declaration order, so this sees the bucket above.\n point_key: {\n collection: 'metric_points',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'point_key',\n expr: \"concat(item.series, '|', item.grain, '|', item.bucket)\",\n },\n // Incidents move forward only: open \u2192 acked \u2192 resolved (or open \u2192 resolved).\n incident_stage: {\n collection: 'incidents',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'open' && (item.state == 'acked' || item.state == 'resolved'))\"\n + \" || (before.state == 'acked' && item.state == 'resolved')\",\n message: 'incidents move open \u2192 acked \u2192 resolved',\n },\n rule_op: {\n collection: 'alert_rules',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.op == '>' || item.op == '<' || item.op == '>=' || item.op == '<=' || item.op == 'stale'\",\n message: \"op must be one of > < >= <= stale\",\n },\n },\n\n // Realtime change-data. A FULL frame carries every field of the row to\n // every subscriber \u2014 including a `readRoles` field the subscriber may not\n // read \u2014 so a collection with any readRoles field publishes IDS ONLY and\n // the client re-reads the row (the read applies the gate). The CI gate\n // `cdc-gated-fields` refuses a full frame on such a collection.\n cdc: {\n tiles_live: { collection: 'metric_latest', channel: 'ops:metrics', events: ['created', 'updated'], payload: 'ids' },\n incidents_live: { collection: 'incidents', channel: 'ops:incidents', payload: 'ids' },\n feed_live: { collection: 'events_feed', channel: 'ops:events', events: ['created'], payload: 'full' },\n },\n\n // The DECLARATIVE twin of functions/compact-daily.ts, shown and left off:\n //\n // readModels: {\n // daily_by_series: {\n // collection: 'metric_points', kind: 'aggregate',\n // spec: {\n // aggregates: [{ fn: 'avg', field: 'value', as: 'avg' }, { fn: 'max', field: 'value', as: 'max' }, { fn: 'count' }],\n // groupBy: ['series', 'bucket'], filter: { grain: 'hour' }, window: { field: 'ts', sinceDays: 2 },\n // },\n // // materialize: { cron: '15 0 * * *', to: 'metric_daily' },\n // },\n // },\n //\n // A materialized read model rewrites a rollup collection on a schedule.\n // This blueprint keeps the imperative function instead because it also\n // compacts the gated restricted_value, writes day rows into the SAME\n // collection the charts already read, and prunes old hour rows.\n },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4 / n1\u2013n2 / t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n // One row per connected source. NON-SECRET settings only \u2014 a token never\n // lives here (ops-action strips token-looking keys from every write).\n integrations: {\n singular: 'integration',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' }, // e.g. stripe, shop, billing-project\n kind: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['stripe', 'rest-json', 'vxil-project', 'webhook-source'] } },\n status: { type: 'string', indexSlot: 's3', validation: { enum: ['ok', 'stale', 'broken', 'could_not_check', 'paused'] } },\n owner: { type: 'string', indexSlot: 's4' }, // the person to ask\n consecutive_failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n poll_every_min: { type: 'int', indexSlot: 'n2', validation: { min: 1, max: 1440 } },\n last_ok_at: { type: 'datetime', indexSlot: 't1' },\n next_due_at: { type: 'datetime', indexSlot: 't2' }, // collect reads `next_due_at <= now`, sorted\n label: { type: 'string', validation: { max: 120 } },\n enabled: { type: 'bool', indexed: true },\n // base_url, path, series[] (name, json_path, unit, sensitive), source_id,\n // aggregates[], link_url \u2014 never a credential\n config: { type: 'json' },\n last_error: { type: 'text', validation: { max: 500 } },\n last_error_at: { type: 'datetime' },\n cursor: { type: 'string' }, // webhook sources: the newest event already in the feed\n },\n },\n\n // The trend store: one row per series per CLOSED hour (grain 'hour') and\n // one per day (grain 'day', written by compact-daily). Never raw samples.\n metric_points: {\n singular: 'metric_point',\n fields: {\n series: { type: 'string', required: true, indexSlot: 's1' }, // 'source:metric[:dim]'\n grain: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['hour', 'day'] } },\n bucket: { type: 'string', indexSlot: 's3' }, // derived (point_bucket)\n point_key: { type: 'string', unique: true, indexSlot: 's4' }, // derived (point_key)\n value: { type: 'float', indexSlot: 'n1' },\n restricted_value: { type: 'float', indexSlot: 'n2', readRoles: MONEY_READERS },\n ts: { type: 'datetime', required: true, indexSlot: 't1' }, // the bucket's start\n unit: { type: 'string' },\n samples: { type: 'int' },\n min: { type: 'float' },\n max: { type: 'float' },\n },\n },\n\n // The tiles: one row per series, patched on every collect tick.\n metric_latest: {\n singular: 'metric_latest',\n fields: {\n series: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n source: { type: 'string', indexSlot: 's2' }, // the integration key\n status: { type: 'string', indexSlot: 's3', validation: { enum: ['ok', 'warn', 'crit', 'stale'] } },\n last_bucket: { type: 'string', indexSlot: 's4' }, // the newest hour already in metric_points\n value: { type: 'float', indexSlot: 'n1' },\n restricted_value: { type: 'float', indexSlot: 'n2', readRoles: MONEY_READERS },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n label: { type: 'string' },\n unit: { type: 'string' },\n sensitive: { type: 'bool' }, // true \u21D2 the number lives in restricted_value only\n hour_samples: { type: 'int' },\n hour_min: { type: 'float' }, // non-sensitive series only\n hour_max: { type: 'float' },\n },\n },\n\n // The updates feed. Title + summary only \u2014 never a vendor payload, which\n // can carry personal data, and this collection's change-data is full.\n events_feed: {\n singular: 'event',\n fields: {\n source: { type: 'string', required: true, indexSlot: 's1' },\n kind: { type: 'string', indexSlot: 's2' },\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['info', 'warn', 'error'] } },\n ext_id: { type: 'string', unique: true, indexSlot: 's4' }, // the sender's event id \u21D2 a redelivery is a 409\n occurred_at: { type: 'datetime', indexSlot: 't1' },\n title: { type: 'string', validation: { max: 200 } },\n url: { type: 'string', validation: { max: 500 } },\n summary: { type: 'text', validation: { max: 2000 } },\n },\n },\n\n alert_rules: {\n singular: 'alert_rule',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n series: { type: 'string', required: true, indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3', validation: { enum: ['ok', 'firing'] } },\n severity: { type: 'string', indexSlot: 's4', validation: { enum: ['info', 'warn', 'crit'] } },\n threshold: { type: 'float', indexSlot: 'n1' }, // for 'stale': minutes without a fresh value\n for_minutes: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n last_eval_at: { type: 'datetime', indexSlot: 't1' },\n last_fired_at: { type: 'datetime', indexSlot: 't2' },\n op: { type: 'string', required: true }, // > < >= <= stale (rule_op hook)\n enabled: { type: 'bool', indexed: true },\n channels: { type: 'json' }, // { chat: bool, email: bool }\n breach_since: { type: 'datetime' },\n repeat_after_hours: { type: 'int', validation: { min: 0 } },\n title: { type: 'string', validation: { max: 200 } },\n },\n },\n\n incidents: {\n singular: 'incident',\n fields: {\n rule: { type: 'string', required: true, indexSlot: 's1' },\n state: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['open', 'acked', 'resolved'] } },\n severity: { type: 'string', indexSlot: 's3' },\n acked_by: { type: 'string', indexSlot: 's4' },\n opened_at: { type: 'datetime', indexSlot: 't1' },\n resolved_at: { type: 'datetime', indexSlot: 't2' },\n title: { type: 'string', validation: { max: 200 } },\n last_value: { type: 'float' }, // null for a sensitive series\n note: { type: 'text', validation: { max: 2000 } },\n },\n },\n\n // Function memory: the `__compact__` row holds the last day compact-daily finished.\n ops_state: {\n singular: 'ops_state',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n cursor: { type: 'string' },\n },\n },\n },\n },\n\n // \u2500\u2500 The code that isn't config (guide ch. 8) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // PULL. Every 5 minutes: poll each due integration, write the tiles and the\n // closed hour. Also an http door \u2014 the \"Sync now\" button \u2014 that checks the\n // caller holds `integrations.sync` in the ops org first.\n collect: {\n entry: './functions/collect.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free: '*/15 * * * *'\n triggers: [{ kind: 'http' }],\n scopes: ['cms:read', 'cms:write', 'orgs:read'],\n // Every ref here must hold a value or the function refuses to run\n // (409 secret_missing): drop the sources you do not poll.\n secrets: ['secret:stripe_key', 'secret:shop_token', 'secret:vxil_peer_key'],\n // Deny-by-default egress: every REST host you poll goes here, then\n // `vxil push`. www.githubstatus.com is the keyless demo source. Which\n // host each SECRET may go to is fixed in functions/collect.ts\n // (SECRET_HOST / PEER_SECRETS), not by an integration row.\n egressAllow: ['api.stripe.com', 'shop.example.com', 'www.githubstatus.com'],\n limits: { timeoutMs: 20_000 }, // one slow vendor call fails in 20 s, not 30\n },\n\n // PUSH. Every minute: received webhooks \u2192 feed rows. And within seconds:\n // this project's own payments-integration events (the `payments.` trigger).\n 'drain-inbound': {\n entry: './functions/drain-inbound.ts',\n trigger: { kind: 'cron', schedule: '* * * * *' }, // Free: remove this trigger (3 crons max)\n triggers: [{ kind: 'webhook', source: 'payments.' }],\n scopes: ['cms:read', 'cms:write'],\n // Received webhook events are not on a function's scoped callback, so the\n // drain reads them with a key of THIS project holding only webhooks:read.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n\n // EVALUATE. Every 5 minutes: rules \xD7 latest values \u2192 incidents, once per crossing.\n 'evaluate-alerts': {\n entry: './functions/evaluate-alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free: '*/15 * * * *'\n scopes: ['cms:read', 'cms:write', 'notifications:send', 'orgs:read'],\n secrets: ['secret:slack_webhook_url'],\n // The chat host. Add 'discord.com' for a Discord webhook or\n // 'chat.googleapis.com' for a Google Chat space webhook.\n egressAllow: ['hooks.slack.com'],\n },\n\n // Nightly: hour rows \u2192 one day row per series, then prune old hour rows.\n 'compact-daily': {\n entry: './functions/compact-daily.ts',\n trigger: { kind: 'cron', schedule: '15 0 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // SHOW (the write half). The dashboard's only write door: ack/resolve an\n // incident, edit or toggle a rule, edit or pause an integration \u2014 each\n // gated on an org permission. Audited as the project; the person is\n // recorded in the row (acked_by).\n 'ops-action': {\n entry: './functions/ops-action.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read'],\n egressAllow: [],\n },\n },\n\n // References only \u2014 values are set once with `vxil secrets set` and never appear here.\n secrets: {\n stripe_key: { feature: 'functions', description: 'Stripe RESTRICTED key, read-only on balance and charges' },\n shop_token: { feature: 'functions', description: 'bearer token of the REST API collect polls (rest-json integrations)' },\n vxil_peer_key: { feature: 'functions', description: 'API key of ANOTHER vxil project: usage:read (+ cms:read for aggregates)' },\n vxil_read_key: { feature: 'functions', description: 'API key of THIS project holding only webhooks:read' },\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret of the staff identity provider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'integrations',\n items: [\n {\n key: 'demo', kind: 'rest-json', label: 'Public status page (demo)', status: 'ok', enabled: true,\n poll_every_min: 5, consecutive_failures: 0, next_due_at: '2026-01-01T00:00:00Z',\n config: {\n base_url: 'https://www.githubstatus.com', path: '/api/v2/incidents/unresolved.json', auth: 'none',\n series: [{ name: 'open_incidents', json_path: 'incidents.length', unit: 'count' }],\n },\n },\n {\n key: 'stripe', kind: 'stripe', label: 'Payments provider', status: 'paused', enabled: false,\n poll_every_min: 5, consecutive_failures: 0, config: {},\n },\n {\n key: 'shop', kind: 'rest-json', label: 'Shop API', status: 'paused', enabled: false,\n poll_every_min: 15, consecutive_failures: 0,\n config: {\n base_url: 'https://shop.example.com', path: '/api/stats',\n series: [\n { name: 'orders_1h', json_path: 'orders.last_hour', unit: 'count' },\n { name: 'revenue_1h', json_path: 'revenue.last_hour', unit: 'usd', sensitive: true },\n ],\n },\n },\n {\n key: 'billing-project', kind: 'vxil-project', label: 'Billing backend (another vxil project)', status: 'paused',\n enabled: false, poll_every_min: 60, consecutive_failures: 0,\n config: { aggregates: [{ name: 'signups_24h', collection: 'accounts', fn: 'count', window_hours: 24 }] },\n },\n {\n key: 'support-inbox', kind: 'webhook-source', label: 'Support tool webhooks', status: 'paused', enabled: false,\n poll_every_min: 1, consecutive_failures: 0, config: { source_id: 'whs_replace_me' },\n },\n ],\n },\n {\n collection: 'alert_rules',\n items: [\n {\n key: 'payment-error-rate', series: 'stripe:charge_error_rate_1h', op: '>', threshold: 5, for_minutes: 10,\n severity: 'crit', state: 'ok', enabled: true, repeat_after_hours: 4, channels: { chat: true, email: true },\n title: 'Payment error rate above 5%',\n },\n {\n key: 'orders-dry', series: 'shop:orders_1h', op: '<', threshold: 1, for_minutes: 60,\n severity: 'warn', state: 'ok', enabled: true, repeat_after_hours: 24, channels: { chat: true, email: false },\n title: 'No orders in the last hour',\n },\n {\n key: 'demo-stale', series: 'demo:open_incidents', op: 'stale', threshold: 30, for_minutes: 0,\n severity: 'warn', state: 'ok', enabled: true, repeat_after_hours: 24, channels: { chat: true, email: false },\n title: 'Status data older than 30 minutes',\n },\n ],\n },\n ],\n },\n});\n",
19374
- "readme": '# Ops dashboard (ops)\n\nA **private dashboard for one team**, wired to the team\'s own services and stores: your payments provider, a\nshop or any REST API, your other vxil projects, and the webhooks those services send you. It shows **live KPI\ntiles**, **hourly and daily trends**, **one updates feed**, **threshold alerts that open incidents** (once per\ncrossing, with a chat and an e-mail message), and **team roles** \u2014 revenue-grade numbers are readable only by the\nroles you name. Staff sign in through your identity provider and nothing else.\n\nIt is distribution over shipped building blocks. There is **no connectors feature**: a connector is a function +\na secret + a host in `egressAllow`. There is no dashboard engine either: every tile, point, feed entry and\nincident is a cms row, and your own frontend renders them \u2014 the reference frontend is\n`examples/apps/team-dashboard`.\n\nWhat it teaches that no other blueprint does: **time series on the cms** (closed-hour writes, a derived bucket,\nnightly compaction), **gated numbers** (`readRoles` + change-data that never carries them), and a **read-only\nbrowser** whose every write goes through one permission-checked function.\n\n## 1. The shape\n\n```\n your services \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 vxil project (this blueprint) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 your frontend\n (examples/apps/team-dashboard)\n payments provider \u2500\u2510 PULL \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 metric_latest \u2500\u2500 tiles \u2500\u2500\u2510\n REST / shop API \u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 collect \u2502 metric_points \u2500\u2500 trends \u2500\u2524 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n other vxil project \u2518 (cron) \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u251C\u2500\u2500\u25B6\u2502 read-only key \u2502\n \u25B2 compact-daily (nightly) \u2502 \u2502 + staff session \u2502\n inbound webhooks \u2500\u2500\u2510 PUSH \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 \u2502 + realtime \u2502\n payments events \u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 drain-inbound\u2502 events_feed \u2500\u2500 feed \u2500\u2500\u2524 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 \u2502 every write\n EVALUATE \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 incidents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u25BC\n alert_rules \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 evaluate-alerts\u2502\u2500\u2500\u25B6 chat + e-mail \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 ops-action \u2502 (org permission\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 checked first)\n```\n\nThe four arrows:\n\n| Arrow | Who | How |\n|---|---|---|\n| **Pull** | `collect`, every 5 minutes | polls each due integration, patches `metric_latest`, writes one `metric_points` row per closed hour |\n| **Push** | `drain-inbound`, every minute + on `payments.` events | received webhooks and the payments integration\'s events \u2192 `events_feed` |\n| **Evaluate** | `evaluate-alerts`, every 5 minutes | `alert_rules` \xD7 `metric_latest` \u2192 `incidents`, chat, e-mail \u2014 once per crossing |\n| **Show** | realtime change-data + your frontend | staff read every collection; the only write door is `ops-action` |\n\n## 2. Apply it\n\n```bash\nmkdir ops-dashboard && cd ops-dashboard && vxil init --template ops-dashboard # init scaffolds into the current directory\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging` for an existing project\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# the secrets (values never touch the config) \u2014 every ref a function declares must hold a value (below):\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nprintf \'%s\' "$STRIPE_RK" | vxil secrets set functions/stripe_key # a RESTRICTED, read-only key\nprintf \'%s\' "$SHOP_TOKEN" | vxil secrets set functions/shop_token\nprintf \'%s\' "$PEER_KEY" | vxil secrets set functions/vxil_peer_key # a key of ANOTHER project\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key # a key of THIS project: webhooks:read\nprintf \'%s\' "$SLACK_URL" | vxil secrets set functions/slack_webhook_url\nvxil push # collections, hooks, change-data, the five functions\nvxil seed # the demo integration + three example rules\nvxil functions invoke collect # one tick by hand: the demo source fills its first tile\n```\n\n**Every secret a function declares must hold a value**, or that function refuses to run\n(`409 secret_missing`, naming the ref). Before the push, delete from `collect`\'s `secrets` the sources you do\nnot poll (and their `egressAllow` hosts) \u2014 or store a placeholder value for a source you will add later.\n\nAlso replace the placeholders in `vxil.config.ts`: the OIDC `issuer`, `clientId` and\n`allowedDomains`, the `allowedRedirectOrigins` of your frontend, and `DASHBOARD_URL` in\n`functions/evaluate-alerts.ts`.\n\n| Secret | What it is | Least privilege |\n|---|---|---|\n| `oidc_client_secret` | your identity provider\'s client secret for this app | \u2014 |\n| `stripe_key` | a payments-provider **restricted** key | read on Balance and Charges, nothing else |\n| `shop_token` | the bearer token of the REST API a `rest-json` integration polls | a read-only token |\n| `vxil_peer_key` | an API key of **another** vxil project you own | `usage:read`, plus `cms:read` if you aggregate its collections |\n| `vxil_read_key` | an API key of **this** project | `webhooks:read` only \u2014 received events are not on a function\'s scoped callback |\n| `slack_webhook_url` | a chat incoming-webhook URL | \u2014 |\n\n`vxil_peer_key` and `vxil_read_key` are minted with `vxil keys mint --name <name> --scopes <a,b>` in the project\nthey belong to. `vxil keys mint` acts on your account, so it needs a dashboard session: run `vxil login` once\nfirst (a project key from `vxil quickstart` / `vxil link` is not enough). `vxil link --key` stores the key you give it in `~/.vxil/credentials.json` (so does `vxil login` with its session): treat that file as a secret \u2014 never commit it, copy it into an image or share it.\n\n## 3. Staff-only sign-in, and the roles\n\n`auth` here is **OIDC-only**: `methods.emailPassword` and `methods.magicLink` are off, and\n`providers.oidc.allowedDomains` lists your company domain. A sign-in whose verified e-mail is not on that list\nis refused (`403 oidc_domain_not_allowed`), and with no other method there is no other way in. **That is the\nstaff gate.** Do not turn magic link or e-mail sign-up back on in a project holding company data: these\ncollections have no owner field, every signed-in person reads all of them, so an open sign-up would let any\naddress on the internet read every number on the dashboard.\n\nRoles live in one org. Create it (slug `ops` \u2014 the functions look it up by slug) and the four custom roles once,\nwith a server key that holds `orgs:write`:\n\n```bash\nvxil api POST /v1/orgs --data \'{"slug":"ops","name":"Ops","owner_user_id":"<your user id>"}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"ops-viewer","name":"Viewer","permissions":["dashboard.read"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"operator","name":"Operator","permissions":["dashboard.read","incidents.manage","integrations.sync"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["dashboard.read","revenue.read"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"ops-admin","name":"Ops admin","permissions":["dashboard.read","revenue.read","incidents.manage","integrations.sync","rules.manage","integrations.manage"]}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<user id>","role":"operator"}\'\n```\n\n`viewer` and `admin` are built-in role names with fixed permission sets, which is why the custom ones are\n`ops-viewer` and `ops-admin`. The built-in `owner` holds every permission. The permission strings are yours \u2014\nvxil only answers whether a member holds one (`GET /v1/orgs/{org}/check`).\n\nWith `orgClaims` on, the member\'s role rides the session, and the IdP\'s `groups` claim (named in\n`claims.roles`) rides beside it: a group called `finance` in your identity provider satisfies the same gate as\nthe org role.\n\n## 4. The browser keys\n\nMint two keys in the dashboard under **Keys \u2192 New key** (or with `vxil keys mint` after `vxil login`):\n\n| Key | Class | Scopes | Why |\n|---|---|---|---|\n| `web-auth` | Server | `auth:signin` | signing in is how a session is obtained, so this key cannot require one |\n| `web-data` | Public / thin-client | `cms:read`, `realtime:write`, `functions:invoke` | refused by the edge without a valid staff session |\n\n`web-data` holds **no `cms:write`**. A staff member can read every collection, mint a realtime connect token\nfor a channel (that is what `realtime:write` is for on a thin-client key \u2014 publishing over REST is refused to\nan end-user session) and call functions. Every write \u2014 acknowledging an incident, editing a rule, pausing an\nintegration \u2014 is `POST /v1/fn/ops-action`, which asks orgs whether this person holds the permission before it\nwrites. The write is audited as the project (the function writes with the project\'s cms scope, so the audit\nrow does not name the person); the human is recorded in the row itself \u2014 `acked_by` on an incident:\n\n```ts\nawait vx.asEndUser(session).fn[\'ops-action\']({ op: \'ack\', incident_id: \'itm_\u2026\', note: \'looking\' });\n// 403 { error: \'role_required\', permission: \'incidents.manage\' } for a viewer\n```\n\n| `op` | Permission | Body |\n|---|---|---|\n| `ack`, `resolve` | `incidents.manage` | `{ incident_id, note? }` \u2014 a compare-and-set on the state the button showed |\n| `upsert_rule` | `rules.manage` | `{ rule: { key, series, op, threshold, for_minutes?, severity?, repeat_after_hours?, channels?, title? } }` |\n| `toggle_rule` | `rules.manage` | `{ key, enabled }` |\n| `update_integration` | `integrations.manage` | `{ integration: { key, label?, owner?, poll_every_min?, config? } }` |\n| `pause_integration` | `integrations.manage` | `{ key, paused }` |\n\n"Sync now" is `POST /v1/fn/collect { integration: \'<key>\' }`, gated on `integrations.sync`.\n\n`update_integration` strips every config key that looks like a credential (`key`, `token`, `secret`,\n`password`, `authorization`, \u2026) and every value that looks like a bearer token, and names what it dropped. A\ncredential is a project secret, set with `vxil secrets set`, never from a browser.\n\nThe three settings that decide **where a credential is sent** \u2014 `config.base_url`, `config.auth` and\n`config.peer` \u2014 are not editable through `ops-action` at all: a change answers `409 locked_config` and an edit\nthat leaves them out keeps the stored values. They change only with a server key (`vxil api PATCH\n/v1/cms/items/integrations/<id>`, or a re-seed). `collect` enforces the same thing on its side, in code: each\nsecret is bound to one host (`SECRET_HOST` in `functions/collect.ts` \u2014 `shop_token` \u2192 your API\'s host;\n`stripe_key` \u2192 the payments provider\'s API; peer keys \u2192 the vxil API only, and only the names in\n`PEER_SECRETS`). A row that would send a credential anywhere else turns `broken` without a request being made.\n\n## 5. Adding a source\n\nA source is three edits: a **case in the `adapter` switch** in `functions/collect.ts`, the **host in\n`egressAllow`**, and a **secret**. Then `vxil push`. The egress guard is deny-by-default: a host you forgot\nanswers `403` and the integration turns `broken` with `egress blocked: add <host> to egressAllow and push`.\n\nBefore writing code, try the generic `rest-json` kind \u2014 a row, no code:\n\n```json\n{ "key": "shop", "kind": "rest-json", "enabled": true, "poll_every_min": 5,\n "next_due_at": "2026-01-01T00:00:00Z",\n "config": { "base_url": "https://shop.example.com", "path": "/api/stats",\n "series": [ { "name": "orders_1h", "json_path": "orders.last_hour", "unit": "count" },\n { "name": "revenue_1h", "json_path": "revenue.last_hour", "unit": "usd", "sensitive": true } ] } }\n```\n\nIt sends `Authorization: Bearer <shop_token>` (`"auth": "none"` for a public endpoint) and reads each\n`json_path` (`a.b.0.c`; a trailing `.length` counts an array). Series are named `<integration key>:<name>`.\nThe token goes **only** to the host in `SECRET_HOST.shop_token` at the top of `functions/collect.ts` \u2014 set it to\nyour API\'s host before the push; a `rest-json` row with a token pointing at any other host is refused. A second\nauthenticated REST API is a second secret and its own `case` (below), never a second row reusing `shop_token`.\n\n**A REST API with its own token** \u2014 one secret per vendor, one case:\n\n```ts\ncase \'helpdesk\': {\n const r = await getJson(\'https://api.helpdesk.example/v2/tickets/count?status=open\',\n { authorization: `Bearer ${secrets.helpdesk_token}` }) as { count: number };\n return [{ series: \'helpdesk:open_tickets\', value: r.count, unit: \'count\' }];\n}\n```\n\n**An analytics API that wants a service-account JWT** \u2014 sign it with WebCrypto inside the function (no SDK, no\nNode), then trade it for an access token:\n\n```ts\nasync function serviceToken(sa: { client_email: string; private_key: string }, scope: string): Promise<string> {\n const b64 = (s: string | ArrayBuffer) => btoa(typeof s === \'string\' ? s : String.fromCharCode(...new Uint8Array(s)))\n .replace(/=+$/, \'\').replace(/\\+/g, \'-\').replace(/\\//g, \'_\');\n const now = Math.floor(Date.now() / 1000);\n const unsigned = `${b64(JSON.stringify({ alg: \'RS256\', typ: \'JWT\' }))}.${b64(JSON.stringify({\n iss: sa.client_email, scope, aud: \'https://oauth2.example.com/token\', iat: now, exp: now + 3600 }))}`;\n const der = Uint8Array.from(atob(sa.private_key.replace(/-----[^-]+-----|\\s/g, \'\')), (c) => c.charCodeAt(0));\n const key = await crypto.subtle.importKey(\'pkcs8\', der, { name: \'RSASSA-PKCS1-v1_5\', hash: \'SHA-256\' }, false, [\'sign\']);\n const sig = await crypto.subtle.sign(\'RSASSA-PKCS1-v1_5\', key, new TextEncoder().encode(unsigned));\n const res = await fetch(\'https://oauth2.example.com/token\', { method: \'POST\',\n headers: { \'content-type\': \'application/x-www-form-urlencoded\' },\n body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${unsigned}.${b64(sig)}` });\n return ((await res.json()) as { access_token: string }).access_token;\n}\n```\n\nStore the service-account JSON as one secret, and add both the token host and the API host to `egressAllow`.\n\n**A SQL query over an HTTPS SQL endpoint** \u2014 the function is the SQL client; vxil never connects to your\ndatabase. The pattern, the credential handling and the least-privilege database user are in\n`examples/byo-db` (https://vxil.com/docs/guide/08-running-your-code-functions#your-own-database-from-a-function):\n\n```ts\ncase \'warehouse\': {\n const rows = await sql(secrets.database_url, \'select count(*)::int as n from orders where created_at > now() - interval \\\'1 hour\\\'\');\n return [{ series: \'warehouse:orders_1h\', value: rows[0].n, unit: \'count\' }];\n}\n```\n\n**A tool that pushes instead of being polled** \u2014 create an inbound webhook source and point the tool at its\nreceiver URL; then add an integration row of kind `webhook-source` with `config.source_id`:\n\n```ts\nconst { source_id, receiver_url_path } = await vx.webhooks.sources.create({ provider: \'generic\', name: \'support tool\' });\n// the receiver URL is returned once \u2014 paste it into the tool\'s webhook settings\n```\n\n## 6. Time series on the cms\n\n- **Write closed hours, not samples.** Every collect tick patches the series\' `metric_latest` row (value,\n `updated_at`, the hour\'s sample count and min/max). When a tick finds the stored sample belongs to an hour that\n has ended, it first writes that hour\'s closing sample as one `metric_points` row (`grain: \'hour\'`), then\n records the hour in `last_bucket`.\n- **The bucket is derived.** The `point_bucket` hook derives `bucket` from `ts` (`2026-10-04T13` for an hour,\n `2026-10-04` for a day) and `point_key` concatenates series, grain and bucket into a `unique` field \u2014 so a\n redelivered tick or two overlapping ticks produce a `409`, never a second point. Both are Lane-A `derive`\n hooks, not `validate`: the server computes the value on every write, so a writer that sends a wrong bucket (or\n none) is overwritten rather than refused, and no writer \u2014 `collect`, `compact-daily`, a backfill script \u2014 can\n file a point under the wrong hour. Keep them `derive` in any copy of this config.\n- **Compaction.** `compact-daily` aggregates yesterday\'s hour rows per series on the server (avg, min, max,\n count) into one `grain: \'day\'` row, then deletes hour rows older than 90 days and feed rows older than 30\n (the bounded filtered delete, 100 rows a call). Charts read `grain: \'hour\'` for the last days and\n `grain: \'day\'` beyond.\n- **The 50,000-row scan cap.** A server-side aggregate refuses (`422`) rather than scanning more than 50,000 rows.\n One day of hour rows is `series \xD7 24`, so compaction stays far below it even at 500 series; an aggregate over\n a year of raw 5-minute samples (105,120 per series) would not fit even for one series.\n- **Why not raw points.** Every cms write is an audited row and a change-data frame, and every call a function\n makes back into vxil counts as a request. A sample every 5 minutes is 288 rows a day per series; a closed hour\n is 24 \u2014 twelve times fewer writes, frames and requests, and twelve times fewer rows under every scan.\n\nThe config also shows (commented) the declarative twin of `compact-daily`, a `readModels` aggregate. It is left\noff because the function also compacts the gated value, writes into the collection the charts already read, and\nprunes.\n\n## 7. Sensitive numbers\n\nA series marked `sensitive` (revenue, balances) writes its number to `restricted_value`, which declares\n`readRoles: [\'finance\', \'ops-admin\', \'owner\']`; `value` stays null. A signed-in staff member without one of those\nroles gets the row with `restricted_value` **absent** and cannot filter, sort or group by it. Your server key\nand the functions (which run as the server) still see it, so alerts evaluate it \u2014 but a sensitive series\' number\nnever goes into a chat message, an e-mail or an incident row.\n\n**Change-data frames do not apply `readRoles`.** A `payload: \'full\'` frame carries the whole row to every\nsubscriber of the channel. So `metric_latest` and `incidents` publish **ids only** and the client re-reads the\nrow through the cms, where the gate applies; only `events_feed`, which has no gated field, publishes full rows.\nThe CI gate `cdc-gated-fields` refuses a full frame on any collection that declares a `readRoles` field.\n\n## 8. Alerts once per crossing\n\nA rule is `{ series, op, threshold, for_minutes, severity, repeat_after_hours, channels }`; `op` is `>`, `<`,\n`>=`, `<=` or `stale` (no fresh value for `threshold` minutes). `evaluate-alerts`:\n\n1. on a new breach stores `breach_since` and waits `for_minutes`;\n2. when the breach is sustained, creates the incident under `lock: \'incident:<rule>\'` with\n `guard: { filter: { rule, state: { $in: [\'open\', \'acked\'] } }, max: 1 }` \u2014 of two overlapping ticks exactly\n one gets a `201`, and only that one posts the chat message and sends the e-mail (the built-in `transactional`\n template, to every `ops` member holding `operator`, `ops-admin` or `owner`);\n3. stays quiet while it stays red, except one reminder every `repeat_after_hours`;\n4. if the incident create fails for any other reason (a `5xx`, a hook\'s `422`), leaves the rule `ok` with its\n `breach_since` and counts it in `failed` \u2014 the next tick crosses again, so the alert is delayed, never lost;\n5. on recovery flips the rule back (an `If-Match` on its version picks the one tick that does it), resolves the\n open incident and posts one RESOLVED message.\n\nThe chat poster sends Slack\'s `text` and Discord\'s `content`; for Discord add `discord.com` to `egressAllow`; a\nGoogle Chat space webhook (`chat.googleapis.com`, also added to `egressAllow`) gets `{ text }` alone.\n\n## 9. Reading your other vxil projects\n\nAn integration of kind `vxil-project` reads another project with **that project\'s own key** \u2014 one key per\nproject, minted there with the least it needs (`usage:read` for its request meter, `cms:read` to aggregate its\ncollections). It is pull, not push: the other project does not know this dashboard exists, and revoking the key\nthere disconnects it. A second project gets a second secret: declare `secret:<name>` on `collect`, add the name\nto `PEER_SECRETS` in `functions/collect.ts`, and point the row at it with `config.peer` (any name not on that\nlist is refused, so a row cannot pick some other secret and send it as a bearer key).\n\n```json\n{ "key": "billing-project", "kind": "vxil-project", "enabled": true, "poll_every_min": 60,\n "config": { "aggregates": [ { "name": "signups_24h", "collection": "accounts", "fn": "count", "window_hours": 24 } ] } }\n```\n\n## 10. Limits and plans\n\n| | Free (staging / development projects) | Developer |\n|---|---|---|\n| Functions / crons | 5 functions, 3 crons, fastest every 15 min | 50 functions, 20 crons, every minute |\n| As shipped | set the two `*/5` schedules (`collect`, `evaluate-alerts`) to `*/15`; drop `drain-inbound`\'s cron trigger (its `payments.` trigger stays) | runs as shipped |\n| Requests a month | 100,000 | 1,000,000 |\n\nEvery call a function makes back into vxil counts as a request (vendor calls do not). The arithmetic for 3\npolled integrations \xD7 5 series, a 30-day month:\n\n| Function | Per run | Runs a month | Requests |\n|---|---|---|---|\n| `collect` every 5 min | 1 list + per integration (1 list + 5 tile patches + 1 status patch) = 22 | 8,640 | ~190,000 |\n| hour closes | 15 series \xD7 1 point | 720 hours | ~11,000 |\n| `evaluate-alerts` every 5 min | 1 rules read + 1 latest read (+ writes on crossings only) | 8,640 | ~17,000 |\n| `drain-inbound` every minute, 1 source | 1 list + 1 events read (+ 1 write per event) | 43,200 | ~86,000 + events |\n| `compact-daily` | 2 aggregates + 15 day rows + retention deletes | 30 | ~1,500 |\n\nAbout **305,000 requests a month** on Developer. On Free, at 15-minute cadence with 2 integrations \xD7 4 series and\nno inbound drain: `collect` ~37,000, `evaluate-alerts` ~6,000, hour closes ~6,000 \u2014 about **50,000**. A tile list\nread by your frontend counts too, so let realtime change-data tell the page when to re-read instead of polling.\n\n## 11. Compose with\n\n- **alerts-to-slack** \u2014 alerts on the platform\'s own failure events (a dead-lettered job, a quarantined\n function, a failing payments webhook) next to this dashboard\'s business thresholds.\n- **service-monitor** \u2014 uptime checks for your endpoints, written into the same kind of rows.\n- **team-workspace** \u2014 the full org model: invitations, per-resource grants, a field only finance receives.\n\n## 12. Where the frontend lives\n\nThe **App builder** builds public-facing apps. An internal dashboard is your own frontend \u2014 any framework,\nany host \u2014 reading this project with the two keys above. The reference is `examples/apps/team-dashboard`:\nsign-in through your identity provider, tiles that re-read on change-data frames, trend charts over\n`metric_points`, the feed, and the incident buttons calling `ops-action`.\n',
19469
+ "readme": '# Ops dashboard (ops)\n\nA **private dashboard for one team**, wired to the team\'s own services and stores: your payments provider, a\nshop or any REST API, your other vxil projects, and the webhooks those services send you. It shows **live KPI\ntiles**, **hourly and daily trends**, **one updates feed**, **threshold alerts that open incidents** (once per\ncrossing, with a chat and an e-mail message), and **team roles** \u2014 revenue-grade numbers are readable only by the\nroles you name. Staff sign in through your identity provider and nothing else.\n\nIt is distribution over shipped building blocks. There is **no connectors feature**: a connector is a function +\na secret + a host in `egressAllow`. There is no dashboard engine either: every tile, point, feed entry and\nincident is a cms row, and your own frontend renders them \u2014 the reference frontend is the\n`team-dashboard` showcase app (https://vxil.com/showcase, downloadable as a zip).\n\nWhat it teaches that no other blueprint does: **time series on the cms** (closed-hour writes, a derived bucket,\nnightly compaction), **gated numbers** (`readRoles` + change-data that never carries them), and a **read-only\nbrowser** whose every write goes through one permission-checked function.\n\n## 1. The shape\n\n```\n your services \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 vxil project (this blueprint) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 your frontend\n (showcase app: team-dashboard)\n payments provider \u2500\u2510 PULL \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 metric_latest \u2500\u2500 tiles \u2500\u2500\u2510\n REST / shop API \u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 collect \u2502 metric_points \u2500\u2500 trends \u2500\u2524 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n other vxil project \u2518 (cron) \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u251C\u2500\u2500\u25B6\u2502 read-only key \u2502\n \u25B2 compact-daily (nightly) \u2502 \u2502 + staff session \u2502\n inbound webhooks \u2500\u2500\u2510 PUSH \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 \u2502 + realtime \u2502\n payments events \u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 drain-inbound\u2502 events_feed \u2500\u2500 feed \u2500\u2500\u2524 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 \u2502 every write\n EVALUATE \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 incidents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u25BC\n alert_rules \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 evaluate-alerts\u2502\u2500\u2500\u25B6 chat + e-mail \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 ops-action \u2502 (org permission\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 checked first)\n```\n\nThe four arrows:\n\n| Arrow | Who | How |\n|---|---|---|\n| **Pull** | `collect`, every 5 minutes | polls each due integration, patches `metric_latest`, writes one `metric_points` row per closed hour |\n| **Push** | `drain-inbound`, every minute + on `payments.` events | received webhooks and the payments integration\'s events \u2192 `events_feed` |\n| **Evaluate** | `evaluate-alerts`, every 5 minutes | `alert_rules` \xD7 `metric_latest` \u2192 `incidents`, chat, e-mail \u2014 once per crossing |\n| **Show** | realtime change-data + your frontend | staff read every collection; the only write door is `ops-action` |\n\n## 2. Apply it\n\n```bash\nmkdir ops-dashboard && cd ops-dashboard && vxil init --template ops-dashboard # init scaffolds into the current directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: make \xA710\'s cron edits in vxil.config.ts now (4 crons, one every minute, do not fit Free)\n# the secrets (values never touch the config) \u2014 every ref a function declares must hold a value (below):\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nprintf \'%s\' "$STRIPE_RK" | vxil secrets set functions/stripe_key # a RESTRICTED, read-only key\nprintf \'%s\' "$SHOP_TOKEN" | vxil secrets set functions/shop_token\nprintf \'%s\' "$PEER_KEY" | vxil secrets set functions/vxil_peer_key # a key of ANOTHER project\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key # a key of THIS project: webhooks:read\nprintf \'%s\' "$SLACK_URL" | vxil secrets set functions/slack_webhook_url\nvxil push # collections, hooks, change-data, the five functions\nvxil seed # the demo integration + three example rules\nvxil functions invoke collect # one tick by hand: the demo source fills its first tile\n```\n\n**Every secret a function declares must hold a value**, or that function refuses to run\n(`409 secret_missing`, naming the ref). Before the push, delete from `collect`\'s `secrets` the sources you do\nnot poll (and their `egressAllow` hosts) \u2014 or store a placeholder value for a source you will add later.\n\nAlso replace the placeholders in `vxil.config.ts`: the OIDC `issuer`, `clientId` and\n`allowedDomains`, the `allowedRedirectOrigins` of your frontend, and `DASHBOARD_URL` in\n`functions/evaluate-alerts.ts`.\n\n| Secret | What it is | Least privilege |\n|---|---|---|\n| `oidc_client_secret` | your identity provider\'s client secret for this app | \u2014 |\n| `stripe_key` | a payments-provider **restricted** key | read on Balance and Charges, nothing else |\n| `shop_token` | the bearer token of the REST API a `rest-json` integration polls | a read-only token |\n| `vxil_peer_key` | an API key of **another** vxil project you own | `usage:read`, plus `cms:read` if you aggregate its collections |\n| `vxil_read_key` | an API key of **this** project | `webhooks:read` only. The shipped `drain-inbound` reads received events with it; that is a holdover, not a platform limit \u2014 a function that declares `webhooks:read` reads received events (including the `after` drain) through its own scoped callback, with no stored key |\n| `slack_webhook_url` | a chat incoming-webhook URL | \u2014 |\n\n`vxil_peer_key` and `vxil_read_key` are minted with `vxil keys mint --name <name> --scopes <a,b>` in the project\nthey belong to. `vxil keys mint` acts on your account, so it needs a dashboard session: run `vxil login` once\nfirst (a project key from `vxil quickstart` / `vxil link` is not enough). `vxil link --key` stores the key you give it in `~/.vxil/credentials.json` (so does `vxil login` with its session): treat that file as a secret \u2014 never commit it, copy it into an image or share it.\n\n## 3. Staff-only sign-in, and the roles\n\n`auth` here is **OIDC-only**: `methods.emailPassword` and `methods.magicLink` are off, and\n`providers.oidc.allowedDomains` lists your company domain. A sign-in whose verified e-mail is not on that list\nis refused (`403 oidc_domain_not_allowed`), and with no other method there is no other way in. **That is the\nstaff gate.** Do not turn magic link or e-mail sign-up back on in a project holding company data: these\ncollections have no owner field, every signed-in person reads all of them, so an open sign-up would let any\naddress on the internet read every number on the dashboard.\n\nRoles live in one org. Create it (slug `ops` \u2014 the functions look it up by slug) and the four custom roles once,\nwith a server key that holds `orgs:write`:\n\nEach person signs in once through your identity provider first \u2014 that sign-in creates their user id\n(start it with `vx.auth.oauth.startUrl(\'oidc\', { redirect_uri })`; vxil.com/docs/guide/06-feature-catalog,\n"Starting social or SSO sign-in from a browser app"). Then look ids up by e-mail. Trying it with no\nidentity provider yet? `vxil api POST /v1/users --data \'{"id":"usr_me","email":"you@yourco.com"}\'` creates a\nrow you can use as `$ME` (it cannot sign in, but the org and role checks work).\n\n```bash\nME=$(vxil api GET "/v1/users?email=you@yourco.com" | jq -r \'.users[0].id\')\nOPS=$(vxil api GET "/v1/users?email=operator@yourco.com" | jq -r \'.users[0].id\')\nORG=$(vxil api POST /v1/orgs --data \'{"slug":"ops","name":"Ops","owner_user_id":"\'$ME\'"}\' | jq -r .org_id)\nvxil api POST /v1/orgs/roles --data \'{"role_key":"ops-viewer","name":"Viewer","permissions":["dashboard.read"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"operator","name":"Operator","permissions":["dashboard.read","incidents.manage","integrations.sync"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["dashboard.read","revenue.read"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"ops-admin","name":"Ops admin","permissions":["dashboard.read","revenue.read","incidents.manage","integrations.sync","rules.manage","integrations.manage"]}\'\nvxil api POST /v1/orgs/$ORG/members --data \'{"user_id":"\'$OPS\'","role":"operator"}\'\n```\n\n`owner`, `admin`, `member` and `viewer` are built-in role names with fixed permission sets, which is why the custom ones are\n`ops-viewer` and `ops-admin`. The built-in `owner` holds every permission. The permission strings are yours \u2014\nvxil only answers whether a member holds one (`GET /v1/orgs/{org}/check`).\n\nWith `orgClaims` on, the member\'s role rides the session, and the IdP\'s `groups` claim (named in\n`claims.roles`) rides beside it: a group called `finance` in your identity provider satisfies the same gate as\nthe org role.\n\n## 4. The browser keys\n\nMint two keys in the dashboard under **Keys \u2192 New key** (or with `vxil keys mint` after `vxil login`):\n\n| Key | Class | Scopes | Why |\n|---|---|---|---|\n| `web-auth` | Server | `auth:signin` | signing in is how a session is obtained, so this key cannot require one |\n| `web-data` | Public / thin-client | `cms:read`, `realtime:write`, `functions:invoke` | refused by the edge without a valid staff session |\n\n`web-data` holds **no `cms:write`**. A staff member can read every collection, mint a realtime connect token\nfor a channel (that is what `realtime:write` is for on a thin-client key \u2014 publishing over REST is refused to\nan end-user session) and call functions. Every write \u2014 acknowledging an incident, editing a rule, pausing an\nintegration \u2014 is `POST /v1/fn/ops-action`, which asks orgs whether this person holds the permission before it\nwrites. The write is audited as the project (the function writes with the project\'s cms scope, so the audit\nrow does not name the person); the human is recorded in the row itself \u2014 `acked_by` on an incident:\n\n```ts\nawait vx.asEndUser(session).fn[\'ops-action\']({ op: \'ack\', incident_id: \'itm_\u2026\', note: \'looking\' });\n// 403 { error: \'role_required\', permission: \'incidents.manage\' } for a viewer\n```\n\n| `op` | Permission | Body |\n|---|---|---|\n| `ack`, `resolve` | `incidents.manage` | `{ incident_id, note? }` \u2014 a compare-and-set on the state the button showed |\n| `upsert_rule` | `rules.manage` | `{ rule: { key, series, op, threshold, for_minutes?, severity?, repeat_after_hours?, channels?, title? } }` |\n| `toggle_rule` | `rules.manage` | `{ key, enabled }` |\n| `update_integration` | `integrations.manage` | `{ integration: { key, label?, owner?, poll_every_min?, config? } }` |\n| `pause_integration` | `integrations.manage` | `{ key, paused }` |\n\n"Sync now" is `POST /v1/fn/collect { integration: \'<key>\' }`, gated on `integrations.sync`.\n\n`update_integration` strips every config key that looks like a credential (`key`, `token`, `secret`,\n`password`, `authorization`, \u2026) and every value that looks like a bearer token, and names what it dropped. A\ncredential is a project secret, set with `vxil secrets set`, never from a browser.\n\nThe three settings that decide **where a credential is sent** \u2014 `config.base_url`, `config.auth` and\n`config.peer` \u2014 are not editable through `ops-action` at all: a change answers `409 locked_config` and an edit\nthat leaves them out keeps the stored values. They change only with a server key (`vxil api PATCH\n/v1/cms/items/integrations/<id>`, or a re-seed). `collect` enforces the same thing on its side, in code: each\nsecret is bound to one host (`SECRET_HOST` in `functions/collect.ts` \u2014 `shop_token` \u2192 your API\'s host;\n`stripe_key` \u2192 the payments provider\'s API; peer keys \u2192 the vxil API only, and only the names in\n`PEER_SECRETS`). A row that would send a credential anywhere else turns `broken` without a request being made.\n\n## 5. Adding a source\n\nA source is three edits: a **case in the `adapter` switch** in `functions/collect.ts`, the **host in\n`egressAllow`**, and a **secret**. Then `vxil push`. The egress guard is deny-by-default: a host you forgot\nanswers `403` and the integration turns `broken` with `egress blocked: add <host> to egressAllow and push`.\n\nBefore writing code, try the generic `rest-json` kind \u2014 a row, no code:\n\n```json\n{ "key": "shop", "kind": "rest-json", "enabled": true, "poll_every_min": 5,\n "next_due_at": "2026-01-01T00:00:00Z",\n "config": { "base_url": "https://shop.example.com", "path": "/api/stats",\n "series": [ { "name": "orders_1h", "json_path": "orders.last_hour", "unit": "count" },\n { "name": "revenue_1h", "json_path": "revenue.last_hour", "unit": "usd", "sensitive": true } ] } }\n```\n\nIt sends `Authorization: Bearer <shop_token>` (`"auth": "none"` for a public endpoint) and reads each\n`json_path` (`a.b.0.c`; a trailing `.length` counts an array). Series are named `<integration key>:<name>`.\nThe token goes **only** to the host in `SECRET_HOST.shop_token` at the top of `functions/collect.ts` \u2014 set it to\nyour API\'s host before the push; a `rest-json` row with a token pointing at any other host is refused. A second\nauthenticated REST API is a second secret and its own `case` (below), never a second row reusing `shop_token`.\n\n**A REST API with its own token** \u2014 one secret per vendor, one case:\n\n```ts\ncase \'helpdesk\': {\n const r = await getJson(\'https://api.helpdesk.example/v2/tickets/count?status=open\',\n { authorization: `Bearer ${secrets.helpdesk_token}` }) as { count: number };\n return [{ series: \'helpdesk:open_tickets\', value: r.count, unit: \'count\' }];\n}\n```\n\n**An analytics API that wants a service-account JWT** \u2014 sign it with WebCrypto inside the function (no SDK, no\nNode), then trade it for an access token:\n\n```ts\nasync function serviceToken(sa: { client_email: string; private_key: string }, scope: string): Promise<string> {\n const b64 = (s: string | ArrayBuffer) => btoa(typeof s === \'string\' ? s : String.fromCharCode(...new Uint8Array(s)))\n .replace(/=+$/, \'\').replace(/\\+/g, \'-\').replace(/\\//g, \'_\');\n const now = Math.floor(Date.now() / 1000);\n const unsigned = `${b64(JSON.stringify({ alg: \'RS256\', typ: \'JWT\' }))}.${b64(JSON.stringify({\n iss: sa.client_email, scope, aud: \'https://oauth2.example.com/token\', iat: now, exp: now + 3600 }))}`;\n const der = Uint8Array.from(atob(sa.private_key.replace(/-----[^-]+-----|\\s/g, \'\')), (c) => c.charCodeAt(0));\n const key = await crypto.subtle.importKey(\'pkcs8\', der, { name: \'RSASSA-PKCS1-v1_5\', hash: \'SHA-256\' }, false, [\'sign\']);\n const sig = await crypto.subtle.sign(\'RSASSA-PKCS1-v1_5\', key, new TextEncoder().encode(unsigned));\n const res = await fetch(\'https://oauth2.example.com/token\', { method: \'POST\',\n headers: { \'content-type\': \'application/x-www-form-urlencoded\' },\n body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${unsigned}.${b64(sig)}` });\n return ((await res.json()) as { access_token: string }).access_token;\n}\n```\n\nStore the service-account JSON as one secret, and add both the token host and the API host to `egressAllow`.\n\n**A SQL query over an HTTPS SQL endpoint** \u2014 the function is the SQL client; vxil never connects to your\ndatabase. The pattern, the credential handling and the least-privilege database user are in\nguide chapter 8 (https://vxil.com/docs/guide/08-running-your-code-functions#your-own-database-from-a-function):\n\n```ts\ncase \'warehouse\': {\n const rows = await sql(secrets.database_url, \'select count(*)::int as n from orders where created_at > now() - interval \\\'1 hour\\\'\');\n return [{ series: \'warehouse:orders_1h\', value: rows[0].n, unit: \'count\' }];\n}\n```\n\n**A tool that pushes instead of being polled** \u2014 create an inbound webhook source and point the tool at its\nreceiver URL; then add an integration row of kind `webhook-source` with `config.source_id`:\n\n```ts\nconst { source_id, receiver_url_path } = await vx.webhooks.sources.create({ provider: \'generic\', name: \'support tool\' });\n// the receiver URL is returned once \u2014 paste it into the tool\'s webhook settings\n```\n\n## 6. Time series on the cms\n\n- **Write closed hours, not samples.** Every collect tick patches the series\' `metric_latest` row (value,\n `updated_at`, the hour\'s sample count and min/max). When a tick finds the stored sample belongs to an hour that\n has ended, it first writes that hour\'s closing sample as one `metric_points` row (`grain: \'hour\'`), then\n records the hour in `last_bucket`.\n- **The bucket is derived.** The `point_bucket` hook derives `bucket` from `ts` (`2026-10-04T13` for an hour,\n `2026-10-04` for a day) and `point_key` concatenates series, grain and bucket into a `unique` field \u2014 so a\n redelivered tick or two overlapping ticks produce a `409`, never a second point. Both are Lane-A `derive`\n hooks, not `validate`: the server computes the value on every write, so a writer that sends a wrong bucket (or\n none) is overwritten rather than refused, and no writer \u2014 `collect`, `compact-daily`, a backfill script \u2014 can\n file a point under the wrong hour. Keep them `derive` in any copy of this config.\n- **Compaction.** `compact-daily` aggregates yesterday\'s hour rows per series on the server (avg, min, max,\n count) into one `grain: \'day\'` row, then deletes hour rows older than 90 days and feed rows older than 30\n (the bounded filtered delete, 100 rows a call). Charts read `grain: \'hour\'` for the last days and\n `grain: \'day\'` beyond.\n- **The 50,000-row scan cap.** A server-side aggregate refuses (`422`) rather than scanning more than 50,000 rows.\n One day of hour rows is `series \xD7 24`, so compaction stays far below it even at 500 series; an aggregate over\n a year of raw 5-minute samples (105,120 per series) would not fit even for one series.\n- **Why not raw points.** Every cms write is an audited row and a change-data frame, and every call a function\n makes back into vxil counts as a request. A sample every 5 minutes is 288 rows a day per series; a closed hour\n is 24 \u2014 twelve times fewer writes, frames and requests, and twelve times fewer rows under every scan.\n\nThe config also shows (commented) the declarative twin of `compact-daily`, a `readModels` aggregate. It is left\noff because the function also compacts the gated value, writes into the collection the charts already read, and\nprunes.\n\n## 7. Sensitive numbers\n\nA series marked `sensitive` (revenue, balances) writes its number to `restricted_value`, which declares\n`readRoles: [\'finance\', \'ops-admin\', \'owner\']`; `value` stays null. A signed-in staff member without one of those\nroles gets the row with `restricted_value` **absent** and cannot filter, sort or group by it. Your server key\nand the functions (which run as the server) still see it, so alerts evaluate it \u2014 but a sensitive series\' number\nnever goes into a chat message, an e-mail or an incident row.\n\n**Change-data frames do not apply `readRoles`.** A `payload: \'full\'` frame carries the whole row to every\nsubscriber of the channel. So `metric_latest` and `incidents` publish **ids only** and the client re-reads the\nrow through the cms, where the gate applies; only `events_feed`, which has no gated field, publishes full rows.\nThe CI gate `cdc-gated-fields` refuses a full frame on any collection that declares a `readRoles` field.\n\n## 8. Alerts once per crossing\n\nA rule is `{ series, op, threshold, for_minutes, severity, repeat_after_hours, channels }`; `op` is `>`, `<`,\n`>=`, `<=` or `stale` (no fresh value for `threshold` minutes). `evaluate-alerts`:\n\n1. on a new breach stores `breach_since` and waits `for_minutes`;\n2. when the breach is sustained, creates the incident under `lock: \'incident:<rule>\'` with\n `guard: { filter: { rule, state: { $in: [\'open\', \'acked\'] } }, max: 1 }` \u2014 of two overlapping ticks exactly\n one gets a `201`, and only that one posts the chat message and sends the e-mail (the built-in `transactional`\n template, to every `ops` member holding `operator`, `ops-admin` or `owner`);\n3. stays quiet while it stays red, except one reminder every `repeat_after_hours`;\n4. if the incident create fails for any other reason (a `5xx`, a hook\'s `422`), leaves the rule `ok` with its\n `breach_since` and counts it in `failed` \u2014 the next tick crosses again, so the alert is delayed, never lost;\n5. on recovery flips the rule back (an `If-Match` on its version picks the one tick that does it), resolves the\n open incident and posts one RESOLVED message.\n\nThe chat poster sends Slack\'s `text` and Discord\'s `content`; for Discord add `discord.com` to `egressAllow`; a\nGoogle Chat space webhook (`chat.googleapis.com`, also added to `egressAllow`) gets `{ text }` alone.\n\n## 9. Reading your other vxil projects\n\nAn integration of kind `vxil-project` reads another project with **that project\'s own key** \u2014 one key per\nproject, minted there with the least it needs (`usage:read` for its request meter, `cms:read` to aggregate its\ncollections). It is pull, not push: the other project does not know this dashboard exists, and revoking the key\nthere disconnects it. A second project gets a second secret: declare `secret:<name>` on `collect`, add the name\nto `PEER_SECRETS` in `functions/collect.ts`, and point the row at it with `config.peer` (any name not on that\nlist is refused, so a row cannot pick some other secret and send it as a bearer key).\n\n```json\n{ "key": "billing-project", "kind": "vxil-project", "enabled": true, "poll_every_min": 60,\n "config": { "aggregates": [ { "name": "signups_24h", "collection": "accounts", "fn": "count", "window_hours": 24 } ] } }\n```\n\n## 10. Limits and plans\n\n| | Free (staging / development projects) | Developer |\n|---|---|---|\n| Functions / crons | 5 functions, 3 crons, fastest every 15 min | 50 functions, 20 crons, every minute |\n| As shipped | set the two `*/5` schedules (`collect`, `evaluate-alerts`) to `*/15`; drop `drain-inbound`\'s cron trigger (its `payments.` trigger stays) | runs as shipped |\n| Requests a month | 100,000 | 1,000,000 |\n\nEvery call a function makes back into vxil counts as a request (vendor calls do not). The arithmetic for 3\npolled integrations \xD7 5 series, a 30-day month:\n\n| Function | Per run | Runs a month | Requests |\n|---|---|---|---|\n| `collect` every 5 min | 1 list + per integration (1 list + 5 tile patches + 1 status patch) = 22 | 8,640 | ~190,000 |\n| hour closes | 15 series \xD7 1 point | 720 hours | ~11,000 |\n| `evaluate-alerts` every 5 min | 1 rules read + 1 latest read (+ writes on crossings only) | 8,640 | ~17,000 |\n| `drain-inbound` every minute, 1 source | 1 list + 1 events read (+ 1 write per event) | 43,200 | ~86,000 + events |\n| `compact-daily` | 2 aggregates + 15 day rows + retention deletes | 30 | ~1,500 |\n\nAbout **305,000 requests a month** on Developer. On Free, at 15-minute cadence with 2 integrations \xD7 4 series and\nno inbound drain: `collect` ~37,000, `evaluate-alerts` ~6,000, hour closes ~6,000 \u2014 about **50,000**. A tile list\nread by your frontend counts too, so let realtime change-data tell the page when to re-read instead of polling.\n\n## 11. Compose with\n\n- **alerts-to-slack** \u2014 alerts on the platform\'s own failure events (a dead-lettered job, a quarantined\n function, a failing payments webhook) next to this dashboard\'s business thresholds.\n- **service-monitor** \u2014 uptime checks for your endpoints, written into the same kind of rows.\n- **team-workspace** \u2014 the full org model: invitations, per-resource grants, a field only finance receives.\n\n## 12. Where the frontend lives\n\nThe **App builder** builds public-facing apps. An internal dashboard is your own frontend \u2014 any framework,\nany host \u2014 reading this project with the two keys above. The reference is the `team-dashboard` showcase app (https://vxil.com/showcase):\nsign-in through your identity provider, tiles that re-read on change-data frames, trend charts over\n`metric_points`, the feed, and the incident buttons calling `ops-action`.\n',
19375
19470
  "functions": {
19376
19471
  "collect.ts": "// collect.ts \u2014 PULL: poll the team's services into the tiles and the hourly trend\n// (a vxil function; cron every 5 minutes + an http \"Sync now\" door).\n//\n// There is no connectors feature. A connector is the `adapter` switch below +\n// a secret + a host in egressAllow. Each tick:\n// 1. reads the enabled integrations whose next_due_at has passed (oldest\n// first, at most MAX_PER_TICK);\n// 2. runs that integration's adapter \u2014 stripe, rest-json or vxil-project \u2014\n// which returns samples: { series, value, unit, sensitive };\n// 3. per series: when the hour of the stored sample has CLOSED, writes that\n// hour's closing sample as ONE metric_points row (grain 'hour'; the unique\n// point_key makes a redelivered tick a 409, never a duplicate) and records\n// it in last_bucket; then PATCHes metric_latest with the new sample (the\n// first sighting creates it);\n// 4. PATCHes the integration: ok + next_due_at, or broken + the error.\n//\n// A sensitive series (revenue, balances) writes `restricted_value` \u2014 readable\n// only by the roles in the config's readRoles \u2014 and leaves `value` null.\n//\n// A vendor failure never throws: the integration is marked broken (failures\n// counted, last_error kept to 500 chars) and the tick answers 200, so one\n// vendor's outage does not trip the function's circuit breaker.\n//\n// \"Sync now\" (http): `POST /v1/fn/collect { integration: '<key>' }` with a\n// signed-in staff session. The caller must hold `integrations.sync` in the ops\n// org (checked with orgs, not trusted from the browser). The http run updates\n// the tiles; a series whose hour has closed is left to the next cron tick,\n// which can read the restricted value it needs to close that hour.\n// A server caller (no session \u2014 `vxil functions invoke collect`, your backend)\n// runs one named integration, or the whole tick when none is named.\n//\n// cron-walk: drains-filter \u2014 each processed integration is PATCHed with next_due_at = now + poll_every_min, so it leaves the read filter\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_PER_TICK = 20; // integrations per tick\nconst MAX_SERIES_PER_SOURCE = 25; // samples one adapter may emit\nconst ERROR_MAX = 500;\nconst OPS_ORG_SLUG = 'ops'; // the one org every staff member joins (README step 3)\nconst VENDOR_TIMEOUT_MS = 8_000;\n\n// \u2500\u2500 credentials are bound to hosts HERE, in code \u2014 never by a row \u2500\u2500\n// An integration row is data a dashboard admin can edit, and its base_url may\n// name any host in egressAllow. So a row never decides where a credential goes:\n// each secret is sent to the ONE host written below, and a row that points it\n// anywhere else turns `broken` without a request being made.\n// shop_token \u2192 the host of your REST API (edit to yours; the demo row uses \"auth\": \"none\")\n// stripe_key \u2192 api.stripe.com, hardcoded in its adapter\n// peer keys \u2192 this platform's own API only, and only the names listed in PEER_SECRETS\nconst SECRET_HOST: Record<string, string> = { shop_token: 'shop.example.com' };\n// The secrets a `vxil-project` row may name in config.peer. A second project =\n// a second `secret:<name>` on collect AND its name here.\nconst PEER_SECRETS: readonly string[] = ['vxil_peer_key'];\n\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ integration?: string }>;\n\ninterface SeriesDef { name: string; json_path: string; unit?: string; sensitive?: boolean; label?: string }\ninterface AggregateDef { name: string; collection: string; fn: 'count' | 'sum' | 'avg' | 'min' | 'max'; field?: string; filter?: Record<string, unknown>; window_hours?: number; sensitive?: boolean; unit?: string }\ninterface IntegrationConfig {\n base_url?: string; path?: string; auth?: 'bearer' | 'none'; series?: SeriesDef[];\n aggregates?: AggregateDef[]; peer?: string;\n}\ninterface Integration {\n key: string; kind: string; status?: string; enabled?: boolean; poll_every_min?: number;\n consecutive_failures?: number; config?: IntegrationConfig; label?: string;\n}\ninterface Latest {\n series: string; source?: string; status?: string; last_bucket?: string | null; value?: number | null;\n restricted_value?: number | null; updated_at?: string; label?: string; unit?: string; sensitive?: boolean;\n hour_samples?: number; hour_min?: number | null; hour_max?: number | null;\n}\ninterface Item<T> { item_id: string; version?: number; data: T }\ninterface Sample { series: string; value: number; unit?: string | undefined; label?: string | undefined; sensitive?: boolean | undefined }\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 cmsJwt = env.scoped_jwts?.cms;\n if (!cmsJwt) return json({ error: 'missing cms scope' }, 403);\n const cms = new Cms(base, cmsJwt);\n const now = new Date();\n\n // \u2500\u2500 the http door \u2500\u2500\n // a signed-in staff member (\"Sync now\"): checked against the ops org, one integration\n // a server caller (`vxil functions invoke collect`, your own backend): one\n // integration when named, otherwise exactly what a cron tick does\n const key = env.trigger === 'http' && typeof env.payload?.integration === 'string' ? env.payload.integration : '';\n if (env.trigger === 'http' && (env.end_user || key)) {\n const user = env.end_user;\n if (user && !(await orgAllows(base, env.scoped_jwts?.orgs, user.id, 'integrations.sync'))) {\n return json({ error: 'role_required', permission: 'integrations.sync' }, 403);\n }\n const row = key ? (await cms.list<Integration>('integrations', { key }, { limit: 1 })).items[0] : undefined;\n if (!row) return json({ error: 'unknown_integration' }, 404);\n if (row.data.kind === 'webhook-source') return json({ error: 'webhook sources are drained, not polled' }, 422);\n const out = await pollOne(cms, env.secrets ?? {}, base, row, now, user ? 'http' : 'cron');\n return json(out, 200);\n }\n\n // \u2500\u2500 the cron tick \u2500\u2500\n const due = await cms.list<Integration>('integrations', {\n enabled: true,\n kind: { $in: ['stripe', 'rest-json', 'vxil-project'] },\n next_due_at: { $lte: now.toISOString() },\n }, { sort: 'next_due_at', limit: MAX_PER_TICK });\n const results = [];\n for (const row of due.items) results.push(await pollOne(cms, env.secrets ?? {}, base, row, now, 'cron'));\n return json({\n polled: results.length,\n ok: results.filter((r) => r.ok).length,\n broken: results.filter((r) => !r.ok).map((r) => r.integration),\n series: results.reduce((n, r) => n + r.series, 0),\n points: results.reduce((n, r) => n + r.points, 0),\n // more were due than one tick takes; the rest are first in line next tick\n truncated: due.items.length === MAX_PER_TICK,\n }, 200);\n },\n};\n\n/** One integration: adapter \u2192 samples \u2192 tiles + closed hours \u2192 the integration's own row. */\nasync function pollOne(\n cms: Cms, secrets: Record<string, string>, base: string, row: Item<Integration>, now: Date, mode: 'cron' | 'http',\n): Promise<{ integration: string; ok: boolean; series: number; points: number; deferred: number; error?: string }> {\n const integ = row.data;\n const nextDue = new Date(now.getTime() + Math.max(1, integ.poll_every_min ?? 5) * 60_000).toISOString();\n let samples: Sample[];\n try {\n samples = (await adapter(integ, secrets, base, now)).slice(0, MAX_SERIES_PER_SOURCE);\n } catch (e) {\n const missing = e instanceof MissingSecret;\n const error = String((e as Error).message ?? e).slice(0, ERROR_MAX);\n // If-Match: the row was read this tick, so the failure count stays exact\n // when two ticks overlap (the loser's PATCH is a 409 and changes nothing).\n await cms.patch('integrations', row.item_id, {\n status: missing ? 'could_not_check' : 'broken',\n consecutive_failures: (integ.consecutive_failures ?? 0) + 1,\n last_error: error,\n last_error_at: now.toISOString(),\n next_due_at: nextDue,\n }, row.version);\n return { integration: integ.key, ok: false, series: 0, points: 0, deferred: 0, error };\n }\n\n const w = await writeSamples(cms, integ.key, samples, now, mode);\n await cms.patch('integrations', row.item_id, {\n status: 'ok', consecutive_failures: 0, last_ok_at: now.toISOString(), last_error: null, next_due_at: nextDue,\n }, row.version);\n return { integration: integ.key, ok: true, series: w.series, points: w.points, deferred: w.deferred };\n}\n\n/** Write one source's samples: close finished hours, then update the tiles. */\nasync function writeSamples(\n cms: Cms, source: string, samples: Sample[], now: Date, mode: 'cron' | 'http',\n): Promise<{ series: number; points: number; deferred: number }> {\n const nowIso = now.toISOString();\n const hourNow = nowIso.slice(0, 13);\n const existing = new Map<string, Item<Latest>>();\n for (const it of (await cms.list<Latest>('metric_latest', { source }, { limit: 100 })).items) existing.set(it.data.series, it);\n\n let series = 0;\n let points = 0;\n let deferred = 0;\n for (const s of samples) {\n const cur = existing.get(s.series);\n const sensitive = Boolean(s.sensitive);\n const reading = sensitive ? { value: null, restricted_value: s.value } : { value: s.value, restricted_value: null };\n\n if (!cur) {\n // first sighting \u2014 a racing tick's create is a 409; the next tick patches it\n const r = await cms.create('metric_latest', {\n series: s.series, source, status: 'ok', label: s.label ?? s.series, unit: s.unit ?? null, sensitive,\n ...(sensitive ? { restricted_value: s.value } : { value: s.value, hour_min: s.value, hour_max: s.value }),\n hour_samples: 1, updated_at: nowIso,\n });\n if (r.ok) series++;\n continue;\n }\n\n const prevHour = (cur.data.updated_at ?? '').slice(0, 13);\n const hourClosed = prevHour !== '' && prevHour < hourNow;\n const patch: Record<string, unknown> = {\n ...reading, updated_at: nowIso, label: s.label ?? cur.data.label ?? s.series, unit: s.unit ?? cur.data.unit ?? null, sensitive,\n };\n if (cur.data.status === 'stale') patch.status = 'ok'; // fresh data again; alert severities are evaluate-alerts' to set\n\n if (hourClosed) {\n if (mode === 'http') { deferred++; continue; } // the cron closes this hour first\n if (cur.data.last_bucket !== prevHour) {\n // the closed hour's closing sample \u2192 ONE point (409 on point_key = already written)\n const wasSensitive = Boolean(cur.data.sensitive);\n const closing = wasSensitive ? cur.data.restricted_value : cur.data.value;\n if (typeof closing === 'number') {\n const p = await cms.create('metric_points', {\n series: s.series, grain: 'hour', ts: `${prevHour}:00:00.000Z`,\n // bucket + point_key are derived by the config's hooks; sent too so the\n // row is complete even where hooks are switched off\n bucket: prevHour, point_key: `${s.series}|hour|${prevHour}`,\n ...(wasSensitive\n ? { restricted_value: closing }\n : { value: closing, min: cur.data.hour_min ?? closing, max: cur.data.hour_max ?? closing }),\n samples: cur.data.hour_samples ?? 1, unit: cur.data.unit ?? null,\n });\n if (p.ok) points++;\n if (p.ok || p.status === 409) patch.last_bucket = prevHour;\n } else {\n patch.last_bucket = prevHour; // nothing to close (the stored value was gated or empty)\n }\n }\n patch.hour_samples = 1;\n patch.hour_min = sensitive ? null : s.value;\n patch.hour_max = sensitive ? null : s.value;\n } else {\n patch.hour_samples = (cur.data.hour_samples ?? 0) + 1;\n patch.hour_min = sensitive ? null : Math.min(cur.data.hour_min ?? s.value, s.value);\n patch.hour_max = sensitive ? null : Math.max(cur.data.hour_max ?? s.value, s.value);\n }\n const r = await cms.patch('metric_latest', cur.item_id, patch, cur.version);\n if (r.ok) series++;\n }\n return { series, points, deferred };\n}\n\n// \u2500\u2500 the adapters: one case per kind, self-contained (a blueprint is a clone) \u2500\u2500\n\nclass MissingSecret extends Error {}\n\nasync function adapter(integ: Integration, secrets: Record<string, string>, vxilBase: string, now: Date): Promise<Sample[]> {\n const cfg = integ.config ?? {};\n switch (integ.kind) {\n case 'stripe': {\n // A RESTRICTED key, read-only on Balance and Charges. Amounts are minor units.\n const key = secrets.stripe_key;\n if (!key) throw new MissingSecret('secret stripe_key is not set');\n const auth = { authorization: `Bearer ${key}` };\n const balance = await getJson(`https://api.stripe.com/v1/balance`, auth) as { available?: Array<{ amount: number; currency: string }> };\n const since = Math.floor(now.getTime() / 1000) - 3600;\n const charges = await getJson(`https://api.stripe.com/v1/charges?limit=100&created[gte]=${since}`, auth) as {\n data?: Array<{ amount: number; currency: string; status: string }>; has_more?: boolean;\n };\n const list = charges.data ?? [];\n const ok = list.filter((c) => c.status === 'succeeded');\n const failed = list.filter((c) => c.status === 'failed').length;\n const out: Sample[] = [\n { series: 'stripe:charges_1h', value: ok.length, unit: 'count', label: 'Charges (last hour)' },\n { series: 'stripe:failed_charges_1h', value: failed, unit: 'count', label: 'Failed charges (last hour)' },\n {\n series: 'stripe:charge_error_rate_1h', unit: '%', label: 'Charge error rate (last hour)',\n value: list.length === 0 ? 0 : Math.round((failed / list.length) * 1000) / 10,\n },\n ];\n const volume = new Map<string, number>();\n for (const c of ok) volume.set(c.currency, (volume.get(c.currency) ?? 0) + c.amount);\n for (const [cur, amount] of volume) {\n out.push({ series: `stripe:volume_1h:${cur}`, value: amount / 100, unit: cur, label: `Volume ${cur.toUpperCase()} (last hour)`, sensitive: true });\n }\n for (const b of balance.available ?? []) {\n out.push({ series: `stripe:balance:${b.currency}`, value: b.amount / 100, unit: b.currency, label: `Available ${b.currency.toUpperCase()}`, sensitive: true });\n }\n // more than 100 charges in the hour: the counts are a floor \u2014 page with\n // `starting_after` (and keep the cursor on the integration row) if you need exact ones\n return out;\n }\n\n case 'rest-json': {\n // Any JSON endpoint: GET base_url + path, pick numbers with json_path.\n if (!cfg.base_url || !cfg.series?.length) throw new Error('config needs base_url and series[]');\n let url: URL;\n try { url = new URL(`${cfg.base_url.replace(/\\/$/, '')}${cfg.path ?? ''}`); } catch { throw new Error('config base_url + path is not a URL'); }\n if (url.protocol !== 'https:') throw new Error('config base_url must be https://');\n const headers: Record<string, string> = { accept: 'application/json' };\n if (cfg.auth !== 'none') {\n // the host of the FINAL url (a path like \"@other.host\" cannot move it)\n if (url.host !== SECRET_HOST.shop_token) {\n throw new Error(`shop_token is bound to ${SECRET_HOST.shop_token} and is never sent to ${url.host}: set \"auth\": \"none\" for a public endpoint, or change SECRET_HOST in collect.ts`);\n }\n if (!secrets.shop_token) throw new MissingSecret('secret shop_token is not set');\n headers.authorization = `Bearer ${secrets.shop_token}`;\n }\n const body = await getJson(url.href, headers);\n const out: Sample[] = [];\n for (const s of cfg.series) {\n const v = pick(body, s.json_path);\n if (typeof v === 'number' && Number.isFinite(v)) {\n out.push({ series: `${integ.key}:${s.name}`, value: v, unit: s.unit, label: s.label ?? s.name, sensitive: s.sensitive });\n }\n }\n if (out.length === 0) throw new Error(`no numeric value at ${cfg.series.map((s) => s.json_path).join(', ')}`);\n return out;\n }\n\n case 'vxil-project': {\n // Another vxil project of yours, read with ITS key (one secret per project;\n // the least-privilege pattern \u2014 usage:read, plus cms:read for aggregates).\n const secretName = cfg.peer ?? 'vxil_peer_key';\n if (!PEER_SECRETS.includes(secretName)) {\n throw new Error(`config.peer '${secretName}' is not a peer secret: add it to PEER_SECRETS in collect.ts`);\n }\n const key = secrets[secretName];\n if (!key) throw new MissingSecret(`secret ${secretName} is not set`);\n const H = { authorization: `Bearer ${key}`, 'content-type': 'application/json' };\n const usage = await getJson(`${vxilBase}/v1/usage`, H) as { data?: { requests?: { used?: number } } };\n const out: Sample[] = [];\n if (typeof usage.data?.requests?.used === 'number') {\n out.push({ series: `${integ.key}:requests_mtd`, value: usage.data.requests.used, unit: 'requests', label: 'Requests this month' });\n }\n for (const a of cfg.aggregates ?? []) {\n const since = new Date(now.getTime() - (a.window_hours ?? 24) * 3600_000).toISOString();\n const r = await fetch(`${vxilBase}/v1/cms/items/${encodeURIComponent(a.collection)}/aggregate`, {\n method: 'POST', headers: H, signal: AbortSignal.timeout(VENDOR_TIMEOUT_MS),\n body: JSON.stringify({\n aggregates: [a.fn === 'count' ? { fn: 'count', as: 'v' } : { fn: a.fn, field: a.field, as: 'v' }],\n ...(a.filter ? { filter: a.filter } : {}),\n window: { field: 'created_at', since },\n }),\n });\n if (!r.ok) throw new Error(`aggregate ${a.collection}: ${r.status}`);\n const g = ((await r.json()) as { data?: { groups?: Array<{ v?: number }> } }).data?.groups?.[0];\n out.push({ series: `${integ.key}:${a.name}`, value: Number(g?.v ?? 0), unit: a.unit, label: a.name, sensitive: a.sensitive });\n }\n return out;\n }\n\n default:\n throw new Error(`no adapter for kind '${integ.kind}'`);\n }\n}\n\nasync function getJson(url: string, headers: Record<string, string>): Promise<unknown> {\n const r = await fetch(url, { headers, signal: AbortSignal.timeout(VENDOR_TIMEOUT_MS) });\n if (r.status === 403 && r.headers.get('x-vxil-egress') === 'blocked') {\n throw new Error(`egress blocked: add ${new URL(url).hostname} to egressAllow and push`);\n }\n if (!r.ok) throw new Error(`GET ${new URL(url).hostname}${new URL(url).pathname}: ${r.status}`);\n return r.json();\n}\n\n/** 'a.b.0.c' into a JSON value; a trailing '.length' counts an array. */\nfunction pick(root: unknown, path: string): unknown {\n let cur: unknown = root;\n for (const part of path.split('.')) {\n if (part === 'length' && Array.isArray(cur)) return cur.length;\n if (cur === null || typeof cur !== 'object') return undefined;\n cur = (cur as Record<string, unknown>)[part];\n }\n return cur;\n}\n\n/** Revocation-grade check: is this user allowed `permission` in the ops org? */\nasync function orgAllows(base: string, orgsJwt: string | undefined, userId: string, permission: string): Promise<boolean> {\n if (!orgsJwt) return false;\n const H = { authorization: `Bearer ${orgsJwt}` };\n const mine = await fetch(`${base}/v1/orgs?user_id=${encodeURIComponent(userId)}`, { headers: H });\n if (!mine.ok) return false;\n const org = ((await mine.json()) as { data?: { orgs?: Array<{ org_id: string; slug: string }> } }).data?.orgs\n ?.find((o) => o.slug === OPS_ORG_SLUG);\n if (!org) return false;\n const q = `user_id=${encodeURIComponent(userId)}&permission=${encodeURIComponent(permission)}`;\n const check = await fetch(`${base}/v1/orgs/${encodeURIComponent(org.org_id)}/check?${q}`, { headers: H });\n if (!check.ok) return false;\n return ((await check.json()) as { data?: { allowed?: boolean } }).data?.allowed === true;\n}\n\n/** The cms REST envelope: reads { data: { items: [{ item_id, version, data }], next_cursor } }. */\nclass Cms {\n constructor(private base: string, private jwt: string) {}\n private h(extra: Record<string, string> = {}) {\n return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json', ...extra };\n }\n async list<T>(coll: string, filter: Record<string, unknown>, o: { sort?: string; limit?: number } = {}): Promise<{ items: Item<T>[]; next_cursor: string | null }> {\n const q = new URLSearchParams({ filter: JSON.stringify(filter), limit: String(o.limit ?? 100) });\n if (o.sort) q.set('sort', o.sort);\n const r = await fetch(`${this.base}/v1/cms/items/${coll}?${q}`, { headers: this.h() });\n if (!r.ok) throw new Error(`cms list ${coll}: ${r.status}`);\n const d = ((await r.json()) as { data?: { items?: Item<T>[]; next_cursor?: string | null } }).data;\n return { items: d?.items ?? [], next_cursor: d?.next_cursor ?? null };\n }\n async create(coll: string, data: Record<string, unknown>): Promise<{ ok: boolean; status: number }> {\n const r = await fetch(`${this.base}/v1/cms/items/${coll}`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ status: 'published', data }),\n });\n return { ok: r.ok, status: r.status };\n }\n async patch(coll: string, id: string, data: Record<string, unknown>, version?: number): Promise<{ ok: boolean; status: number }> {\n const r = await fetch(`${this.base}/v1/cms/items/${coll}/${id}`, {\n method: 'PATCH',\n headers: this.h(version !== undefined ? { 'if-match': String(version) } : {}),\n body: JSON.stringify({ data }),\n });\n return { ok: r.ok, status: r.status };\n }\n}\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
19377
19472
  "compact-daily.ts": "// compact-daily.ts \u2014 hour rows \u2192 day rows, then retention (a vxil function;\n// cron '15 0 * * *', just after midnight UTC).\n//\n// 1. Two bounded server-side aggregates over YESTERDAY's hour rows\n// (POST /v1/cms/items/metric_points/aggregate, grouped by series, windowed\n// on the `ts` slot): avg/min/max/count of `value`, and avg/max of the gated\n// `restricted_value` (this function runs as the server, so it sees both).\n// 2. One grain 'day' row per series. The unique point_key ('series|day|date')\n// makes a re-run a 409 per row \u2014 the day is never written twice.\n// 3. Retention: hour rows older than RETAIN_HOUR_DAYS and feed rows older than\n// RETAIN_FEED_DAYS are removed with the bounded filtered delete (100 rows a\n// call, following next_cursor, at most MAX_DELETE_CALLS calls a night; the\n// remainder is reported as `truncated` and taken the next night).\n// 4. The `__compact__` row in ops_state records the last day finished.\n//\n// Why hour buckets and not raw samples: every cms write is an audited row and\n// a change-data frame. One closed hour per series is 24 rows a day; a raw\n// sample every 5 minutes would be 288 \u2014 twelve times the writes, the frames,\n// the request budget, and the rows the 50,000-row aggregate scan must cover.\n//\n// cron-walk: complete-per-tick \u2014 one aggregate per value kind is the whole read; the retention deletes follow next_cursor under MAX_DELETE_CALLS and report `truncated`\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst RETAIN_HOUR_DAYS = 90;\nconst RETAIN_FEED_DAYS = 30;\nconst MAX_DELETE_CALLS = 30;\nconst MAX_GROUPS = 500;\n\ninterface Group { key: { series?: string }; avg?: number | null; min?: number | null; max?: number | null; n?: number }\ninterface Item<T> { item_id: string; version?: number; data: T }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as CronFunctionEnvelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cmsJwt = env.scoped_jwts?.cms;\n if (!cmsJwt) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cmsJwt}`, 'content-type': 'application/json' };\n\n // the slot this tick was due for (stable across a late start or a re-delivery)\n const slot = new Date(env.scheduled_for ?? Date.now());\n const day = new Date(Date.UTC(slot.getUTCFullYear(), slot.getUTCMonth(), slot.getUTCDate() - 1)).toISOString().slice(0, 10);\n const since = `${day}T00:00:00.000Z`;\n const until = new Date(Date.parse(since) + 86_400_000).toISOString(); // exclusive\n\n const aggregate = async (field: 'value' | 'restricted_value'): Promise<Group[] | null> => {\n const r = await fetch(`${base}/v1/cms/items/metric_points/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'avg', field, as: 'avg' }, { fn: 'min', field, as: 'min' },\n { fn: 'max', field, as: 'max' }, { fn: 'count', as: 'n' },\n ],\n groupBy: ['series'],\n filter: { grain: 'hour' },\n window: { field: 'ts', since, until },\n limit: MAX_GROUPS,\n }),\n });\n if (!r.ok) return null;\n return ((await r.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n };\n const plain = await aggregate('value');\n const gated = await aggregate('restricted_value');\n if (!plain || !gated) return Response.json({ error: 'aggregate_failed', day }, { status: 502 });\n\n // one day row per series; a series whose hours carried restricted_value is sensitive\n const bySeries = new Map<string, { plain?: Group; gated?: Group }>();\n for (const g of plain) if (g.key.series) bySeries.set(g.key.series, { plain: g });\n for (const g of gated) if (g.key.series) bySeries.set(g.key.series, { ...bySeries.get(g.key.series), gated: g });\n\n let written = 0;\n let existed = 0;\n for (const [series, { plain: p, gated: q }] of bySeries) {\n const sensitive = typeof q?.avg === 'number';\n if (!sensitive && typeof p?.avg !== 'number') continue;\n const r = await fetch(`${base}/v1/cms/items/metric_points`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n status: 'published',\n data: {\n series, grain: 'day', ts: since, bucket: day, point_key: `${series}|day|${day}`,\n samples: p?.n ?? q?.n ?? 0,\n ...(sensitive\n ? { restricted_value: round(q!.avg!) }\n : { value: round(p!.avg!), min: p!.min ?? null, max: p!.max ?? null }),\n },\n }),\n });\n if (r.ok) written++;\n else if (r.status === 409) existed++;\n }\n\n // retention \u2014 the bounded filtered delete, followed to the end or the cap\n const cutoff = (days: number) => new Date(Date.parse(since) - days * 86_400_000).toISOString();\n let calls = 0;\n const sweep = async (coll: string, filter: Record<string, unknown>): Promise<{ deleted: number; complete: boolean }> => {\n let deleted = 0;\n let cursor: string | null = null;\n while (calls < MAX_DELETE_CALLS) {\n calls++;\n const r = await fetch(`${base}/v1/cms/items/${coll}/delete`, {\n method: 'POST', headers: H,\n body: JSON.stringify({ filter, limit: 100, ...(cursor ? { cursor } : {}) }),\n });\n if (!r.ok) return { deleted, complete: false };\n const d = ((await r.json()) as { data?: { deleted?: number; complete?: boolean; next_cursor?: string | null } }).data ?? {};\n deleted += d.deleted ?? 0;\n // a call stopped by its time budget hands back next_cursor; otherwise the\n // filter simply re-matches what is left \u2014 loop while rows are still going\n cursor = d.complete === false ? (d.next_cursor ?? null) : null;\n if ((d.deleted ?? 0) === 0) return { deleted, complete: true };\n }\n return { deleted, complete: false };\n };\n const hours = await sweep('metric_points', { grain: 'hour', ts: { $lt: cutoff(RETAIN_HOUR_DAYS) } });\n const feed = await sweep('events_feed', { occurred_at: { $lt: cutoff(RETAIN_FEED_DAYS) } });\n\n // remember the finished day (create once, then PATCH)\n const sq = new URLSearchParams({ filter: JSON.stringify({ key: '__compact__' }), limit: '1' });\n const st = await fetch(`${base}/v1/cms/items/ops_state?${sq}`, { headers: H });\n const row = ((await st.json().catch(() => ({}))) as { data?: { items?: Item<{ cursor?: string }>[] } }).data?.items?.[0];\n const mark = { key: '__compact__', cursor: day, updated_at: new Date().toISOString() };\n if (row) {\n await fetch(`${base}/v1/cms/items/ops_state/${row.item_id}`, { method: 'PATCH', headers: H, body: JSON.stringify({ data: mark }) });\n } else {\n await fetch(`${base}/v1/cms/items/ops_state`, { method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data: mark }) });\n }\n\n return Response.json({\n day, series: bySeries.size, written, existed,\n groups_capped: plain.length >= MAX_GROUPS || gated.length >= MAX_GROUPS,\n pruned: { hour_rows: hours.deleted, feed_rows: feed.deleted },\n truncated: !hours.complete || !feed.complete, // retention resumes tomorrow\n });\n },\n};\n\nconst round = (n: number) => Math.round(n * 1000) / 1000;\n",
@@ -19407,7 +19502,7 @@ export default defineConfig({
19407
19502
  "oidc_client_secret"
19408
19503
  ],
19409
19504
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Service monitor\" \u2014 uptime and health checks for your team's PUBLIC\n// endpoints, dead-man's-switch heartbeats from your own jobs, outages that open\n// and close once per state crossing, the on-call shift paged from data, and a\n// keyless public status page. Declared end-to-end in ONE typed file.\n//\n// \u2022 auth \u2192 staff sign in through your identity provider (OIDC);\n// password and magic-link sign-in are off \u2014 staff only\n// \u2022 orgs \u2192 one team org; the roles `oncall` (outages.manage) and\n// `monitor-admin` (checks.manage) are rows you add once\n// \u2022 cms \u2192 checks \xB7 uptime_hourly \xB7 outages \xB7 oncall_shifts (private)\n// and status_components \xB7 outage_updates (\u2B22 PUBLIC: only\n// their PUBLISHED rows are readable with no key)\n// \u2022 notifications \u2192 the page: e-mail + the in-app inbox of the shift\n// \u2022 functions \u2192 probe (cron) \xB7 heartbeat (http) \xB7 outage-action (http) \xB7\n// daily-report (cron)\n//\n// There is no monitoring engine, no escalation engine and no connector here:\n// a probe is a cron function behind a deny-by-default egress allowlist, an\n// outage is a row whose creation is guarded, a page is a read of who is on\n// shift right now, and escalation is one more filter in the same cron.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // \u2500\u2500 Staff-only sign-in \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // The OIDC issuer is your company's identity provider; `allowedDomains`\n // fences sign-in to your own e-mail domain (no e-mail \u21D2 refused). Password\n // and magic-link sign-in are switched off, so nobody outside the IdP can\n // create an account. Store the client secret once:\n // printf '%s' \"$OIDC_SECRET\" | vxil secrets set auth/oidc_client_secret\n auth: {\n methods: { emailPassword: false, magicLink: false },\n providers: {\n oidc: {\n issuer: 'https://login.example-idp.com', // https, no query/fragment\n clientId: 'vxil-service-monitor', // not a secret: it rides every authorize URL\n clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n scopes: ['email', 'profile'], // `openid` is always added\n allowedDomains: ['example.com'], // fail-closed domain fence\n },\n },\n // The member's org role rides the session (a snapshot, refreshed with it).\n // The functions below do NOT trust it: they re-check the permission live.\n orgClaims: { enabled: true },\n session: { ttlMinutes: 60, refreshTtlDays: 7 },\n security: {\n // every sign-in return URL must be your own admin app\n allowedRedirectOrigins: ['https://status-admin.example.com'],\n },\n },\n\n // \u2500\u2500 One team org, three roles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // `viewer` is built in (reads only). `oncall` and `monitor-admin` are\n // CUSTOM roles \u2014 rows, not config \u2014 added once with `POST /v1/orgs/roles`\n // (see the README). `owner` holds every permission.\n orgs: { enabled: true, maxOrgsPerTenant: 5, maxMembersPerOrg: 500, invitationTtlHours: 72 },\n\n cms: {\n // Draft \u2192 publish is ON, and it matters for exactly one collection:\n // `outage_updates`. A staff note is written as a DRAFT and is never\n // public; only an update the team chooses to publish reaches the status\n // page. Every other write in this blueprint passes `status` explicitly.\n draftPublish: true,\n hooks: {\n // The OUTAGE LIFECYCLE, enforced in the same write: open \u2192 acked \u2192\n // resolved, open \u2192 resolved; `resolved` is terminal.\n outage_stage: {\n collection: 'outages',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'open' && (item.state == 'acked' || item.state == 'resolved'))\"\n + \" || (before.state == 'acked' && item.state == 'resolved')\",\n message: 'illegal outage state transition (open \u2192 acked \u2192 resolved)',\n },\n // ONE uptime row per check per hour: the composed key is DERIVED\n // server-side, and `point_key` is unique \u2014 a racing second create is\n // a 409, never a second row. (Hooks do not run on $inc, which is fine:\n // an increment never changes check or bucket.)\n uptime_key: {\n collection: 'uptime_hourly',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'point_key',\n expr: \"concat(item.check, '|', item.bucket)\",\n },\n check_kind: {\n collection: 'checks',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.kind == 'http' || item.kind == 'keyword' || item.kind == 'heartbeat'\",\n message: 'kind must be http, keyword or heartbeat',\n },\n // A probed URL is https, always. A heartbeat check has no URL.\n check_url: {\n collection: 'checks',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.kind == 'heartbeat' || startsWith(item.url, 'https://')\",\n message: 'an http or keyword check needs an https:// url',\n },\n },\n // OPTIONAL live view for your admin screen: every outage write is pushed to\n // a realtime channel (enable the realtime feature too). Safe with\n // `payload: 'full'` because no `outages` field declares readRoles.\n // cdc: {\n // outages_live: { collection: 'outages', channel: 'monitor:outages', payload: 'full' },\n // },\n },\n\n // The page goes out as the shipped `transactional` message (a subject + a\n // paragraph + a button to the outage) on e-mail AND the in-app inbox.\n notifications: {\n provider: 'mock', // switch to your e-mail provider before production\n fromEmail: 'monitor@example.com',\n fromName: 'Service monitor',\n inboxEnabled: true,\n },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // What to watch. One row per endpoint or heartbeat.\n checks: {\n singular: 'check',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // 'api-health'\n kind: { type: 'string', required: true, indexSlot: 's2' }, // http | keyword | heartbeat (hook-checked)\n component: { type: 'string', indexSlot: 's3' }, // a status_components key\n state: { type: 'string', indexSlot: 's4', validation: { enum: ['up', 'degraded', 'down', 'paused'] } },\n consecutive_failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_latency_ms: { type: 'float', indexSlot: 'n2' },\n next_due_at: { type: 'datetime', indexSlot: 't1' }, // the probe's work-queue filter\n last_checked_at: { type: 'datetime', indexSlot: 't2' },\n // \u2500\u2500 unslotted \u2500\u2500\n name: { type: 'string', validation: { max: 120 } },\n url: { type: 'string', validation: { max: 2000 } }, // https only (check_url hook)\n expect_status: { type: 'int', validation: { min: 100, max: 599 } }, // default 200\n expect_contains: { type: 'string', validation: { max: 200 } }, // keyword checks\n send_probe_token: { type: 'bool' }, // send `x-probe-token: <probe_token secret>`\n timeout_ms: { type: 'int', validation: { min: 1000, max: 15000 } }, // default 10000\n degraded_ms: { type: 'int', validation: { min: 1 } }, // slower than this \u21D2 degraded\n interval_min: { type: 'int', validation: { min: 1, max: 1440 } }, // default 1\n failures_to_down: { type: 'int', validation: { min: 1, max: 10 } }, // default 2\n enabled: { type: 'bool', indexed: true },\n heartbeat_grace_min: { type: 'int', validation: { min: 1, max: 10080 } }, // heartbeat checks\n last_heartbeat_at: { type: 'datetime' },\n hour_bucket: { type: 'string' }, // 'YYYY-MM-DDTHH' of hour_row\n hour_row: { type: 'string' }, // the current uptime_hourly item id\n hour_max: { type: 'float' }, // the slowest sample this hour (drives latency_max)\n open_outage: { type: 'string' }, // the outage this down-crossing opened\n last_error: { type: 'text' },\n },\n },\n\n // Hourly counters: ok / total per check per hour, incremented atomically.\n uptime_hourly: {\n singular: 'uptime_point',\n fields: {\n check: { type: 'string', required: true, indexSlot: 's1' }, // a checks key\n bucket: { type: 'string', required: true, indexSlot: 's2' }, // 'YYYY-MM-DDTHH' (UTC)\n point_key: { type: 'string', indexSlot: 's3', unique: true }, // derived: check|bucket\n ok: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n total: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n ts: { type: 'datetime', indexSlot: 't1' }, // the hour's start \u2014 the report window field\n latency_sum: { type: 'float' },\n latency_max: { type: 'float' },\n },\n },\n\n // One row per down-crossing. Created under a lock + guard, so a check can\n // never have two open outages, however often a tick is re-delivered.\n outages: {\n singular: 'outage',\n fields: {\n check: { type: 'string', required: true, indexSlot: 's1' },\n state: { type: 'string', indexSlot: 's2', validation: { enum: ['open', 'acked', 'resolved'] } },\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['minor', 'major'] } },\n acked_by: { type: 'string', indexSlot: 's4' }, // the acknowledging staff member\n opened_at: { type: 'datetime', indexSlot: 't1' },\n resolved_at: { type: 'datetime', indexSlot: 't2' },\n title: { type: 'string', validation: { max: 200 } },\n cause: { type: 'text' },\n duration_min: { type: 'float' },\n paged: { type: 'json' }, // who was paged, when \u2014 written once (null \u21D2 not yet)\n escalated_at: { type: 'datetime' }, // the secondary was paged\n },\n },\n\n // \u2B22 PUBLIC: the updates the team PUBLISHES. A note stays a draft \u2014 and a\n // draft is never served on the public lane.\n outage_updates: {\n singular: 'outage_update',\n public: true,\n fields: {\n outage: { type: 'relation', relationTo: 'outages', required: true, indexSlot: 's1' },\n kind: {\n type: 'string', required: true, indexSlot: 's2',\n validation: { enum: ['investigating', 'identified', 'monitoring', 'resolved', 'note'] },\n },\n // the author's user id: visible to signed-in staff with a role, never on\n // the public lane (a readRoles field is never served anonymously)\n author: { type: 'string', indexSlot: 's3', readRoles: ['owner', 'admin', 'viewer', 'oncall', 'monitor-admin'] },\n at: { type: 'datetime', indexSlot: 't1' },\n component: { type: 'string' }, // the status_components key it is about\n body: { type: 'text', validation: { max: 2000 } },\n },\n },\n\n // \u2B22 PUBLIC: the components on your status page and their current status.\n status_components: {\n singular: 'status_component',\n public: true,\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // 'api'\n name: { type: 'string', required: true, indexSlot: 's2' }, // 'API'\n group: { type: 'string', indexSlot: 's3' },\n status: {\n type: 'string', indexSlot: 's4',\n validation: { enum: ['operational', 'degraded', 'partial_outage', 'major_outage'] },\n },\n sort: { type: 'int', indexSlot: 'n1' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n description: { type: 'text' },\n },\n },\n\n // Who is on call, as DATA. The probe pages whoever's shift covers now.\n oncall_shifts: {\n singular: 'oncall_shift',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' }, // the staff member's user id\n email: { type: 'string', indexSlot: 's2' }, // optional delivery override\n role: { type: 'string', indexSlot: 's3', validation: { enum: ['primary', 'secondary'] } },\n starts_at: { type: 'datetime', required: true, indexSlot: 't1' },\n ends_at: { type: 'datetime', required: true, indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // THE PROBE. Every minute: due checks \u2192 probe in parallel \u2192 counters \u2192\n // once-per-crossing outages, component status and pages \u2192 escalation.\n probe: {\n entry: './functions/probe.ts',\n // Free plan: '*/15 * * * *' (its fastest function cron). `overlap: 'skip'`:\n // a tick still running never runs beside the next one.\n trigger: { kind: 'cron', schedule: '* * * * *', overlap: 'skip' },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n secrets: ['secret:slack_webhook_url', 'secret:probe_token'],\n // Deny-by-default egress: EVERY probed host must be listed here, plus the\n // chat host ('discord.com' for Discord, 'chat.googleapis.com' for a Google\n // Chat space webhook). Private, loopback and internal hosts are unreachable\n // by design \u2014 expose a public health endpoint guarded by the probe token.\n egressAllow: ['status.example.com', 'api.example.com', 'hooks.slack.com'],\n limits: { timeoutMs: 15000 }, // per outbound fetch; a check's own timeout_ms is \u2264 this\n },\n\n // DEAD-MAN'S SWITCH. Your own cron jobs call it when they finish:\n // POST /v1/fn/heartbeat {\"check\":\"nightly-backup\"}\n // with a key holding ONLY functions:invoke, kept in their CI secret store.\n heartbeat: {\n entry: './functions/heartbeat.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // STAFF ACTIONS: ack \xB7 resolve \xB7 post_update (outages.manage) and pause \xB7\n // resume \xB7 publish_component (checks.manage), each re-checked live against\n // the caller's org role.\n 'outage-action': {\n entry: './functions/outage-action.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read'],\n egressAllow: [],\n },\n\n // THE MORNING E-MAIL. 08:00 UTC \u2014 function crons are UTC; if you need local\n // time, enqueue it from a jobs schedule that carries a timezone instead.\n 'daily-report': {\n entry: './functions/daily-report.ts',\n trigger: { kind: 'cron', schedule: '0 8 * * *', overlap: 'skip' },\n scopes: ['cms:read', 'notifications:send'],\n egressAllow: [],\n },\n },\n\n // References only \u2014 values are stored once and never appear in this file.\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL for pages' },\n probe_token: { feature: 'functions', description: 'shared token your public health endpoints require in x-probe-token' },\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret for your identity provider' },\n },\n\n // Seeds land as DRAFTS (a seed is a plain create). Publish the two components\n // once \u2014 `outage-action` `publish_component`, or the dashboard \u2014 and they\n // appear on the status page. The http checks land PAUSED (state 'paused',\n // enabled false) until their hosts are in the probe's egressAllow; turn each\n // on with `outage-action` `resume`, whose precondition is `state: 'paused'`.\n seed: {\n cms: [\n {\n collection: 'status_components',\n items: [\n { key: 'api', name: 'API', group: 'Core', status: 'operational', sort: 1, updated_at: '2026-01-01T00:00:00Z' },\n { key: 'website', name: 'Website', group: 'Core', status: 'operational', sort: 2, updated_at: '2026-01-01T00:00:00Z' },\n ],\n },\n {\n collection: 'checks',\n items: [\n {\n key: 'api-health', name: 'API health', kind: 'http', component: 'api', state: 'paused',\n url: 'https://api.example.com/health', expect_status: 200, send_probe_token: true,\n timeout_ms: 10000, degraded_ms: 2000, interval_min: 1, failures_to_down: 2,\n enabled: false, consecutive_failures: 0, next_due_at: '2026-01-01T00:00:00Z',\n },\n {\n key: 'website-home', name: 'Website', kind: 'keyword', component: 'website', state: 'paused',\n url: 'https://status.example.com/', expect_status: 200, expect_contains: '<title>',\n timeout_ms: 10000, degraded_ms: 3000, interval_min: 5, failures_to_down: 2,\n enabled: false, consecutive_failures: 0, next_due_at: '2026-01-01T00:00:00Z',\n },\n {\n key: 'nightly-backup', name: 'Nightly backup', kind: 'heartbeat', component: 'api', state: 'up',\n heartbeat_grace_min: 1500, interval_min: 15, failures_to_down: 1,\n enabled: true, consecutive_failures: 0, next_due_at: '2026-01-01T00:00:00Z',\n },\n ],\n },\n ],\n },\n});\n",
19410
- "readme": '# Service monitor (ops)\n\nUptime and health checks for your team\'s **public endpoints**, **dead-man\'s-switch heartbeats** from your\nown scheduled jobs, outages that open and close **once per state crossing**, the on-call shift **paged from\ndata** in chat, e-mail and the in-app inbox, staff who acknowledge, resolve and post updates, and a\n**keyless public status page** that shows component status and only the updates you chose to publish.\n\nIt is four functions and six collections. There is no monitoring engine, no escalation engine and no\nconnector in it: a probe is a cron function behind a deny-by-default egress allowlist, an outage is a row\nwhose creation is guarded, a page is a read of who is on shift right now, and escalation is one more\nfilter in the same cron.\n\n```bash\nmkdir service-monitor && cd service-monitor && vxil init --template service-monitor # init scaffolds into the current directory\nvxil quickstart --env staging --no-push --invite <code> # or `vxil link <slug> --env staging`\nvxil projects workload <slug> staging # Free plan: functions deploy on staging/development\nprintf \'%s\' "$SLACK_URL" | vxil secrets set functions/slack_webhook_url\nprintf \'%s\' "$PROBE_TOKEN" | vxil secrets set functions/probe_token\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nvxil push # collections, hooks, auth, orgs, the four functions\nvxil seed # 2 components, 2 http checks (paused), 1 heartbeat\n```\n\nThen, once:\n\n```bash\n# the two custom roles (rows, not config \u2014 `viewer` and `owner` are built in)\nvxil api POST /v1/orgs/roles --data \'{"role_key":"oncall","name":"On call","permissions":["outages.manage"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"monitor-admin","name":"Monitor admin","permissions":["outages.manage","checks.manage"]}\'\n# the team org (its owner holds every permission), then each staff member with a role\n# \u2014 a member\'s user id exists after their first sign-in\nvxil api POST /v1/orgs --data \'{"slug":"platform","name":"Platform team","owner_user_id":"<your user id>"}\'\nvxil api POST /v1/orgs/<org_id>/members --data \'{"user_id":"<user id>","role":"oncall"}\'\n# who is on call, as data\nvxil api POST /v1/cms/items/oncall_shifts --data \'{"status":"published","data":{"user":"<user id>","role":"primary","starts_at":"2026-10-06T08:00:00Z","ends_at":"2026-10-13T08:00:00Z"}}\'\n```\n\nEdit `issuer`, `clientId` and `allowedDomains` in `vxil.config.ts` to your identity provider before you push.\n\n**Both probe secrets must be set before the probe can run**: a function whose declared secret has no value\nis refused at invoke (`409 secret_missing`), so the cron would fail every tick. No chat? Delete\n`\'secret:slack_webhook_url\'` from the probe\'s `secrets` (the probe then pages by e-mail and inbox only). No\ntoken-guarded endpoint yet? Set `probe_token` to any random value, or delete it the same way.\n\n> **Plan note.** Free deploys functions to `staging` and `development` projects, caps a project at 5\n> functions and 3 crons, and runs a cron every 15 minutes at the fastest. This blueprint uses 4 functions\n> and 2 crons, so it fits \u2014 change the probe\'s `schedule` to `*/15 * * * *` on Free (the config comment\n> says so), or the deploy answers `402 plan_limit`. Developer and above run it every minute.\n\n## The collections\n\n| Collection | What it holds | Who reads it |\n|---|---|---|\n| `checks` | what to watch: `key`, `kind` (`http` \xB7 `keyword` \xB7 `heartbeat`), `url`, expected status/text, timeout, `degraded_ms`, `interval_min`, `failures_to_down`, and the live state (`up` \xB7 `degraded` \xB7 `down` \xB7 `paused`) | staff |\n| `uptime_hourly` | one row per check per hour: `ok`, `total`, `latency_sum`, `latency_max` | staff, the daily report |\n| `outages` | one row per down-crossing: `open` \u2192 `acked` \u2192 `resolved`, who acked, duration, who was paged | staff |\n| `outage_updates` \u2B22 | the updates staff write; **only published ones are public** | everyone (published only) |\n| `status_components` \u2B22 | the components on your status page and their status | everyone (published only) |\n| `oncall_shifts` | who is on call, `primary` or `secondary`, from when to when | staff, the probe |\n\nThree Lane-A hooks guard the data in the same write: `outage_stage` (open \u2192 acked \u2192 resolved, resolved is\nfinal), `check_kind` and `check_url` (an http or keyword check needs an `https://` url), and `uptime_key`,\nwhich derives `point_key = check|bucket` server-side on a field declared `unique` \u2014 so one hour can never\nhave two rows for the same check.\n\n## Probes: a cron function with deny-by-default egress\n\n`probe` runs every minute. It reads the due checks \u2014 `enabled`, `next_due_at \u2264 now`, oldest first, at most\n`MAX_PER_TICK` (40) \u2014 and probes them in parallel:\n\n- **http** \u2014 `GET url`, with the check\'s `timeout_ms` (\u2264 15 s), `redirect: \'manual\'`, and the\n `x-probe-token` header when the check sets `send_probe_token`. Up when the status equals `expect_status`\n (200 by default).\n- **keyword** \u2014 the same, and the body must contain `expect_contains`.\n- **heartbeat** \u2014 no fetch at all (below).\n\nA **redirect is a failure** with reason `redirect`. The probe never follows one: a public host can redirect\nto a private one, so the platform\'s egress guard refuses to follow redirects, and the probe reports it\nrather than chasing it. Point a check at the final URL.\n\n**Every probed host must be on the probe\'s `egressAllow`.** A host that is not listed fails with\n`host not in egressAllow` \u2014 nothing leaves the function unless you named it. Add the chat host too\n(`hooks.slack.com`; `discord.com` for Discord; `chat.googleapis.com` for a Google Chat space webhook, which\ntakes `{ text }` alone \u2014 the function sends only that field to it).\n\n**What cannot be probed.** Private networks, loopback and internal hostnames are unreachable from a\nfunction by design. To watch an internal service, expose a small **public health endpoint** for it and\nguard it with the probe token:\n\n```text\nGET https://api.example.com/health x-probe-token: <probe_token>\n\u2192 200 when the service and its dependencies answer, 503 otherwise; 401 without the token\n```\n\nThe token is a per-function secret (`secret:probe_token`), so it never appears in your config or your\nrepository.\n\nThe seeded http checks land **paused** (`state: "paused"`, `enabled: false`) until their hosts are on the\nallowlist: edit the URLs, list the hosts, `vxil push`, then turn each one on with\n`{"op":"resume","check":"api-health"}` (and `website-home`) through `outage-action`. `resume` only accepts a\npaused check, sets it `up` and due now, and the next tick probes it.\n\n## Dead-man\'s-switch heartbeats\n\nSome failures make no request fail: a nightly backup that silently stopped running. A `heartbeat` check\ninverts the probe \u2014 **your job calls vxil** when it finishes, and silence is the alarm:\n\n```bash\n# the last step of the nightly backup job, wherever it runs\ncurl -s -X POST https://api.vxil.com/v1/fn/heartbeat \\\n -H "authorization: Bearer $VXIL_HEARTBEAT_KEY" -H \'content-type: application/json\' \\\n -d \'{"check":"nightly-backup"}\'\n```\n\n`VXIL_HEARTBEAT_KEY` is a **Server-class** key holding **only `functions:invoke`** (the `job-heartbeat` row in\n[the key table](#keys)), stored in that job\'s CI secret store. The\nfunction stamps `last_heartbeat_at` and makes the check due now; the probe marks the check down when the\nlast heartbeat is older than `heartbeat_grace_min` (no fetch, no egress). A check that has never beaten is\nnot judged: there is no `pending` state, so it keeps the state it has (`up` for a new check) and each probe\nrun only reschedules it, reporting it with `pending: true` in that run\'s result until the first heartbeat\narrives. The function answers `404` for an unknown check,\n`409` for a paused one or a non-heartbeat check, and `403` when it is called from a signed-in session:\nheartbeats are machine calls.\n\nA `functions:invoke` key can call any of the project\'s functions over HTTP, not only this one. Treat it\nlike any other credential, and keep `outage-action` safe on its own: it refuses a caller without a staff\nsession.\n\n## Once-per-crossing outages, paged from data\n\nThe state machine is small:\n\n- **up \u2192 down** after `failures_to_down` **consecutive** failures (2 by default). One failure is a blip.\n- **down \u2192 up** on the **first** success.\n- **degraded** when a successful answer took longer than `degraded_ms` \u2014 the component turns `degraded`\n and the chat gets one line, but no outage opens.\n\nEach tick first **claims** the check with one `If-Match` write that also moves `next_due_at` forward. A\nre-delivered or overlapping tick gets `409` there and stands down, so everything after the claim happens\nonce.\n\nOn the down-crossing the probe creates the outage under a **lock and a guard**:\n\n```json\nPOST /v1/cms/items/outages\n{ "data": { "check": "api-health", "state": "open", "severity": "major", \u2026 },\n "status": "published",\n "lock": "outage:api-health",\n "guard": { "filter": { "check": "api-health", "state": { "$in": ["open", "acked"] } }, "max": 1 } }\n```\n\nA check can therefore never have two open outages: a second create is `409 guard_failed`, which the probe\ntreats as *already open* and adopts. It then **pages the shift that covers now** \u2014 `oncall_shifts` with\n`starts_at \u2264 now < ends_at` and role `primary` \u2014 in chat once, and to each person by e-mail and in the\nin-app inbox. The page is claimed on the outage with an `if: { "paged": null }` precondition, so however\nthe tick is delivered, an outage pages at most once.\n\n**Escalation is not an engine.** In the same tick, the probe looks for outages still `open` (nobody acked)\n`ESCALATE_AFTER_MIN` (15) minutes after they opened, stamps `escalated_at` on each with a precondition, and\npages the `secondary`. Set the constant to `0` to turn it off.\n\nOn recovery the probe resolves the outage with an `if` on its state (`open` or `acked`), writes\n`duration_min`, and posts **recovered** once. If a staff member resolved it by hand while the check was\nstill failing, the probe does not reopen it until the check has recovered and failed again.\n\n## Keys\n\nMint the keys in the dashboard under **Keys \u2192 New key** (or with `vxil keys mint`, which needs a dashboard\nsession: `vxil login` first). Two of them live in the staff app, one in your jobs:\n\n| Key | Class | Scopes | Where it lives | Why |\n|---|---|---|---|---|\n| `web-auth` | Server | `auth:signin` | the staff app | signing in is how a session is obtained, so this key cannot require one |\n| `web-data` | Public / thin-client | `cms:read`, `functions:invoke` | the staff app | refused by the edge without a valid staff session |\n| `job-heartbeat` | Server | `functions:invoke` | your job\'s CI secret store, **never a browser** | heartbeats are machine calls with no session |\n\n`web-data` holds **no `cms:write`**: the staff app reads the collections directly, and every write goes\nthrough `outage-action`, which checks the person\'s permission. Because it is a thin-client key, every call\nit makes carries a signed-in session, and `heartbeat` refuses any call that carries one. **The staff app\ntherefore cannot send a heartbeat.**\n\n**Never ship a Server-class key with `functions:invoke` in a browser.** Anyone can copy a key out of a\npage. A Server-class key with no session can call `heartbeat` for any check key, as often as it likes, and\neach fake beat keeps that check `up`. A backup that stopped running would then never page anyone, which\ndefeats the heartbeat check. The heartbeat\'s only authentication is the key: the function accepts any caller\nholding `functions:invoke` *without* a session. Keep `job-heartbeat` in the job\'s secret store, give it no\nother scope, and rotate it like any other credential.\n\n`cms:read` on `web-data` lets **anyone who can sign in** read the staff collections, including check URLs,\noutages and the on-call rota. Signing in is fenced only by `allowedDomains`, so that means anyone at your\ncompany domain, not just members of the team org. Org roles gate the actions, not the reads. If the rota or\nthe internal URLs should be narrower than that, assign the identity-provider app to the on-call group only.\n\n## Staff actions\n\nYour staff app calls `outage-action` with the `web-data` key plus the signed-in staff member\'s session:\n\n```json\nPOST /v1/fn/outage-action\n{ "op": "ack", "outage_id": "\u2026" }\n{ "op": "resolve", "outage_id": "\u2026" }\n{ "op": "post_update", "outage_id": "\u2026", "kind": "identified", "body": "\u2026", "publish": true,\n "component_status": "partial_outage" }\n{ "op": "pause", "check": "api-health" }\n{ "op": "resume", "check": "api-health" }\n{ "op": "publish_component", "key": "api" }\n```\n\n`ack`, `resolve` and `post_update` need **`outages.manage`**; `pause`, `resume` and `publish_component` need\n**`checks.manage`**. The function re-checks the permission **live** on every call\n(`GET /v1/orgs/session-claims`), not from the role in the session, so a member you remove loses the buttons\nat once. Every state change is a conditional write and the `outage_stage` hook is the authority, so two\npeople pressing *Ack* at once produce one ack and one `409`.\n\n## The public status page\n\n`status_components` and `outage_updates` are `public: true`. Their **published** rows are readable with **no\nAPI key**, edge-cached, from any front end:\n\n```bash\ncurl "https://api.vxil.com/v1/cms/public/<TENANT_ID>/status_components?sort=sort"\ncurl "https://api.vxil.com/v1/cms/public/<TENANT_ID>/outage_updates?sort=-at&limit=20"\n```\n\n- **Only published rows are served.** A `post_update` with `publish: false` \u2014 and every `kind: "note"` \u2014\n is written as a **draft**: staff see it, the public never does. Notes are where internal detail belongs.\n- **Never put an internal URL, hostname or customer name in a published update.** The body is public the\n moment it is published.\n- The `author` of an update is role-gated (`readRoles`), so the public lane never serves your staff\'s ids.\n- Seeds land as drafts. Publish the two seeded components once:\n `{"op":"publish_component","key":"api"}` and `{"op":"publish_component","key":"website"}`.\n- The checks, their URLs, the outages and the shifts are **not** public. A component\'s status moves when\n the probe sees a crossing, or when staff publish an update with `component_status`.\n\n## Uptime counters\n\nOne `uptime_hourly` row per check per hour. The first sample of an hour creates the row (`point_key` is\nunique, so a racing create is a `409`, which turns into an increment on the existing row); every later\nsample is one atomic write:\n\n```json\nPATCH /v1/cms/items/uptime_hourly/<id>\n{ "$inc": { "ok": 1, "total": 1, "latency_sum": 182.4 } }\n```\n\n`$inc` is a single conditional statement \u2014 no read first, no lost update. `latency_max` is written only\nwhen a sample beats the hour\'s maximum.\n\n```\nuptime % = 100 \xD7 \u03A3 ok / \u03A3 total over the rows in the window\navg latency = \u03A3 latency_sum / \u03A3 ok (successful samples only)\n```\n\n`daily-report` (08:00 UTC) computes the first one for every check in **one server-side aggregate** \u2014\ngroup by `check`, sum `ok` and `total`, a 1-day window on `ts` \u2014 and e-mails it, worst first, with the\noutages opened that day, to whoever is on shift. Function crons run in UTC; for local time, enqueue the\nreport from a jobs schedule that carries a timezone.\n\n## Compose it\n\n- **alerts-to-slack** watches the *platform\'s own* failure events \u2014 this blueprint\'s functions failing,\n dead-lettered notifications, a quarantined function. Run both: one watches your services, the other\n watches the monitor.\n- **payments-heartbeat** is the same once-per-crossing pattern applied to silence from a payments\n integration\'s provider.\n- An **ops dashboard** in the same project can read `checks`, `outages` and `uptime_hourly` for its tiles \u2014\n the collection names here are specific to monitoring, so they do not clash.\n\n`vxil push` writes each feature\'s config as a whole, so to combine blueprints in one project, copy the\ncollections, hooks and functions of one into the other\'s `vxil.config.ts` and push that file.\n\n## Cadence and counts\n\n| | Free | Developer and above |\n|---|---|---|\n| probe cron | `*/15 * * * *` | `* * * * *` |\n| functions / crons used | 4 of 5 / 2 of 3 | 4 / 2 |\n| checks per tick | 40 (oldest due first) | 40 \u2014 raise `MAX_PER_TICK` with care: each check costs a probe and 2\u20133 writes |\n| finest interval | 15 minutes | 1 minute |\n\nA tick probes up to 40 checks; the rest wait one tick. With the default one-minute interval that is about\n40 checks per project; at `interval_min: 5` it is about 200.\n\n## What to learn from this\n\n- **Deny-by-default egress is a feature of a monitor.** The probe can reach exactly the hosts you listed \u2014\n and the public health endpoint plus a token is how an internal service joins the list safely.\n- **A crossing is a write that can only succeed once.** A lock + guard, an `If-Match` claim, and an `if`\n precondition turn an at-least-once cron into exactly one outage, one page and one "recovered".\n- **On-call is data.** Who to page is a filtered read of `oncall_shifts`, and escalation is the same cron\n asking one more question.\n- **Public by publishing.** A keyless status page needs no server: two public collections, and a draft\n that stays internal until someone chooses to publish it.\n',
19505
+ "readme": '# Service monitor (ops)\n\nUptime and health checks for your team\'s **public endpoints**, **dead-man\'s-switch heartbeats** from your\nown scheduled jobs, outages that open and close **once per state crossing**, the on-call shift **paged from\ndata** in chat, e-mail and the in-app inbox, staff who acknowledge, resolve and post updates, and a\n**keyless public status page** that shows component status and only the updates you chose to publish.\n\nIt is four functions and six collections. There is no monitoring engine, no escalation engine and no\nconnector in it: a probe is a cron function behind a deny-by-default egress allowlist, an outage is a row\nwhose creation is guarded, a page is a read of who is on shift right now, and escalation is one more\nfilter in the same cron.\n\n```bash\nmkdir service-monitor && cd service-monitor && vxil init --template service-monitor # init scaffolds into the current directory\nvxil quickstart --env staging --no-push --invite <code> # or `vxil link <slug> --env staging`\nvxil projects workload <slug> staging # Free plan: functions deploy on staging/development\n# Free plan only: set the probe\'s schedule to \'*/15 * * * *\' in vxil.config.ts now (plan note below)\nprintf \'%s\' "$SLACK_URL" | vxil secrets set functions/slack_webhook_url\nprintf \'%s\' "$PROBE_TOKEN" | vxil secrets set functions/probe_token\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\n# now: set the oidc issuer / clientId / allowedDomains in vxil.config.ts to your identity provider\nvxil push # collections, hooks, auth, orgs, the four functions\nvxil seed # 2 components, 1 http + 1 keyword check (paused), 1 heartbeat check\n```\n\nThen, once. The org and the shifts need **user ids**, and staff only get one by signing in. Sign-in is\nOIDC only (password and magic link are off), and this blueprint ships no sign-in page: your staff app starts\nit with `vx.auth.oauth.startUrl(\'oidc\', { redirect_uri })` and finishes it at\n`GET /v1/auth/oauth/oidc/callback` (vxil.com/docs/guide/06-feature-catalog, "Starting social or SSO sign-in\nfrom a browser app"). After each person\'s first sign-in, look their id up by e-mail:\n\n```bash\nME=$(vxil api GET "/v1/users?email=you@yourco.com" | jq -r \'.users[0].id\')\nEVE=$(vxil api GET "/v1/users?email=eve@yourco.com" | jq -r \'.users[0].id\')\n```\n\nJust trying it out, with no identity provider yet? Create the rows yourself \u2014\n`vxil api POST /v1/users --data \'{"id":"usr_me","email":"you@yourco.com"}\'` \u2014 and use `usr_me` as `$ME`.\nThose users cannot sign in, but the org, the shift and the paging read all work.\n\n```bash\n# the two custom roles (rows, not config \u2014 `owner`, `admin`, `member` and `viewer` are built in)\nvxil api POST /v1/orgs/roles --data \'{"role_key":"oncall","name":"On call","permissions":["outages.manage"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"monitor-admin","name":"Monitor admin","permissions":["outages.manage","checks.manage"]}\'\n# the team org (its owner holds every permission), then each staff member with a role\nORG=$(vxil api POST /v1/orgs --data \'{"slug":"platform","name":"Platform team","owner_user_id":"\'$ME\'"}\' | jq -r .org_id)\nvxil api POST /v1/orgs/$ORG/members --data \'{"user_id":"\'$EVE\'","role":"oncall"}\'\n# who is on call, as data (pick dates that include today)\nvxil api POST /v1/cms/items/oncall_shifts --data \'{"status":"published","data":{"user":"\'$EVE\'","role":"primary","starts_at":"2026-10-06T08:00:00Z","ends_at":"2026-10-13T08:00:00Z"}}\'\n```\n\n**Both probe secrets must be set before the probe can run**: a function whose declared secret has no value\nis refused at invoke (`409 secret_missing`), so the cron would fail every tick. No chat? Delete\n`\'secret:slack_webhook_url\'` from the probe\'s `secrets` (the probe then pages by e-mail and inbox only). No\ntoken-guarded endpoint yet? Set `probe_token` to any random value, or delete it the same way.\n\n> **Plan note.** Free deploys functions to `staging` and `development` projects, caps a project at 5\n> functions and 3 crons, and runs a cron every 15 minutes at the fastest. This blueprint uses 4 functions\n> and 2 crons, so it fits \u2014 change the probe\'s `schedule` to `*/15 * * * *` on Free (the config comment\n> says so), or the deploy answers `402 plan_limit`. Developer and above run it every minute.\n\n## The collections\n\n| Collection | What it holds | Who reads it |\n|---|---|---|\n| `checks` | what to watch: `key`, `kind` (`http` \xB7 `keyword` \xB7 `heartbeat`), `url`, expected status/text, timeout, `degraded_ms`, `interval_min`, `failures_to_down`, and the live state (`up` \xB7 `degraded` \xB7 `down` \xB7 `paused`) | staff |\n| `uptime_hourly` | one row per check per hour: `ok`, `total`, `latency_sum`, `latency_max` | staff, the daily report |\n| `outages` | one row per down-crossing: `open` \u2192 `acked` \u2192 `resolved`, who acked, duration, who was paged | staff |\n| `outage_updates` \u2B22 | the updates staff write; **only published ones are public** | everyone (published only) |\n| `status_components` \u2B22 | the components on your status page and their status | everyone (published only) |\n| `oncall_shifts` | who is on call, `primary` or `secondary`, from when to when | staff, the probe |\n\nThree Lane-A hooks guard the data in the same write: `outage_stage` (open \u2192 acked \u2192 resolved, resolved is\nfinal), `check_kind` and `check_url` (an http or keyword check needs an `https://` url), and `uptime_key`,\nwhich derives `point_key = check|bucket` server-side on a field declared `unique` \u2014 so one hour can never\nhave two rows for the same check.\n\n## Probes: a cron function with deny-by-default egress\n\n`probe` runs every minute. It reads the due checks \u2014 `enabled`, `next_due_at \u2264 now`, oldest first, at most\n`MAX_PER_TICK` (40) \u2014 and probes them in parallel:\n\n- **http** \u2014 `GET url`, with the check\'s `timeout_ms` (\u2264 15 s), `redirect: \'manual\'`, and the\n `x-probe-token` header when the check sets `send_probe_token`. Up when the status equals `expect_status`\n (200 by default).\n- **keyword** \u2014 the same, and the body must contain `expect_contains`.\n- **heartbeat** \u2014 no fetch at all (below).\n\nA **redirect is a failure** with reason `redirect`. The probe never follows one: a public host can redirect\nto a private one, so the platform\'s egress guard refuses to follow redirects, and the probe reports it\nrather than chasing it. Point a check at the final URL.\n\n**Every probed host must be on the probe\'s `egressAllow`.** A host that is not listed fails with\n`host not in egressAllow` \u2014 nothing leaves the function unless you named it. Add the chat host too\n(`hooks.slack.com`; `discord.com` for Discord; `chat.googleapis.com` for a Google Chat space webhook, which\ntakes `{ text }` alone \u2014 the function sends only that field to it).\n\n**What cannot be probed.** Private networks, loopback and internal hostnames are unreachable from a\nfunction by design. To watch an internal service, expose a small **public health endpoint** for it and\nguard it with the probe token:\n\n```text\nGET https://api.example.com/health x-probe-token: <probe_token>\n\u2192 200 when the service and its dependencies answer, 503 otherwise; 401 without the token\n```\n\nThe token is a per-function secret (`secret:probe_token`), so it never appears in your config or your\nrepository.\n\nThe seeded http checks land **paused** (`state: "paused"`, `enabled: false`) until their hosts are on the\nallowlist: edit the URLs, list the hosts, `vxil push`, then turn each one on with\n`{"op":"resume","check":"api-health"}` (and `website-home`) through `outage-action`. `resume` only accepts a\npaused check, sets it `up` and due now, and the next tick probes it.\n\n## Dead-man\'s-switch heartbeats\n\nSome failures make no request fail: a nightly backup that silently stopped running. A `heartbeat` check\ninverts the probe \u2014 **your job calls vxil** when it finishes, and silence is the alarm:\n\n```bash\n# the last step of the nightly backup job, wherever it runs\ncurl -s -X POST https://api.vxil.com/v1/fn/heartbeat \\\n -H "authorization: Bearer $VXIL_HEARTBEAT_KEY" -H \'content-type: application/json\' \\\n -d \'{"check":"nightly-backup"}\'\n```\n\n`VXIL_HEARTBEAT_KEY` is a **Server-class** key holding **only `functions:invoke`** (the `job-heartbeat` row in\n[the key table](#keys)), stored in that job\'s CI secret store. The\nfunction stamps `last_heartbeat_at` and makes the check due now; the probe marks the check down when the\nlast heartbeat is older than `heartbeat_grace_min` (no fetch, no egress). A check that has never beaten is\nnot judged: there is no `pending` state, so it keeps the state it has (`up` for a new check) and each probe\nrun only reschedules it, reporting it with `pending: true` in that run\'s result until the first heartbeat\narrives. The function answers `404` for an unknown check,\n`409` for a paused one or a non-heartbeat check, and `403` when it is called from a signed-in session:\nheartbeats are machine calls.\n\nA `functions:invoke` key can call any of the project\'s functions over HTTP, not only this one. Treat it\nlike any other credential, and keep `outage-action` safe on its own: it refuses a caller without a staff\nsession.\n\n## Once-per-crossing outages, paged from data\n\nThe state machine is small:\n\n- **up \u2192 down** after `failures_to_down` **consecutive** failures (2 by default). One failure is a blip.\n- **down \u2192 up** on the **first** success.\n- **degraded** when a successful answer took longer than `degraded_ms` \u2014 the component turns `degraded`\n and the chat gets one line, but no outage opens.\n\nEach tick first **claims** the check with one `If-Match` write that also moves `next_due_at` forward. A\nre-delivered or overlapping tick gets `409` there and stands down, so everything after the claim happens\nonce.\n\nOn the down-crossing the probe creates the outage under a **lock and a guard**:\n\n```json\nPOST /v1/cms/items/outages\n{ "data": { "check": "api-health", "state": "open", "severity": "major", \u2026 },\n "status": "published",\n "lock": "outage:api-health",\n "guard": { "filter": { "check": "api-health", "state": { "$in": ["open", "acked"] } }, "max": 1 } }\n```\n\nA check can therefore never have two open outages: a second create is `409 guard_failed`, which the probe\ntreats as *already open* and adopts. It then **pages the shift that covers now** \u2014 `oncall_shifts` with\n`starts_at \u2264 now < ends_at` and role `primary` \u2014 in chat once, and to each person by e-mail and in the\nin-app inbox. The page is claimed on the outage with an `if: { "paged": null }` precondition, so however\nthe tick is delivered, an outage pages at most once.\n\n**Escalation is not an engine.** In the same tick, the probe looks for outages still `open` (nobody acked)\n`ESCALATE_AFTER_MIN` (15) minutes after they opened, stamps `escalated_at` on each with a precondition, and\npages the `secondary`. Set the constant to `0` to turn it off.\n\nOn recovery the probe resolves the outage with an `if` on its state (`open` or `acked`), writes\n`duration_min`, and posts **recovered** once. If a staff member resolved it by hand while the check was\nstill failing, the probe does not reopen it until the check has recovered and failed again.\n\n## Keys\n\nMint the keys in the dashboard under **Keys \u2192 New key** (or with `vxil keys mint`, which needs a dashboard\nsession: `vxil login` first). Two of them live in the staff app, one in your jobs:\n\n| Key | Class | Scopes | Where it lives | Why |\n|---|---|---|---|---|\n| `web-auth` | Server | `auth:signin` | the staff app | signing in is how a session is obtained, so this key cannot require one |\n| `web-data` | Public / thin-client | `cms:read`, `functions:invoke` | the staff app | refused by the edge without a valid staff session |\n| `job-heartbeat` | Server | `functions:invoke` | your job\'s CI secret store, **never a browser** | heartbeats are machine calls with no session |\n\n`web-data` holds **no `cms:write`**: the staff app reads the collections directly, and every write goes\nthrough `outage-action`, which checks the person\'s permission. Because it is a thin-client key, every call\nit makes carries a signed-in session, and `heartbeat` refuses any call that carries one. **The staff app\ntherefore cannot send a heartbeat.**\n\n**Never ship a Server-class key with `functions:invoke` in a browser.** Anyone can copy a key out of a\npage. A Server-class key with no session can call `heartbeat` for any check key, as often as it likes, and\neach fake beat keeps that check `up`. A backup that stopped running would then never page anyone, which\ndefeats the heartbeat check. The heartbeat\'s only authentication is the key: the function accepts any caller\nholding `functions:invoke` *without* a session. Keep `job-heartbeat` in the job\'s secret store, give it no\nother scope, and rotate it like any other credential.\n\n`cms:read` on `web-data` lets **anyone who can sign in** read the staff collections, including check URLs,\noutages and the on-call rota. Signing in is fenced only by `allowedDomains`, so that means anyone at your\ncompany domain, not just members of the team org. Org roles gate the actions, not the reads. If the rota or\nthe internal URLs should be narrower than that, assign the identity-provider app to the on-call group only.\n\n## Staff actions\n\nYour staff app calls `outage-action` with the `web-data` key plus the signed-in staff member\'s session:\n\n```json\nPOST /v1/fn/outage-action\n{ "op": "ack", "outage_id": "\u2026" }\n{ "op": "resolve", "outage_id": "\u2026" }\n{ "op": "post_update", "outage_id": "\u2026", "kind": "identified", "body": "\u2026", "publish": true,\n "component_status": "partial_outage" }\n{ "op": "pause", "check": "api-health" }\n{ "op": "resume", "check": "api-health" }\n{ "op": "publish_component", "key": "api" }\n```\n\n`ack`, `resolve` and `post_update` need **`outages.manage`**; `pause`, `resume` and `publish_component` need\n**`checks.manage`**. The function re-checks the permission **live** on every call\n(`GET /v1/orgs/session-claims`), not from the role in the session, so a member you remove loses the buttons\nat once. Every state change is a conditional write and the `outage_stage` hook is the authority, so two\npeople pressing *Ack* at once produce one ack and one `409`.\n\n## The public status page\n\n`status_components` and `outage_updates` are `public: true`. Their **published** rows are readable with **no\nAPI key**, edge-cached, from any front end:\n\n```bash\ncurl "https://api.vxil.com/v1/cms/public/<TENANT_ID>/status_components?sort=sort"\ncurl "https://api.vxil.com/v1/cms/public/<TENANT_ID>/outage_updates?sort=-at&limit=20"\n```\n\n- **Only published rows are served.** A `post_update` with `publish: false` \u2014 and every `kind: "note"` \u2014\n is written as a **draft**: staff see it, the public never does. Notes are where internal detail belongs.\n- **Never put an internal URL, hostname or customer name in a published update.** The body is public the\n moment it is published.\n- The `author` of an update is role-gated (`readRoles`), so the public lane never serves your staff\'s ids.\n- Seeds land as drafts. Publish the two seeded components once:\n `{"op":"publish_component","key":"api"}` and `{"op":"publish_component","key":"website"}`.\n- The checks, their URLs, the outages and the shifts are **not** public. A component\'s status moves when\n the probe sees a crossing, or when staff publish an update with `component_status`.\n\n## Uptime counters\n\nOne `uptime_hourly` row per check per hour. The first sample of an hour creates the row (`point_key` is\nunique, so a racing create is a `409`, which turns into an increment on the existing row); every later\nsample is one atomic write:\n\n```json\nPATCH /v1/cms/items/uptime_hourly/<id>\n{ "$inc": { "ok": 1, "total": 1, "latency_sum": 182.4 } }\n```\n\n`$inc` is a single conditional statement \u2014 no read first, no lost update. `latency_max` is written only\nwhen a sample beats the hour\'s maximum.\n\n```\nuptime % = 100 \xD7 \u03A3 ok / \u03A3 total over the rows in the window\navg latency = \u03A3 latency_sum / \u03A3 ok (successful samples only)\n```\n\n`daily-report` (08:00 UTC) computes the first one for every check in **one server-side aggregate** \u2014\ngroup by `check`, sum `ok` and `total`, a 1-day window on `ts` \u2014 and e-mails it, worst first, with the\noutages opened that day, to whoever is on shift. Function crons run in UTC; for local time, enqueue the\nreport from a jobs schedule that carries a timezone.\n\n## Compose it\n\n- **alerts-to-slack** watches the *platform\'s own* failure events \u2014 this blueprint\'s functions failing,\n dead-lettered notifications, a quarantined function. Run both: one watches your services, the other\n watches the monitor.\n- **payments-heartbeat** is the same once-per-crossing pattern applied to silence from a payments\n integration\'s provider.\n- An **ops dashboard** in the same project can read `checks`, `outages` and `uptime_hourly` for its tiles \u2014\n the collection names here are specific to monitoring, so they do not clash.\n\n`vxil push` writes each feature\'s config as a whole, so to combine blueprints in one project, copy the\ncollections, hooks and functions of one into the other\'s `vxil.config.ts` and push that file.\n\n## Cadence and counts\n\n| | Free | Developer and above |\n|---|---|---|\n| probe cron | `*/15 * * * *` | `* * * * *` |\n| functions / crons used | 4 of 5 / 2 of 3 | 4 / 2 |\n| checks per tick | 40 (oldest due first) | 40 \u2014 raise `MAX_PER_TICK` with care: each check costs a probe and 2\u20133 writes |\n| finest interval | 15 minutes | 1 minute |\n\nA tick probes up to 40 checks; the rest wait one tick. With the default one-minute interval that is about\n40 checks per project; at `interval_min: 5` it is about 200.\n\n## What to learn from this\n\n- **Deny-by-default egress is a feature of a monitor.** The probe can reach exactly the hosts you listed \u2014\n and the public health endpoint plus a token is how an internal service joins the list safely.\n- **A crossing is a write that can only succeed once.** A lock + guard, an `If-Match` claim, and an `if`\n precondition turn an at-least-once cron into exactly one outage, one page and one "recovered".\n- **On-call is data.** Who to page is a filtered read of `oncall_shifts`, and escalation is the same cron\n asking one more question.\n- **Public by publishing.** A keyless status page needs no server: two public collections, and a draft\n that stays internal until someone chooses to publish it.\n',
19411
19506
  "functions": {
19412
19507
  "daily-report.ts": "// daily-report.ts \u2014 THE MORNING E-MAIL (a vxil function, cron trigger).\n//\n// Trigger: cron `0 8 * * *`. Function crons run in UTC; if your team wants it\n// at 08:00 local time, enqueue it from a jobs schedule that carries a timezone.\n//\n// ONE server-side aggregate over uptime_hourly \u2014 group by check, sum ok, sum\n// total, over the last day (the `ts` window) \u2014 gives every check's uptime:\n//\n// uptime % = 100 \xD7 \u03A3 ok / \u03A3 total (per check, last 24 h)\n//\n// plus the outages opened in the same window, e-mailed to whoever is on shift\n// now. A check with no samples in the window is simply absent from the table.\n\n// cron-walk: single-read \u2014 one bounded server-side aggregate (\u2264500 groups) and one bounded outage list are the whole job; nothing to page.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_OUTAGES_LISTED = 50;\nconst MAX_LINES = 60; // checks listed in the e-mail body\n\ninterface Group { key: { check?: string }; ok?: number; total?: number }\ninterface Row<T> { item_id: string; data: T }\ninterface OutageData { check: string; state?: string; title?: string; opened_at?: string; duration_min?: number }\ninterface ShiftData { user: string; email?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as CronFunctionEnvelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notify = env.scoped_jwts?.notifications;\n if (!cms || !notify) return Response.json({ error: 'missing cms or notifications scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date();\n const nowIso = now.toISOString();\n const since = new Date(now.getTime() - 86_400_000).toISOString();\n\n // 1. the aggregate: sum needs ok/total on n-slots (n1, n2); the 1-day window rides ts (t1)\n const agg = await fetch(`${base}/v1/cms/items/uptime_hourly/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'sum', field: 'ok', as: 'ok' },\n { fn: 'sum', field: 'total', as: 'total' },\n ],\n groupBy: ['check'],\n window: { field: 'ts', sinceDays: 1 },\n limit: 500,\n }),\n });\n if (!agg.ok) return Response.json({ error: 'aggregate_failed', status: agg.status }, { status: 502 });\n const groups = ((await agg.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n\n // 2. the outages opened in the same window\n const of = encodeURIComponent(JSON.stringify({ opened_at: { $gte: since } }));\n const ol = await fetch(`${base}/v1/cms/items/outages?filter=${of}&sort=-opened_at&limit=${MAX_OUTAGES_LISTED}`, { headers: H });\n const outages = ol.ok ? ((await ol.json()) as { data?: { items?: Row<OutageData>[] } }).data?.items ?? [] : [];\n\n // 3. who gets it: everyone on shift right now\n const sf = encodeURIComponent(JSON.stringify({ starts_at: { $lte: nowIso }, ends_at: { $gt: nowIso } }));\n const sl = await fetch(`${base}/v1/cms/items/oncall_shifts?filter=${sf}&limit=20`, { headers: H });\n const shifts = sl.ok ? ((await sl.json()) as { data?: { items?: Row<ShiftData>[] } }).data?.items ?? [] : [];\n if (shifts.length === 0) return Response.json({ checks: groups.length, outages: outages.length, sent: 0, reason: 'nobody on shift' });\n\n const rows = groups\n .filter((g) => g.key.check && (g.total ?? 0) > 0)\n .map((g) => ({ check: g.key.check!, pct: (100 * (g.ok ?? 0)) / (g.total ?? 1), total: g.total ?? 0 }))\n .sort((a, b) => a.pct - b.pct); // worst first\n const lines = rows.slice(0, MAX_LINES).map((r) => `${r.check}: ${r.pct.toFixed(2)} % of ${r.total} checks`);\n if (rows.length > MAX_LINES) lines.push(`\u2026 and ${rows.length - MAX_LINES} more`);\n const outageLines = outages.map((o) => `${o.data.title ?? o.data.check} \u2014 ${o.data.state ?? 'open'}${o.data.duration_min !== undefined ? ` (${o.data.duration_min} min)` : ''}`);\n const paragraph = [\n `Uptime, last 24 h (worst first): ${lines.length ? lines.join(' \xB7 ') : 'no samples'}.`,\n `Outages opened: ${outageLines.length ? outageLines.join(' \xB7 ') : 'none'}.`,\n ].join(' ');\n\n // one report per person per day, however often this tick is delivered\n const day = nowIso.slice(0, 10);\n let sent = 0;\n for (const s of dedupe(shifts.map((r) => r.data))) {\n const res = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notify}`, 'content-type': 'application/json', 'idempotency-key': `daily-report:${day}:${s.user}` },\n body: JSON.stringify({\n user_id: s.user,\n ...(s.email ? { to_email: s.email } : {}),\n template: 'transactional',\n channel: 'email',\n data: { subject: `Service monitor \u2014 daily report ${day}`, paragraph },\n }),\n }).catch(() => null);\n if (res?.ok) sent++;\n }\n return Response.json({ checks: rows.length, outages: outages.length, sent });\n },\n};\n\nfunction dedupe(shifts: ShiftData[]): ShiftData[] {\n const seen = new Set<string>();\n return shifts.filter((s) => (seen.has(s.user) ? false : (seen.add(s.user), true)));\n}\n",
19413
19508
  "heartbeat.ts": "// heartbeat.ts \u2014 THE DEAD-MAN'S SWITCH (a vxil function, http trigger).\n//\n// Your own scheduled jobs (a backup, an export, a nightly sync \u2014 wherever they\n// run) call this when they FINISH:\n//\n// curl -s -X POST https://api.vxil.com/v1/fn/heartbeat \\\n// -H \"authorization: Bearer $VXIL_HEARTBEAT_KEY\" -H 'content-type: application/json' \\\n// -d '{\"check\":\"nightly-backup\"}'\n//\n// with a SERVER-class key holding ONLY `functions:invoke`, kept in that job's CI\n// secret store and never shipped to a browser: the key is this endpoint's only\n// authentication, so whoever holds it can keep a dead job's check `up`. The\n// staff app's thin-client key always carries a session, which is refused here. It stamps `last_heartbeat_at` on the check and makes the check due now,\n// so the next probe tick (\u2264 1 minute) judges it \u2014 a check that was down\n// recovers on its first beat. The PROBE is what notices silence: a check whose\n// last heartbeat is older than its grace goes down there, with no fetch.\n//\n// 404 \u2014 no such check \xB7 409 \u2014 not a heartbeat check, or paused/disabled\n// 403 \u2014 called from a signed-in session: heartbeats are machine calls\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = HttpFunctionEnvelope<{ check?: string }>;\ninterface CheckData { key: string; kind?: string; state?: string; enabled?: boolean }\ninterface Row { item_id: string; data: CheckData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return fail(403, 'missing_scope', 'the function has no cms token');\n if (env.end_user) return fail(403, 'machine_only', 'a heartbeat comes from a job with a functions:invoke key, not from a signed-in session');\n const key = env.payload?.check;\n if (typeof key !== 'string' || !/^[A-Za-z0-9._:-]{1,120}$/.test(key)) {\n return fail(422, 'bad_check', 'send {\"check\":\"<check key>\"}');\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const found = await fetch(`${base}/v1/cms/items/checks?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return fail(502, 'lookup_failed', `checks read ${found.status}`);\n const row = ((await found.json()) as { data?: { items?: Row[] } }).data?.items?.[0];\n if (!row) return fail(404, 'unknown_check', `no check '${key}'`);\n if (row.data.kind !== 'heartbeat') return fail(409, 'not_a_heartbeat', `'${key}' is a ${row.data.kind} check`);\n if (row.data.state === 'paused' || row.data.enabled === false) return fail(409, 'paused', `'${key}' is paused`);\n\n const now = new Date().toISOString();\n const res = await fetch(`${base}/v1/cms/items/checks/${row.item_id}`, {\n method: 'PATCH',\n headers: H,\n // `if`: a check paused between the read and this write stays paused\n body: JSON.stringify({ data: { last_heartbeat_at: now, next_due_at: now }, if: { state: { $ne: 'paused' } } }),\n });\n if (res.status === 409) return fail(409, 'paused', `'${key}' is paused`);\n if (!res.ok) return fail(502, 'write_failed', `checks write ${res.status}`);\n return Response.json({ check: key, beat_at: now });\n },\n};\n\nconst fail = (status: number, code: string, message: string) => Response.json({ error: { code, message } }, { status });\n",
@@ -19435,7 +19530,7 @@ export default defineConfig({
19435
19530
  "vxil_read_key"
19436
19531
  ],
19437
19532
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Job Runner\" \u2014 the BACKGROUND-WORK blueprint. Four ways work leaves the\n// request path, and what happens when it fails:\n//\n// \u2022 enqueue \u2192 one-off work, deduplicated by an idempotency key, and\n// optionally deferred (seconds from now, or an exact time)\n// \u2022 schedules \u2192 cron, with a LATE schedule reporting itself\n// \u2022 queue trigger \u2192 a deployed function that IS the worker\n// \u2022 generation \u2192 a long provider call vxil babysits for you, holding\n// credits while it runs and releasing them when it ends\n// \u2022 the spine \u2192 every failure is an audit event; one cron function turns\n// the ones that matter into `incidents` rows\n//\n// The credits half is a payments INTEGRATION with the deterministic `mock`\n// provider, so the whole reserve \u2192 settle \u2192 refund story runs with no provider\n// account at all. \"Credits\" here are usage units, not money.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n // a failing delivery is retried this many times before it dead-letters\n retry: { defaultMaxAttempts: 3 },\n retention: { successfulRunDays: 7, failedRunDays: 30 },\n // once this many runs have dead-lettered today, a further failing run\n // skips its remaining retries and dead-letters immediately \u2014 a bound on\n // the churn ONE pathological target can generate. 0 = unlimited.\n dlqDailyQuota: 200,\n concurrency: { maxConcurrent: 5 },\n schedules: { maxPerTenant: 20 },\n generation: {\n maxConcurrent: 5,\n defaultTimeoutMs: 300_000, // 5 min unless the descriptor says otherwise\n pollMaxAttempts: 30, // give up after 30 polls \u2192 terminal fail\n maxReserveCredits: 200, // a single run can never hold more than this\n maxOutstandingReserveCredits: 5_000, // \u2026nor can all in-flight runs together\n },\n },\n\n // The ledger half. `mock` is the deterministic default provider: no keys,\n // no account, the entire credits path exercisable end to end. Point it at\n // your own Stripe/Paddle/PayPal/RevenueCat account when you go live \u2014 vxil\n // is never in the flow of funds.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n // product id \u2192 what buying it grants. Consumed by the provider webhook\n // reducer; the map is DATA, so adding products never changes the config.\n productMap: {\n render_pack_1000: { creditType: 'render_credits', amount: 1000, period: 'once' },\n },\n // tier \u2192 what being on it entitles you to, and what it tops up monthly\n tierMap: {\n pro: {\n entitlements: ['render'],\n quotas: { renders_per_day: 500 },\n rank: 10,\n grants: [{ creditType: 'render_credits', amount: 5_000, period: 'monthly' }],\n },\n },\n // a generation that ends in failure gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // The work items. One row per requested render.\n renders: {\n singular: 'render',\n fields: {\n // THE DEDUPE ANCHOR. Queue deliveries are at-least-once, so the\n // worker may see the same item twice; a unique field turns the second\n // write into a clean 409 the function treats as \"already done\".\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n title: { type: 'string', indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3' }, // queued | processing | done | failed\n run_id: { type: 'string', indexSlot: 's4' }, // the jobs run that owns it\n credits: { type: 'int', indexSlot: 'n1' }, // held for this render\n created_at: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n\n // The failure memory. One row per incident KEY, so ten dead letters of\n // the same job are one row \u2014 plus the reserved `__cursor__` row holding\n // the audit-stream watermark.\n incidents: {\n singular: 'incident',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n event: { type: 'string', indexSlot: 's2' }, // the audit event that raised it\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n subject: { type: 'string', indexSlot: 's4' }, // job name / schedule id\n seen_count: { type: 'int', indexSlot: 'n1' },\n first_seen: { type: 'datetime', indexSlot: 't1' },\n last_seen: { type: 'datetime', indexSlot: 't2' },\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE WORKER. A queue-triggered function is invoked by an enqueue, not by a\n // request \u2014 it has no URL a browser can reach. Delivery is at-least-once,\n // so it dedupes on `request_key` rather than assuming exactly-once.\n 'process-batch': {\n entry: './functions/process-batch.ts',\n trigger: { kind: 'queue', source: 'renders' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE FAILURE DRAIN. Every feature writes its lifecycle to your tenant's\n // audit stream; this reads the stream past a watermark every 5 minutes and\n // turns the failure events that matter into `incidents` rows.\n 'incident-watch': {\n entry: './functions/incident-watch.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n scopes: ['cms:read', 'cms:write'],\n // The audit stream is a control-plane read, not one of the feature APIs\n // the function's scoped callback covers \u2014 so the drain reads it with the\n // narrowest key that can: one holding ONLY `features:read`.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n },\n\n secrets: {\n vxil_read_key: {\n feature: 'functions',\n description: 'a vxil API key of this backend holding ONLY features:read \u2014 reads the audit stream',\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'renders',\n items: [\n {\n request_key: 'seed-0001',\n title: 'Quarterly report render',\n state: 'queued',\n credits: 10,\n created_at: '2026-04-01T08:00:00Z',\n notes: 'Seed row so the collection is not empty on first push.',\n },\n ],\n },\n ],\n },\n});\n",
19438
- "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nvxil init --template job-runner\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging`\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + both functions\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n`vxil_read_key` is an API key **of this same backend** holding **only `features:read`** \u2014 the\nincident drain reads the audit stream with it (dashboard \u2192 API keys \u2192 create, tick `features:read`\nand nothing else). Everything else here needs no credentials at all: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n> **On the Free plan** a function cron may run at most every 15 minutes \u2014 change `incident-watch` to\n> `*/15 * * * *` in `vxil.config.ts` before `vxil push`, or the deploy answers `402 plan_limit`\n> (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes (every fifteen on the Free plan). Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit:\n\n```bash\nvxil api GET /v1/webhooks/events/catalog\n# 200 { "data": { "count": 170, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": 21 }, \u2026 ] } } \u2190 34 prefixes\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** `templates/alerts-to-slack/` (the same drain, routed to a chat channel instead of a\ncollection) and `templates/payments-heartbeat/` (noticing the failure that is *silence*).\n',
19533
+ "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nmkdir my-jobs && cd my-jobs\nvxil init --template job-runner\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: set incident-watch\'s cron to \'*/15 * * * *\' in vxil.config.ts now (note below)\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name incident-audit --scopes audit:read --json | jq -r .api_key | vxil secrets set functions/vxil_read_key\nvxil push # collections + both functions; writes vxil.types.ts\nvxil seed # the demo render row\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n`vxil_read_key` is an API key **of this same backend** holding **only `audit:read`** \u2014 the\nincident drain reads the audit stream with it (the mint line above pipes it straight into the secret and\nneeds `jq`; or dashboard \u2192 API keys \u2192 create, tick `audit:read` and nothing else). The key is a holdover\nin the shipped function, not a platform limit: a function that declares the `audit:read` scope reads\n`/v1/audit` through its own scoped callback. `features:read` (what the config\'s comments name) also works,\nbut it also opens the whole-project export, so prefer `audit:read`. Everything else here needs no credentials at all: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n> **On the Free plan** a function cron may run at most every 15 minutes \u2014 change `incident-watch` to\n> `*/15 * * * *` in `vxil.config.ts` before `vxil push`, or the deploy answers `402 plan_limit`\n> (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`. The project key quickstart minted carries these (`vxil env pull && set -a && . ./.env.local && set +a`, then `KEY=$VXIL_API_KEY`); the `vxil api` lines need no variable at all.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .field`, not `jq .data.field`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes (every fifteen on the Free plan). Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit.\n\nThis blueprint does not enable `webhooks`, so the project key has no webhooks scopes and the two\ncalls below would get `403 insufficient_scope` through `vxil api`. Mint a key that has them (after\n`vxil login`) and call with it:\n\n```bash\nHOOKS_KEY=$(vxil keys mint --name ops-hooks --scopes webhooks:read,webhooks:write --json | jq -r .api_key)\ncurl -s https://api.vxil.com/v1/webhooks/events/catalog -H "authorization: Bearer $HOOKS_KEY"\n# { "count": \u2026, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": \u2026 }, \u2026 ] } \u2190 one entry per event prefix\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\ncurl -s -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H "authorization: Bearer $HOOKS_KEY" -H \'content-type: application/json\' \\\n -d \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** the **alerts-to-slack** blueprint (the same drain, routed to a chat channel instead of a\ncollection) and the **payments-heartbeat** blueprint (noticing the failure that is *silence*).\n',
19439
19534
  "functions": {
19440
19535
  "incident-watch.ts": "// incident-watch.ts \u2014 FAILURE EVENTS \u2192 `incidents` ROWS (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Every feature writes its lifecycle to your\n// tenant's AUDIT STREAM \u2014 that stream is the event spine, and this function is\n// one consumer of it. Each run:\n// 1. reads the watermark (the reserved `__cursor__` row in `incidents`; the\n// first run stores the NEWEST id and stops, so history never floods you),\n// 2. drains `GET /v1/audit/export?after_id=\u2026` (ascending NDJSON) past it,\n// 3. matches each row against RULES \u2014 an allow-list, not a firehose,\n// 4. upserts ONE row per incident key (ten dead letters of the same job are\n// one row with `seen_count: 10`, not ten rows),\n// 5. advances the watermark with If-Match, so an overlapping tick stands down\n// instead of double-counting.\n//\n// WHY A KEY: the audit stream is a platform read, not one of the feature APIs\n// the function's scoped callback covers \u2014 so the drain uses the narrowest key\n// that can reach it, one holding ONLY `features:read`, stored as a secret and\n// revocable without a redeploy.\n//\n// To fan the same events out to YOUR OWN https endpoint instead, subscribe:\n// POST /v1/webhooks/subscriptions { \"target_url\": \"\u2026\", \"event_prefixes\": [\"job.\"] }\n// Both ride the same spine; this one keeps the state inside your backend.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 rows \u2014 bounds one tick\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype Payload = Record<string, unknown>;\ninterface Rule {\n level: Level;\n key: (p: Payload) => string;\n subject: (p: Payload) => string;\n detail: (p: Payload) => string;\n /** a healing event: clears the row rather than incrementing it */\n resolves?: boolean;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\nconst num = (v: unknown) => (typeof v === 'number' ? v : null);\n\n/** THE ALLOW-LIST. Every name below is an audit event the jobs feature writes\n * today; anything not listed is ignored. Own this table \u2014 it is data, not a\n * routing engine. `GET /v1/webhooks/events/catalog` lists every event the\n * platform can emit if you want to widen it. */\nconst RULES: Record<string, Rule> = {\n // a run exhausted its retries\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `run ${str(p.run_id)} died after attempt ${num(p.attempt) ?? '?'}`,\n },\n // the tenant burned its daily dead-letter budget \u2014 a louder, rarer signal\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n subject: (p) => str(p.job_name),\n detail: (p) => `daily dead-letter quota spent; run ${str(p.run_id)} skipped its retries`,\n },\n // a schedule fired more than 2\xD7 its own interval late\n 'jobs.schedule.missed': {\n level: 'warn',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) =>\n `expected ${str(p.expected_at)}, observed ${str(p.observed_at)} `\n + `(${num(p.late_seconds) ?? '?'}s late on a ${num(p.interval_seconds) ?? '?'}s interval)`,\n },\n // \u2026and its healing twin, sharing the SAME key, so recovery closes the row\n 'jobs.schedule.recovered': {\n level: 'info',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `back on time at ${str(p.observed_at)}`,\n resolves: true,\n },\n // a generation run ended in a terminal failure (its held credits are released)\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'unclassified')}`,\n subject: (p) => str(p.error_class, 'unclassified'),\n detail: (p) => `run ${str(p.run_id)} failed (${str(p.error_class, 'no error class')})`,\n },\n};\n\ninterface AuditRow { id?: string | number; event?: string; created_at?: string; payload?: Payload }\ninterface Item { item_id: string; version: number; data: Record<string, unknown> }\n// the schedule tick, or a hand invoke (`POST /v1/fn/incident-watch`) carrying `{ dry_run }`\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{ dry_run?: boolean }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const readKey = env.secrets?.vxil_read_key;\n if (!cms || !readKey) {\n return Response.json({ error: 'missing cms scope or vxil_read_key secret' }, { status: 503 });\n }\n const store = new Store(base, cms);\n\n // 1. the watermark\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const newest = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: newest, last_seen: new Date().toISOString() });\n return Response.json({ initialized: true, cursor: newest, raised: 0 });\n }\n let cursor = String(cursorRow.data.cursor ?? '0');\n\n // 2. drain\n const rows: AuditRow[] = [];\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows: batch, next } = await drain(base, readKey, cursor);\n rows.push(...batch);\n if (batch.length > 0) cursor = String(batch[batch.length - 1]!.id ?? cursor);\n if (!next || batch.length === 0) break;\n cursor = next;\n }\n\n // 3 + 4. match and upsert\n let raised = 0;\n let resolved = 0;\n const now = new Date().toISOString();\n for (const row of rows) {\n const rule = RULES[String(row.event ?? '')];\n if (!rule) continue;\n const p = row.payload ?? {};\n const key = rule.key(p);\n const existing = await store.byKey(key);\n\n if (rule.resolves) {\n if (existing) {\n await store.patch(existing, {\n level: 'info', event: String(row.event), detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n resolved++;\n }\n continue;\n }\n if (existing) {\n await store.patch(existing, {\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: Number(existing.data.seen_count ?? 0) + 1,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n } else {\n await store.create({\n key,\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: 1,\n first_seen: now,\n last_seen: now,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n });\n }\n raised++;\n }\n\n // 5. advance \u2014 If-Match, so exactly one overlapping run wins\n if (!env.payload?.dry_run) {\n await store.patch(cursorRow, { cursor, last_seen: now });\n }\n return Response.json({ scanned: rows.length, raised, resolved, cursor });\n },\n};\n\n// \u2500\u2500 the audit stream (ascending NDJSON; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function drain(\n base: string, key: string, afterId: string,\n): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(\n `${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`,\n { headers: { authorization: `Bearer ${key}` } },\n );\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms store (the REST envelope is { data: { items: [{ item_id, version, data }] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() {\n return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' };\n }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/incidents?filter=${filter}&limit=1`, {\n headers: this.h(),\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 a concurrent run already claimed this unique key */\n async create(data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n /** false on 409 \u2014 a concurrent run already moved this row past `version` */\n async patch(item: Item, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents/${item.item_id}`, {\n method: 'PATCH',\n headers: { ...this.h(), 'if-match': String(item.version) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n",
19441
19536
  "process-batch.ts": "// process-batch.ts \u2014 THE WORKER (a vxil function).\n//\n// Trigger: queue. This function has no URL a browser can reach \u2014 it runs because\n// something was ENQUEUED. The envelope it receives is:\n// { trigger: 'queue', tenant_id, request_id, idempotency_key, vxil_base,\n// scoped_jwts, secrets, payload }\n// where `payload` is exactly the object you put inside the enqueue body's\n// `payload.payload`, and `idempotency_key` is stable per run.\n//\n// DELIVERY IS AT-LEAST-ONCE. A retry, an overlapping tick, or a replay can hand\n// you the same batch twice, so correctness cannot rest on \"it runs once\". Here\n// the `request_key` field on `renders` is declared `unique`, which turns the\n// second create into a clean 409 \u2014 and a 409 is not an error, it is the answer\n// \"already done\". That is the whole dedupe strategy: one declared field, and a\n// status code you agree to read as success.\n\nimport type { QueueFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = QueueFunctionEnvelope<{ batch_id?: string; items?: Array<{ key?: string; title?: string; credits?: number }> }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n const batchId = String(env.payload?.batch_id ?? env.idempotency_key ?? 'batch');\n const items = Array.isArray(env.payload?.items) ? env.payload!.items! : [];\n if (items.length === 0) return Response.json({ batch_id: batchId, created: 0, duplicates: 0 });\n\n let created = 0;\n let duplicates = 0;\n let rejected = 0;\n\n for (const [i, item] of items.entries()) {\n // Derive a stable key per item so the SAME batch always produces the SAME\n // keys \u2014 that is what makes the redelivery a duplicate rather than a copy.\n const requestKey = String(item.key ?? `${batchId}:${i}`);\n const res = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: {\n request_key: requestKey,\n title: String(item.title ?? requestKey),\n state: 'queued',\n run_id: env.idempotency_key ?? null,\n credits: typeof item.credits === 'number' ? item.credits : 0,\n created_at: new Date().toISOString(),\n },\n }),\n });\n if (res.ok) created++;\n else if (res.status === 409) duplicates++; // already processed \u2014 the point of `unique`\n else rejected++;\n }\n\n return Response.json({ batch_id: batchId, created, duplicates, rejected });\n },\n};\n"
@@ -19464,7 +19559,7 @@ export default defineConfig({
19464
19559
  "vxil_jobs_key"
19465
19560
  ],
19466
19561
  "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// \"Render Farm\" \u2014 long renders and transcodes (minutes, ffmpeg, a headless\n// browser) run on a runtime YOU rent: Trigger.dev, Modal, a container on your\n// own cloud account. vxil keeps the four things that must not be lost while\n// that runtime works: the credits held for the render, the deadline, the\n// signed completion callback, and the status row the app watches.\n//\n// request-render (your function, end-user mode)\n// \u2192 creates-or-finds the user's `renders` row (unique render_key)\n// \u2192 POST /v1/jobs/generation in WEBHOOK mode: your render endpoint,\n// credits HELD, a deadline, the status mirrored onto the row\n// vxil's generation lane\n// \u2192 POSTs your render endpoint { render_id, user_id, composition, props,\n// payload, callback_url } with your bearer token\n// your runtime (README: Trigger.dev, or your own containers)\n// \u2192 acks within 20 s, renders, POSTs { status: 'processing', \u2026 } and then\n// { status: 'completed', output_url, duration_s, \u2026 } to callback_url\n// vxil\n// \u2192 completed: credits COMMIT, every callback key lands on the row\n// \u2192 failed / no answer by the deadline: credits REFUNDED, row says failed\n// \u2192 job.generation.completed | failed \u2192 notify-ready tells the owner\n// redrive-pending (cron, every minute)\n// \u2192 starts renders that waited at the concurrency cap (429 \u2014 the app was\n// told `queued: true`), same key; tells the owner if one never starts\n//\n// No container tier and no workflow engine inside vxil: the runtime is yours,\n// the bookkeeping is vxil's. \"Credits\" are usage units on the deterministic\n// `mock` payments integration \u2014 not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n // how many renders may be in flight at once for this project\n maxConcurrent: 20,\n // a render that never calls back fails (and refunds) after 30 minutes\u2026\n defaultTimeoutMs: 1_800_000,\n // \u2026and no render may ask for more than the platform ceiling, one hour\n maxTimeoutMs: 3_600_000,\n // one render never holds more than 50 credits\n maxReserveCredits: 50,\n // all of this project's in-flight renders together hold at most 5,000\n maxOutstandingReserveCredits: 5_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n render_pack_100: { creditType: 'render_credits', amount: 100, period: 'once' },\n },\n // no subscription tiers in this blueprint \u2014 credits come from packs\n tierMap: {},\n // a render that fails, times out or is cancelled gives its credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a signed-in user sees only the renders they own\n strictEndUserScope: true,\n // Lane-A hook (guide ch. 7): render_key IS owner + ':' + request_key,\n // server-enforced, so one user's request_key can never collide with \u2014\n // or block \u2014 another user's.\n hooks: {\n render_key_shape: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.render_key == concat(item.owner, ':', item.request_key)\",\n message: \"render_key must be owner + ':' + request_key\",\n },\n },\n },\n\n notifications: { provider: 'mock', fromEmail: 'renders@render-farm.example' },\n functions: { enabled: true },\n\n // the README's keyless-container coordinator uploads each finished output\n // into this project's files (`output_file` below). The per-object ceiling\n // the upload-url call pre-checks is `maxObjectBytes` \u2014 100 MB by default,\n // which fits the coordinator's buffered upload (\"tens of MB\"); raise it\n // (up to 5 GB) for long or high-bitrate renders. Not using the coordinator?\n // Remove this and the `output_file` field.\n files: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR: a double tap or a retried request is a 409 that\n // request-render reads back \u2014 and the same key is the generation\n // run's idempotency_key, so the render endpoint is asked once.\n render_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n // which composition / preset your runtime renders (your vocabulary)\n composition: { type: 'string', required: true, indexSlot: 's3' },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n props: { type: 'json' },\n run_id: { type: 'text' },\n // the run's deadline_at, computed ONCE when the row is created and\n // re-sent unchanged by every start of it (request-render's re-drive,\n // redrive-pending): jobs hands an existing run back only to the SAME\n // request, so a value rebuilt per send answers 422\n // idempotency_key_reused once that check is enforced (logged today).\n // null = no run holds the key (after a 429 / 503): the next start\n // stores a fresh one.\n deadline_at: { type: 'datetime' },\n // \u2500\u2500 keys your runtime sends back. EVERY key of the completion body is\n // written onto this row, so each one must be a declared field (a\n // write naming an unknown field is refused: vxil then writes the\n // status word alone and the run reports `mirror_error` with the\n // keys it dropped \u2014 declare the field so its value lands too).\n output_url: { type: 'text' },\n // the files-feature object id, when a coordinator uploads the output\n // into this project's files (README \"Keys stay out of the container\")\n output_file: { type: 'file' },\n duration_s: { type: 'float' },\n // progress keys: a `processing` ping carries them onto the row\n // (request-render's status_mirror.progress_fields \u2014 progress 0..100,\n // a float: the run keeps 2 decimals,\n // stage \u2264 64 chars, message \u2264 200), and the completion body writes\n // them too (progress: 100, stage: 'done').\n progress: { type: 'float', validation: { min: 0, max: 100 } },\n stage: { type: 'string' },\n message: { type: 'text' },\n // written by notify-ready from job.generation.failed (and by\n // redrive-pending when a render never got a run)\n error: { type: 'text' },\n // written by redrive-pending: how often it tried to start this render,\n // and when it last did. The re-driver's read needs no new index \u2014\n // `status` (s4) and `created_at` (t1) are slots; `run_id: null` is a\n // residual test over the rows they pick.\n redrive_attempts: { type: 'int' },\n redriven_at: { type: 'datetime' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode\n // (with the user's session): the held credits are forced onto that user,\n // and the row is theirs.\n 'request-render': {\n entry: './functions/request-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // render_url: your render endpoint (https). render_token: the bearer\n // token that endpoint checks. Both ride the generation run; vxil's\n // generation lane \u2014 not this function \u2014 calls the endpoint.\n secrets: ['secret:render_url', 'secret:render_token'],\n egressAllow: [],\n signature: {\n input: { composition: 'string', request_key: 'string', props: 'json?' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; status: string }',\n },\n },\n\n // THE BACKLOG RE-DRIVER. At the generation cap request-render answers 429\n // and the row waits `pending` with no run; every minute this starts the\n // oldest such rows (\u2265 30 s old, 20 per tick) with the SAME idempotency key,\n // stops at the first 429 (or at a refusal that means the setup is wrong),\n // and fails \u2014 and tells the owner of \u2014 a row that waited over an hour.\n // overlap 'skip': a slow tick is never doubled by the next one.\n // Free plan: a function cron may fire at most every 15 minutes \u2014 use\n // '*/15 * * * *' there (README \"The backlog\").\n 'redrive-pending': {\n entry: './functions/redrive-pending.ts',\n trigger: { kind: 'cron', schedule: '* * * * *', overlap: 'skip' },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n // vxil_jobs_key: an API key of this backend holding ONLY jobs:write \u2014 a\n // cron tick has no signed-in user to hold credits for, so the start is\n // made as your trusted server (README \"The backlog\")\n secrets: ['secret:vxil_jobs_key', 'secret:render_url', 'secret:render_token'],\n egressAllow: [],\n },\n\n // job.generation.completed | failed \u2192 write the failure cause onto the row\n // and tell the owner. A non-2xx is retried; the notification's\n // Idempotency-Key (one per run) makes a redelivery send nothing twice.\n 'notify-ready': {\n entry: './functions/notify-ready.ts',\n trigger: { kind: 'webhook', source: 'job.generation.', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n egressAllow: [],\n },\n },\n\n secrets: {\n render_url: {\n feature: 'functions',\n description: 'your render endpoint \u2014 the https URL vxil POSTs each render to (a Trigger.dev relay, or your own container endpoint)',\n },\n render_token: {\n feature: 'functions',\n description: 'a long random token your render endpoint checks on the Authorization header (Bearer \u2026)',\n },\n vxil_jobs_key: {\n feature: 'functions',\n description: 'an API key of this backend holding ONLY jobs:write \u2014 redrive-pending starts backlogged renders with it (vxil keys mint --name render-redrive --scopes jobs:write). jobs:write also lets it cancel or replay any run, enqueue jobs and manage schedules and flow rules: a server key, kept only here',\n },\n },\n});\n",
19467
- "readme": "# Render Farm \u2014 long renders on a runtime you rent, with vxil holding the credits, the deadline and the callback\n\n```bash\nmkdir my-renders && cd my-renders\nvxil init --template render-farm # init scaffolds into the CURRENT directory\nprintf '%s' \"$RENDER_URL\" | vxil secrets set functions/render_url # your render endpoint (https)\nprintf '%s' \"$RENDER_TOKEN\" | vxil secrets set functions/render_token # a long random token it checks\n# the backlog re-driver's key: an API key of this backend holding ONLY jobs:write\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name render-redrive --scopes jobs:write --json | jq -r .api_key | vxil secrets set functions/vxil_jobs_key\nvxil push\n```\n\n> **Plan note.** The functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n> On the Free plan a function cron may also fire at most every 15 minutes, so change `redrive-pending`'s\n> schedule to `'*/15 * * * *'` there. The blueprint is written for Developer and up, where it runs every\n> minute. Everything else works the same; a backlog just drains more slowly.\n\nA video render, a transcode, a headless-browser capture: minutes of CPU, ffmpeg or Chromium. That does\nnot fit in a vxil function (a delivered trigger gets about a minute), and vxil will not grow a container\ntier or a workflow engine to run it. So the work runs on **a runtime you rent** \u2014 Trigger.dev, Modal,\na container on your own cloud account \u2014 and vxil keeps the four things that must survive while it\nruns:\n\n| vxil holds | so that |\n|---|---|\n| **the credits** reserved for the render | a failed, abandoned or cancelled render gives them back, and a user can never start more than they can pay for |\n| **the deadline** | a render your runtime never reports on fails and refunds after 30 minutes (at most one hour) |\n| **the signed completion callback** | your runtime needs no vxil key: the URL it is handed is the credential for that one render |\n| **the status row** | the app reads (or subscribes to) one `renders` row: `pending \u2192 processing \u2192 completed | failed`, plus everything your runtime sent back |\n\n**What this blueprint teaches that the others do not:** the hand-off to **your own** long-running\nruntime through a **webhook-mode generation run** \u2014 the contract your endpoint and your worker must\nkeep, and two complete runtime options below. (`fal-media` shows the same lane against a vendor queue\nAPI; `job-runner` shows a provider call vxil polls.)\n\n## What you get\n\n- **`renders`** \u2014 one row per render, owned by the user who asked for it (`strictEndUserScope`: a\n signed-in user reads only their own). `render_key` is the owner + `:` + the client's `request_key`\n (the composition is enforced by a `beforeWrite` hook) and is **unique**, so a double tap or a retried\n request finds the first row instead of starting a second render. Your runtime's answer lands on the\n row: `output_url`, `duration_s`, `progress`, `stage`, `message`.\n- **`request-render`** (http function, end-user mode) \u2014 creates-or-finds the row, then starts ONE\n generation run: your endpoint (`render_url`), your token on its `Authorization` header, a status\n mirror onto the row, `reserve_credits` for the render (5 `render_credits`), a 30-minute deadline, and\n the `render_key` as the run's `idempotency_key` \u2014 so a re-driven start gets the same run back. That\n works only because every start of a row sends the **same body**. Jobs compares a fingerprint of\n the request \u2014 canonical JSON, with defaults applied and keys sorted, and the *values* of\n `provider.headers` / `completion.poll.headers` left out (so a rotated token is not a different\n request) \u2014 not its raw bytes. A different body under a used key answers `422\n idempotency_key_reused` (enforced on every project since 2026-10-05). So nothing in the\n descriptor is rebuilt per send \u2014 the run's `deadline_at`\n is computed **once**, stored on the row (`deadline_at`) when the row is created, and re-sent as\n stored by every start (cleared only when jobs says no run holds the key \u2014 a `429` or `503\n payments_unavailable` \u2014 so the next start stores a fresh one). Never put `Date.now()`, a random id or\n any other per-send value into a generation body you may re-send. The credits it holds are the\n function's fixed price, never a value read from the row.\n- **`redrive-pending`** (cron function, every minute, `overlap: 'skip'`) \u2014 drains the backlog. When\n the project already has `generation.maxConcurrent` renders in flight, `request-render` answers `429`\n (with `queued: true`) and the row waits `pending` with no run. This function starts those rows,\n oldest first, with the **same** idempotency key, and tells the owner when one never starts\n ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **`notify-ready`** (webhook function on `job.generation.`) \u2014 writes the failure cause onto the row (a\n platform class such as `GenerationExpired` gets a short human hint after it) and sends the owner one\n message per run (`Idempotency-Key: render-ready:<run_id>`). A render refused for too few credits\n keeps `insufficient_credits`; when `request-render` refused it, the caller already got the `402`\n and nothing is sent, and when the re-driver started it (the user last heard \"queued\"), the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`, the re-driver's own key). It\n **branches on `error_class`**, because not every `job.generation.failed` is a render that failed:\n `PaymentsUnavailable` (the credits could not be held at the start \u2014 the row is still queued and a\n new run follows) is skipped (and when a re-sent `request-render` raced that start and linked its\n run, the row is unlinked \u2014 `run_id: null`, only while it is still `pending` on that run \u2014 so\n `redrive-pending` starts it), and `Cancelled` (your own cancel) writes `error: cancelled` and sends\n nothing. Both are also lower-level events (`warn` / `info`), so they stay out of an immediate\n failure digest.\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed to try it).\n \"Credits\" are usage units you meter, not money.\n\nThe row is a **view** for the app; the run and the ledger are the truth. If your app's client key\ncarries `cms:write`, a signed-in user can edit their own `renders` row (say, set `status` to\n`completed`) \u2014 that changes nothing they are charged or given. Anything that grants something on\ncompletion should read the run (`GET /v1/jobs/runs/{run_id}`) or react to `job.generation.completed`,\nas `notify-ready` does \u2014 or give the client key only `cms:read`.\n\n### Upgrading a render-farm project scaffolded before 2026-10-04\n\nProjects created from an earlier copy of this blueprint compute `deadline_at` on every send. To\nkeep every start of a row the same request:\n\n1. add the `deadline_at: { type: 'datetime' }` field to `renders` in your `vxil.config.ts`;\n2. take the current `functions/request-render.ts` and `functions/redrive-pending.ts` (they store\n the deadline on the row and re-send the stored value), then `vxil push`.\n\nRows created before the upgrade have no stored deadline: their next start (a re-drive) stores a\nfresh one, so that one send differs from the run's first body. Such a send answers `422\nidempotency_key_reused` naming the run, which both functions handle by linking that run to the row.\n\n## The contract your runtime keeps\n\nWhatever runs the render, these are the only things it has to do:\n\n| Step | What arrives / what to send |\n|---|---|\n| **Start** | vxil `POST`s your `render_url` with `Authorization: Bearer <render_token>`, the header `x-vxil-run-id` and JSON `{ render_id, user_id, composition, props, payload: { generation_id, correlation_id, deadline_at }, callback_url }` (`user_id` is the render's owner; `deadline_at` is the row's stored deadline, the same on every start of that row). Check the token, **store or queue the work and answer `2xx` within 20 seconds** \u2014 never render inline, and never wait on a container's cold start. When you cannot take the work right now, say so at once with a `503` instead of holding the request open; the start is then sent again later, as after a timeout. `408` / `429` / `5xx` / a timeout is retried with backoff (30 s, then 1, 2 and 4 minutes, doubling up to 10 minutes; a `Retry-After` on your answer sets the wait, 1 s to 10 min, never past the deadline; `max_attempts` counts start calls) until the run's attempts are used \u2014 with the default five, the last try comes 7 to 8 minutes after the first, so leave room for that in the deadline. Any other `4xx` ends the run and refunds the credits. A start can arrive more than once (a lost answer is retried), so **dedupe by the run id** (`x-vxil-run-id`; in this blueprint `render_id` = `payload.generation_id` names the same single run), never by a hash of the content ([why](#one-render-one-run-dedupe-a-doubled-start)). |\n| **Progress** (optional, best-effort) | `POST callback_url` with `{\"status\": \"processing\", \"progress\": 40, \"stage\": \"encoding\"}`. A processing ping moves the row to `processing` and writes its `progress` (0\u2013100) / `stage` (\u2264 64 chars) / `message` (\u2264 200 chars) onto the row \u2014 `request-render` asks for that with `status_mirror.progress_fields` \u2014 so the app shows live progress by watching the row. Other keys on a ping are not written to the row (the run keeps the latest report, `GET /v1/jobs/runs/{run_id}` \u2192 `progress`). **At most one ping every 5 seconds**, sending the latest state; a ping that fails or answers `429` is simply dropped ([callback limits](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)). |\n| **Done** (must arrive) | `POST callback_url` with `{\"status\": \"completed\", \"output_url\": \"https://\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` \u2014 or, when the output is uploaded into this project's files, `{\"status\": \"completed\", \"output_file\": \"obj_\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` ([below](#keys-stay-out-of-the-container-the-recommended-shape)). At most 256 KiB, and **every key a declared field of `renders`** (add a field before you send a new key; [test it](#a-contract-test-for-your-runtime)). The credits are committed and every key is written onto the row. **Retry this post on `429`, `5xx` and network errors, honouring `Retry-After`, until `deadline_at`** \u2014 never drop it (`postCallback` below). |\n| **Failed** (must arrive) | `POST callback_url` with `{\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"ffmpeg exited 1: \u2026\"}`. The credits are refunded; `error` (a short code) and `hint` reach `notify-ready` as `error_class` / `error_hint` and land on the row's `error`. Retried exactly like `completed`. |\n| **Never answers** | the run fails at the deadline (`GenerationExpired`), the credits are refunded, and a callback after that changes nothing. |\n| **The deadline** | `payload.deadline_at` (an ISO time) is the row's stored deadline: treat it as the latest moment to finish. vxil's own deadline is the run's `timeout` (30 minutes) counted from when **that run** was queued; any callback that arrives at or after vxil's deadline ends the run as `GenerationExpired` (credits refunded), and a `completed` posted then is answered `{ generation_status: \"failed\", expired: true }` \u2014 the output exists, but the user was refunded and the row says failed. The stored value is computed when the row is created and re-sent unchanged, so on a row whose run started later (a re-drive after a lost answer or a `409`) `deadline_at` can be **earlier** than vxil's real stop time \u2014 even already past when the start arrives (a row may wait up to an hour for a slot). It is never later, so honouring it is always safe. Check it before each attempt starts: a render that cannot finish by then should post `failed` and stop. |\n\n`callback_url` needs no other credential \u2014 and nothing else should see it. A repeated `completed` or\n`failed` post is answered with the settled state and changes nothing, so your worker can safely retry\nits own callback on a network error. The output bytes stay where your runtime wrote them (your bucket,\nyour CDN) and vxil stores the keys, not the file, unless a coordinator uploads the output into this\nproject's files ([next section](#keys-stay-out-of-the-container-the-recommended-shape)).\n\nEach start request also carries an `X-Vxil-Jobs-Signature` header (verifiable with your project's\njobs signing secret, `GET /v1/jobs/signing-secret`) and `x-vxil-run-id`. This blueprint uses the\nbearer token because it is one string comparison in any language.\n\n### Callback limits: progress is best-effort, the final post must arrive\n\nEach render's `callback_url` has its **own** budget at vxil's edge: about 120 posts a minute for that\none run. Every post over it, pings included, is counted against a small separate allowance (about 20\na minute), and on that allowance only a `completed` or `failed` post is accepted. A runtime that pings\nevery 5 seconds never gets near either number. One that sends more than about 140 posts in a minute\nuses the allowance up as well, and its final post waits for the next minute. All of a project's\ncallbacks together also share a ceiling of about 3,000 a minute, plus about 300 a minute for final posts.\nEvery number here is best-effort (counted per serving machine). The signature in the URL identifies\nthe run, so fifty renders posting from one shared egress IP do not share a budget. Over a budget the\nanswer is `429 rate_limited` with `Retry-After: 30`.\n\nTwo rules follow, and the helpers below keep both:\n\n- **Progress is best-effort.** Post at most one `processing` ping every 5 seconds, carrying the latest\n state; a ping that fails or answers `429` is dropped, and the next one carries the newer state.\n- **The final post must arrive.** A `completed` or `failed` post that answers `429`, `5xx` or never\n gets an answer is sent again, after `Retry-After` when there is one, until `deadline_at`. A repeat\n of a final post is harmless: it is answered with the settled state and changes nothing. Any other\n `4xx` means the URL or the body is wrong, so retrying will not help.\n\n```ts\n// callbacks.ts \u2014 for the coordinator, a task, or any worker that posts to callback_url\n/** completed / failed: MUST arrive. Retries 429, 5xx and network errors (honouring Retry-After)\n * until `untilMs`; throws only when it cannot deliver in time, or on another 4xx. */\nexport async function postCallback(callbackUrl: string, body: Record<string, unknown>, untilMs: number): Promise<void> {\n for (let attempt = 0; ; attempt++) {\n let res: Response | undefined;\n try {\n res = await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n });\n } catch { /* a network error: send it again */ }\n if (res && res.ok) return;\n if (res && res.status !== 429 && res.status < 500) throw new Error(`callback refused: ${res.status}`);\n const retryAfterS = Number(res?.headers.get('retry-after'));\n const waitMs = retryAfterS > 0 ? retryAfterS * 1000 : Math.min(1000 * 2 ** attempt, 30_000);\n if (Date.now() + waitMs > untilMs) throw new Error(`callback not delivered in time (last answer: ${res?.status ?? 'none'})`);\n await new Promise((r) => setTimeout(r, waitMs));\n }\n}\n\n/** processing: best-effort, at most one post every `gapMs`; a refused or failed ping is dropped. */\nexport function progressReporter(callbackUrl: string, gapMs = 5_000) {\n let last = 0;\n return async (p: { progress?: number; stage?: string; message?: string }): Promise<void> => {\n if (Date.now() - last < gapMs) return; // coalesced: the next ping carries newer state\n last = Date.now();\n await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ status: 'processing', ...p }),\n }).catch(() => undefined);\n };\n}\n```\n\n### One render, one run: dedupe a doubled start\n\nvxil sends the start again when it did not get your answer: a timeout, a dropped connection, a `5xx`.\nSo the same start can reach you twice, even while the first copy is still being handled. Claim the\nwork under the **run id** (`x-vxil-run-id`) before you launch anything. Answer `202` to a repeat\nonly once the work is launched. While the first copy is still launching, answer `503`, so the start\nstays open and vxil sends it again; a `202` would end vxil's retries even if that first launch then failed. In this blueprint `render_id` (= `payload.generation_id`, the row's id)\nnames the same single run, because `request-render` starts one run per row.\n\nNever key the claim by a hash of the content (the composition and props). Two users who ask for the\nsame render produce two runs with one hash: the second start would find the first's claim and be\nrefused, or overwrite the first's `callback_url`, and that run would wait out its deadline and refund.\nIf you want identical renders to share their output, claim by run id and look the output up by hash\nas a separate step.\n\n## Keys stay out of the container (the recommended shape)\n\nThe render container is the part of your system that runs the most third-party code (ffmpeg,\nChromium, fonts and media from the user's props), so give it **no vxil key at all**. This is the\nshape the blueprint recommends, whatever runs the container:\n\n```\nvxil \u2500\u2500start\u2500\u2500\u25B6 coordinator \u2500\u2500launch\u2500\u2500\u25B6 container\n (render_url) \u2502 progress / failed \u2500\u2500\u2500\u2500\u2500\u2500\u25B6 callback_url (keyless)\n \u25B2 output bytes \u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u2514\u2500 mints the upload URL with ITS files:write key, PUTs the bytes,\n completes the object, POSTs \"completed\" + output_file \u2500\u2500\u25B6 callback_url\n```\n\n- **The files feature is on.** The blueprint's config enables it (`files: { enabled: true }`) and\n declares `output_file` as a `file` field; without it every upload-url call is refused and the\n render waits out its deadline. Its per-object ceiling, `maxObjectBytes`, is 100 MB by default,\n enough for the buffered coordinator below; raise it for long or high-bitrate renders.\n- **The coordinator** is your `render_url`: a small endpoint in your own account \u2014 an edge worker\n with a per-render lock, or a route on any thin server. It is the only piece that holds a vxil key,\n and that key holds only **`files:write`** (`vxil keys mint --name render-uploads --scopes files:write`).\n- **The container** gets the job, the keyless `callback_url` (for `processing` pings and for\n `failed`), an `output_url` on the coordinator and an **output ticket** for it: a token signed for\n that one render and useless after its deadline, sent on the `Authorization` header (never in the\n URL, where access logs would keep it).\n- **The upload happens once the size is known.** The container POSTs the finished file to its\n ticket. The coordinator reads it, mints the files upload URL for exactly that size\n (the quota pre-check uses it), PUTs the bytes, completes the object, and only then posts\n `completed` with the object id as `output_file`. A crash anywhere before that leaves the render\n open, and it refunds at the deadline like any other.\n\n```ts\n// coordinator.ts \u2014 your render_url. A standard fetch handler (an edge worker, or a Node 18+ adapter).\n// Env: RENDER_TOKEN (= the vxil secret render_token), TICKET_SECRET (a long random string),\n// VXIL_FILES_KEY (an API key holding ONLY files:write), VXIL_BASE (https://api.vxil.com).\nimport { postCallback } from './callbacks';\n\ntype Start = {\n render_id: string; user_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; deadline_at: string }; callback_url: string;\n};\ntype Ticket = { run_id: string; render_id: string; user_id: string; callback_url: string; exp: number };\ntype Env = { RENDER_TOKEN: string; TICKET_SECRET: string; VXIL_FILES_KEY: string; VXIL_BASE: string };\n\nconst enc = new TextEncoder();\nconst b64u = (b: ArrayBuffer | Uint8Array) =>\n btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\nconst unb64u = (s: string) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));\nconst hmacKey = (secret: string) =>\n crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);\nasync function sealTicket(env: Env, t: Ticket): Promise<string> {\n const body = b64u(enc.encode(JSON.stringify(t)));\n return `${body}.${b64u(await crypto.subtle.sign('HMAC', await hmacKey(env.TICKET_SECRET), enc.encode(body)))}`;\n}\nasync function openTicket(env: Env, raw: string): Promise<Ticket | null> {\n const [body, sig] = raw.split('.');\n if (!body || !sig) return null;\n try {\n // crypto.subtle.verify compares in constant time (a `!==` on the signature would not)\n if (!(await crypto.subtle.verify('HMAC', await hmacKey(env.TICKET_SECRET), unb64u(sig), enc.encode(body)))) return null;\n const t = JSON.parse(new TextDecoder().decode(unb64u(body))) as Ticket;\n return t.exp > Date.now() ? t : null;\n } catch {\n return null; // not base64url / not JSON\n }\n}\n/** The output's file extension, from the Content-Type the container sends. */\nconst EXT: Record<string, string> = {\n 'video/mp4': 'mp4', 'video/webm': 'webm', 'image/gif': 'gif', 'image/png': 'png', 'image/jpeg': 'jpg', 'application/pdf': 'pdf',\n};\n/** Sent with a 503: when to send the request again. */\nconst AGAIN_IN_30S = { 'retry-after': '30' };\nconst vxil = (env: Env, path: string, body?: unknown) => fetch(`${env.VXIL_BASE}${path}`, {\n method: 'POST',\n headers: { authorization: `Bearer ${env.VXIL_FILES_KEY}`, 'content-type': 'application/json' },\n ...(body ? { body: JSON.stringify(body) } : {}),\n});\n\nexport default {\n async fetch(req: Request, env: Env): Promise<Response> {\n const url = new URL(req.url);\n\n // 1. the start: check the token, launch the container, answer inside 20 s\n if (req.method === 'POST' && url.pathname === '/start') {\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) return new Response('unauthorized', { status: 401 });\n const s = (await req.json()) as Start;\n // a run started before request-render sent user_id (an older copy of this\n // blueprint): a server-mode upload must name its user, so refuse the start \u2014\n // a 4xx ends that run and refunds it, instead of a 422 at upload time\n if (!s.user_id) return new Response('start body has no user_id: redeploy request-render', { status: 400 });\n // one run, one render: claim the work under the RUN id before launching anything. The claim\n // says `launching` (and expires after a minute) until the launch succeeds, then `launched`.\n // A start vxil re-sends (a lost answer) is acknowledged with 202 only once the work is\n // `launched`; while another copy is still launching it gets 503, so vxil keeps retrying\n // instead of treating a launch that may yet fail as accepted.\n // Never claim by a content hash: two users' identical renders are two runs.\n const runId = req.headers.get('x-vxil-run-id') ?? s.payload.generation_id;\n const claimKey = `start:${runId}`;\n if (!(await store.claim(claimKey, 'launching', 60_000))) {\n if ((await store.get(claimKey)) === 'launched') return Response.json({ accepted: true, duplicate: true }, { status: 202 });\n return new Response('this render is still being launched', { status: 503, headers: AGAIN_IN_30S });\n }\n const ticket = await sealTicket(env, {\n run_id: runId, render_id: s.render_id, user_id: s.user_id, callback_url: s.callback_url,\n exp: Date.parse(s.payload.deadline_at), // useless once vxil stops waiting\n });\n try {\n await launchContainer({ // YOUR container platform's API: it must QUEUE\n run_id: runId, render_id: s.render_id, // the job and return at once \u2014 never wait.\n // Pass run_id as its idempotency key if it has one\n composition: s.composition, props: s.props ?? {},// here for a cold start\n deadline_at: s.payload.deadline_at,\n callback_url: s.callback_url, // for processing pings and `failed`\n output_url: `${url.origin}/output`, // POST the file here, with\n output_ticket: ticket, // Authorization: Bearer <output_ticket>\n });\n } catch {\n await store.release(claimKey); // not launched: let vxil's retry try again\n return new Response('cannot take the render right now', { status: 503, headers: AGAIN_IN_30S });\n }\n await store.put(claimKey, 'launched'); // no expiry: every later copy is a duplicate\n return Response.json({ accepted: true }, { status: 202 });\n }\n\n // 2. the output: the container POSTs the finished file here (again, on any non-2xx answer)\n if (req.method === 'POST' && url.pathname === '/output') {\n const t = await openTicket(env, (req.headers.get('authorization') ?? '').replace(/^Bearer /, ''));\n if (!t) return new Response('bad or expired ticket', { status: 403 });\n const contentType = (req.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();\n const ext = EXT[contentType];\n if (!ext) return new Response(`send the output's Content-Type (one of: ${Object.keys(EXT).join(', ')})`, { status: 415 });\n // a re-sent output after the upload already happened: skip straight to the settle\n let object_id = await store.get(`output:${t.run_id}`);\n if (!object_id) {\n // buffered: fine for outputs of tens of MB \u2014 see \"Very large outputs\" below\n const bytes = await req.arrayBuffer();\n const size = bytes.byteLength;\n if (size === 0) return new Response('empty output', { status: 400 });\n\n const minted = await vxil(env, '/v1/files/upload-url', {\n user_id: t.user_id, filename: `${t.render_id}.${ext}`, content_type: contentType, size_bytes: size,\n });\n if (!minted.ok) return new Response(`upload-url ${minted.status}`, { status: 502 }); // the container sends the file again\n const m = ((await minted.json()) as { data: { object_id: string; upload_url: string } }).data;\n const put = await fetch(m.upload_url, { method: 'PUT', headers: { 'content-type': contentType }, body: bytes });\n if (!put.ok) return new Response(`upload ${put.status}`, { status: 502 });\n const done = await vxil(env, `/v1/files/${encodeURIComponent(m.object_id)}/complete`);\n if (!done.ok) return new Response(`complete ${done.status}`, { status: 502 });\n object_id = m.object_id;\n await store.put(`output:${t.run_id}`, object_id);\n }\n\n // 3. settle the render on the keyless callback: a MUST-ARRIVE post. Retry here for up to a\n // minute (never past the deadline); if it still did not land, answer 503 and the container\n // sends the output again \u2014 the note above turns that into one more settle attempt.\n try {\n await postCallback(t.callback_url,\n { status: 'completed', output_file: object_id, progress: 100, stage: 'done' },\n Math.min(t.exp, Date.now() + 60_000));\n } catch (e) {\n return new Response(`callback: ${String(e)}`, { status: 503, headers: AGAIN_IN_30S });\n }\n return Response.json({ object_id });\n }\n return new Response('not found', { status: 404 });\n },\n};\n\ndeclare function launchContainer(job: Record<string, unknown>): Promise<void>; // your container platform's API\n/** Your coordinator's own small store: a key-value namespace, a table, a per-key lock. `claim` is\n * an insert-if-absent (of `value`, expiring after `ttlMs`) that answers true for the FIRST caller\n * only; `put` writes without an expiry. */\ndeclare const store: {\n claim(key: string, value: string, ttlMs: number): Promise<boolean>; release(key: string): Promise<void>;\n get(key: string): Promise<string | null>; put(key: string, value: string): Promise<void>;\n};\n```\n\nThe container's side is three kinds of HTTP call and no vxil key: `processing` pings to\n`callback_url`, the file to `output_url` (with `Authorization: Bearer <output_ticket>` and the\nfile's `Content-Type`), and `{\"status\": \"failed\", \u2026}` to `callback_url` if it gives up. The\ncontainer sends its pings through `progressReporter` and its `failed` through `postCallback`\n([callbacks.ts](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)), and it sends\nthe output again on any non-`2xx` answer until `deadline_at`. Five notes on the shape:\n\n- **A doubled start launches one container.** The coordinator claims `start:<run id>` as\n `launching` before it launches and marks it `launched` once the launch succeeded. A start vxil\n re-sends is answered `202` and launches nothing only when the claim says `launched`; while the\n first copy is still launching, the re-sent one is answered `503`, so vxil keeps the start alive.\n (A `202` there would end vxil's retries, and if the first launch then failed nothing would run\n and the render would refund at its deadline.) When the launch fails, the coordinator releases\n the claim and answers `503` at once, and vxil sends the start again later. A coordinator that\n dies mid-launch leaves a `launching` claim that expires after a minute, so a later copy launches;\n pass the run id as your container platform's idempotency key, where it has one, so that copy\n cannot start a second container if the first launch did go through.\n- **A retried output post** (the container saw a network error after the coordinator had uploaded)\n finds `output:<run id>` in the coordinator's store, skips the upload and only sends the\n `completed` post again. A repeated `completed` is answered with the settled state and changes\n nothing, so the final post can be retried as often as it takes.\n- **Very large outputs.** The coordinator above holds the whole file in memory, and passing gigabytes\n through it costs its bandwidth too. For big files, have the container report the size first\n (`POST /output-url` with its ticket on the `Authorization` header and `{ size_bytes }`) and let the coordinator answer with the\n presigned upload URL it minted; the container PUTs straight to it, and the coordinator completes\n the object and posts `completed` when the container says it is done. The container still holds no\n key: a presigned URL is good for one object for a few minutes.\n- **Upgrading an earlier copy of this blueprint.** Renders started before `request-render` put\n `user_id` in the start body have none, and a server-mode upload must name its user. The\n coordinator refuses such a start with a `400`, which ends that run and refunds it at once;\n redeploy `request-render` (`vxil push`) before you point `render_url` at the coordinator.\n- **Serving it.** The row now holds a files object id. Read it back with a signed download URL, or\n publish it from a settle function with a server key (`vx.files.publish(object_id)`) for a stable\n public URL served from the edge cache.\n\nOptions A and B below show the same contract with the runtime posting `completed` itself (an\n`output_url` in your own bucket). Either can adopt the coordinator: point `render_url` at it, and have\nstep 1 trigger the Trigger.dev task or spawn the Modal function.\n\n## Option A \u2014 Trigger.dev (v4)\n\nTrigger.dev runs the render as a task on a machine you pick, with ffmpeg or Chromium baked into the\nimage, retries, and its own run dashboard. Two pieces: a **relay endpoint** that turns vxil's start\nrequest into a Trigger.dev trigger, and the **task**.\n\n**Why a relay, and not `render_url` pointed straight at Trigger.dev's trigger API?** vxil sends\n`callback_url` beside `payload` at the top level of the body, and Trigger.dev's trigger API passes\nonly `payload` to the task \u2014 the task would never see where to report. The relay is ~30 lines and is\nalso where your `render_token` is checked. Host it anywhere that serves https: a serverless function on\nyour web host, a small edge worker, a route in your existing API.\n\n```ts\n// relay.ts \u2014 your render_url. Standard fetch handler (edge worker / serverless function / Node 18+ adapter).\n// Env: RENDER_TOKEN (the same value as the vxil secret render_token), TRIGGER_SECRET_KEY (tr_prod_\u2026 / tr_dev_\u2026).\ntype Start = {\n render_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; correlation_id?: string; deadline_at: string }; callback_url: string;\n};\n\nexport default {\n async fetch(req: Request, env: { RENDER_TOKEN: string; TRIGGER_SECRET_KEY: string }): Promise<Response> {\n if (req.method !== 'POST') return new Response('method not allowed', { status: 405 });\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) {\n return new Response('unauthorized', { status: 401 }); // a 4xx ends the vxil run (and refunds)\n }\n const s = (await req.json()) as Start;\n const res = await fetch('https://api.trigger.dev/api/v1/tasks/render-video/trigger', {\n method: 'POST',\n headers: { authorization: `Bearer ${env.TRIGGER_SECRET_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n payload: {\n render_id: s.render_id, composition: s.composition, props: s.props ?? {},\n callback_url: s.callback_url, deadline_at: s.payload.deadline_at,\n },\n options: {\n // a start vxil re-sends (a lost answer) triggers the SAME Trigger.dev run\n idempotencyKey: `render:${s.render_id}`,\n // a render still queued after 3 minutes is dropped (it never runs, and no\n // onFailure fires \u2014 vxil refunds it at its deadline). Part of the budget below.\n ttl: '3m',\n tags: [`render_${s.render_id}`],\n },\n }),\n });\n if (res.ok) return Response.json({ accepted: true }, { status: 202 });\n // Trigger.dev busy or down: answer 503 and vxil tries the start again; anything else ends the run\n return new Response(`trigger.dev ${res.status}`, { status: res.status === 429 || res.status >= 500 ? 503 : 400 });\n },\n};\n```\n\n```ts\n// trigger.config.ts \u2014 ffmpeg and Chromium in the task image\nimport { defineConfig } from '@trigger.dev/sdk';\nimport { ffmpeg } from '@trigger.dev/build/extensions/core';\nimport { puppeteer } from '@trigger.dev/build/extensions/puppeteer';\n\nexport default defineConfig({\n project: '<your project ref>',\n dirs: ['./trigger'],\n maxDuration: 600, // CPU seconds PER ATTEMPT \u2014 the task sets its own; see the budget below\n build: { extensions: [ffmpeg(), puppeteer()] }, // puppeteer also needs PUPPETEER_EXECUTABLE_PATH set in the Trigger.dev env\n});\n```\n\n```ts\n// trigger/render-video.ts \u2014 the task: render, upload, report to vxil\nimport { task, metadata, logger } from '@trigger.dev/sdk';\nimport { postCallback, progressReporter } from '../callbacks'; // the helpers above\n\ntype Payload = {\n render_id: string; composition: string; props: Record<string, unknown>;\n callback_url: string; deadline_at: string; // when vxil stops waiting (ISO)\n};\n\n/** The longest one attempt takes, wall clock, with margin. An attempt that cannot\n * finish before deadline_at does not start: it tells vxil, so the credits come back now. */\nconst ATTEMPT_WALL_MS = 12 * 60_000;\n\n/** completed / failed: retried on 429 / 5xx / network errors (Retry-After honoured) until the\n * deadline \u2014 a repeated final post is a no-op on vxil's side, so retrying is always safe. */\nconst report = (p: Payload, body: Record<string, unknown>) =>\n postCallback(p.callback_url, body, Date.parse(p.deadline_at));\n\nexport const renderVideo = task({\n id: 'render-video',\n machine: 'large-1x', // 4 vCPU / 8 GB \u2014 size to your renders\n maxDuration: 600, // CPU seconds per attempt (10 min) \u2014 not wall time\n retry: { maxAttempts: 2, minTimeoutInMs: 5_000, maxTimeoutInMs: 30_000 },\n run: async (p: Payload) => {\n if (Date.now() + ATTEMPT_WALL_MS > Date.parse(p.deadline_at)) {\n // too late to finish inside vxil's deadline: refund now, and do not retry\n await report(p, { status: 'failed', error: 'deadline', hint: 'no time left for another attempt' });\n return { skipped: 'deadline' };\n }\n metadata.set('stage', 'rendering'); // Trigger.dev's own run view\n const progress = progressReporter(p.callback_url); // best-effort, at most one ping per 5 s\n await progress({ stage: 'rendering', progress: 0 });\n\n // \u2026your render: drive Chromium for frames, run ffmpeg \u2014 call progress({ progress, stage })\n // as often as you like (it coalesces) \u2014 and write the file\n // to YOUR bucket keyed by render_id (so a retried attempt overwrites, not duplicates)\u2026\n const outputUrl = `https://cdn.example.com/renders/${p.render_id}.mp4`;\n const durationS = 31.2;\n logger.info('rendered', { render_id: p.render_id, outputUrl });\n if (Date.now() > Date.parse(p.deadline_at)) {\n // vxil has already failed and refunded this render: the post below is answered\n // with that settled state. Your sizing is off \u2014 widen the budget below.\n logger.warn('finished after the vxil deadline', { render_id: p.render_id });\n }\n\n await report(p, {\n status: 'completed', output_url: outputUrl, duration_s: durationS, progress: 100, stage: 'done',\n });\n return { output_url: outputUrl };\n },\n // after the last attempt THROWS: tell vxil, so the credits come back now, not at the\n // deadline. Not called when an attempt exceeds maxDuration or the run expires on its\n // ttl \u2014 those refund only at vxil's deadline.\n onFailure: async ({ payload, error }) => {\n await report(payload, {\n status: 'failed', error: 'render_failed', hint: String(error instanceof Error ? error.message : error).slice(0, 200),\n });\n },\n});\n```\n\n**Budget the wall clock.** Trigger.dev's limits and vxil's deadline are separate clocks, and only\nvxil's refunds. Size them so a render always ends \u2014 `completed` or `failed` \u2014 before vxil's deadline:\n\n```\nttl + maxAttempts \xD7 (longest attempt, wall clock) + retry backoff < timeout.after_ms\n3 min + 2 \xD7 12 min + \u2264 1 min = 28 min < 30 min\n```\n\n`maxDuration` counts **CPU time per attempt**, not wall time across the run, so it does not bound\nthe sum: the `deadline_at` check at the start of each attempt does. If your renders need more, raise\n`RENDER_DEADLINE_MS` in `request-render` (up to `generation.maxTimeoutMs`, one hour) and resize the\nrest to fit.\n\n**Be honest with yourself about three things before you ship on Trigger.dev Cloud:**\n\n- **Data residency.** Trigger.dev Cloud keeps its operational and log data \u2014 including each run's\n payload \u2014 in the US (us-east-1), even when the machines run elsewhere. Your render props and the\n `callback_url` pass through it. If that rules it out, self-host Trigger.dev for your own app, or use\n option B.\n- **The callback URL is a credential for one render.** It appears in Trigger.dev's run payload and\n dashboard. It can settle only that render, and stops mattering once the render is settled.\n- **Two clocks, and `onFailure` is not a guarantee.** Trigger.dev calls `onFailure` only after the last\n attempt throws. A run that exceeds `maxDuration`, or expires on its `ttl` before it starts, ends\n without it \u2014 vxil refunds those at its deadline, not sooner. Keep the budget above, and keep the\n `deadline_at` check, so a late attempt refunds early instead of finishing after the refund.\n\n## Option B \u2014 your own container runtime (Modal, Fly, a container on your own cloud account)\n\nSame contract, no relay: the endpoint you deploy **is** `render_url`. It must answer within 20 seconds,\nso it only checks the token, hands the job to a background worker and answers `2xx`; the worker renders\nand posts back. On Modal:\n\n```python\n# render_app.py \u2014 `modal deploy render_app.py`; render_url = the endpoint's https URL\nimport http.client, json, os, time, urllib.error, urllib.request\nfrom datetime import datetime, timedelta, timezone\nimport modal\nfrom fastapi import HTTPException, Request # also `pip install fastapi` where you run `modal deploy`\n\nimage = (modal.Image.debian_slim()\n .apt_install(\"ffmpeg\", \"chromium\")\n .pip_install(\"fastapi[standard]\"))\napp = modal.App(\"render-farm\", image=image)\nsecrets = [modal.Secret.from_name(\"render-farm\")] # RENDER_TOKEN\n\ndef _post(callback_url: str, body: dict) -> None:\n req = urllib.request.Request(callback_url, data=json.dumps(body).encode(),\n headers={\"content-type\": \"application/json\"}, method=\"POST\")\n with urllib.request.urlopen(req, timeout=30) as res:\n res.read()\n\ndef report(callback_url: str, body: dict, deadline: datetime) -> None:\n \"\"\"completed / failed: MUST arrive. Retries 429, 5xx and network errors (honouring\n Retry-After) until the deadline; a repeated final post changes nothing on vxil's side.\"\"\"\n attempt = 0\n while True:\n wait = min(2 ** attempt, 30)\n attempt += 1\n try:\n _post(callback_url, body)\n return\n except urllib.error.HTTPError as e:\n if e.code != 429 and e.code < 500:\n raise # another 4xx: the URL or the body is wrong\n ra = e.headers.get(\"retry-after\")\n wait = int(ra) if ra and ra.isdigit() else wait\n except (OSError, http.client.HTTPException):\n pass # no answer, or the connection dropped mid-answer\n # (URLError, a reset, RemoteDisconnected, IncompleteRead,\n # a timeout): send it again\n if datetime.now(timezone.utc) + timedelta(seconds=wait) > deadline:\n raise RuntimeError(\"callback not delivered before the deadline\")\n time.sleep(wait)\n\n_last_ping = {}\ndef ping(callback_url: str, body: dict) -> None:\n \"\"\"processing: best-effort, at most one every 5 s; a refused or failed ping is dropped.\"\"\"\n now = time.monotonic()\n if now - _last_ping.get(callback_url, 0.0) < 5:\n return\n _last_ping[callback_url] = now\n try:\n _post(callback_url, {\"status\": \"processing\", **body})\n except Exception:\n pass\n\nATTEMPT_WALL_S = 25 * 60 # = the timeout below; a call cut off there may never reach its except\n\n@app.function(cpu=4, memory=8192, timeout=ATTEMPT_WALL_S, secrets=secrets)\ndef render(job: dict) -> None:\n cb = job[\"callback_url\"]\n deadline = datetime.fromisoformat(job[\"payload\"][\"deadline_at\"].replace(\"Z\", \"+00:00\"))\n if datetime.now(timezone.utc) + timedelta(seconds=ATTEMPT_WALL_S) > deadline:\n # queued too long to finish before vxil stops waiting: refund now\n report(cb, {\"status\": \"failed\", \"error\": \"deadline\", \"hint\": \"started too late to finish\"}, deadline)\n return\n try:\n ping(cb, {\"stage\": \"rendering\", \"progress\": 0})\n # \u2026render with ffmpeg / chromium (ping(cb, {...}) as often as you like),\n # upload to YOUR bucket keyed by job[\"render_id\"]\u2026\n output_url = f\"https://cdn.example.com/renders/{job['render_id']}.mp4\"\n except Exception as e: # the RENDER failed: tell vxil now, so the credits come back before the deadline\n report(cb, {\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": str(e)[:200]}, deadline)\n raise\n # the output exists: only `completed` may follow. Outside the try on purpose, so a\n # trouble delivering it can never turn into a `failed` post that refunds a finished render.\n report(cb, {\"status\": \"completed\", \"output_url\": output_url, \"duration_s\": 31.2,\n \"progress\": 100, \"stage\": \"done\"}, deadline)\n\n@app.function(secrets=secrets)\n@modal.fastapi_endpoint(method=\"POST\")\nasync def start(request: Request):\n if request.headers.get(\"authorization\") != f\"Bearer {os.environ['RENDER_TOKEN']}\":\n raise HTTPException(status_code=401, detail=\"unauthorized\") # a 4xx ends the vxil run\n job = await request.json()\n await render.spawn.aio(job) # queued; returns at once, well inside the 20-second window\n return {\"accepted\": True}\n```\n\n`spawn` queues the call and returns immediately, so the endpoint answers in well under a second. A\nstart can arrive twice (a lost answer is retried), so dedupe on the run id\n(`request.headers[\"x-vxil-run-id\"]`; never a hash of the props): keep the run ids you have spawned in\na `modal.Dict`, or make the render overwrite the same output key.\n\nAnything else that can (1) answer an https POST in under 20 s, (2) run the work in the background and\n(3) POST JSON to a URL fits the same contract: a Fly Machine started per job, a container service with\na queue in front, your own GPU box. vxil does not care what runs the render \u2014 only that the start is\nacknowledged quickly and the callback eventually comes.\n\n## Run it\n\nGive a user some credits from your server (or sell `render_pack_100` through your payments provider):\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"render_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser):\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['request-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['request-render']({\n composition: 'promo-30s', request_key: 'promo-1', props: { headline: 'Spring sale' },\n});\n// \u2192 { item_id, run_id, credits: 5 }\n// (or { duplicate: true, request_key, item_id, run_id, status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.status \u2192 'completed', row.output_url \u2192 'https://cdn.example.com/renders/\u2026.mp4'\n}\n```\n\n**Try it before you have a runtime.** Point `render_url` at any https endpoint that answers `2xx`\n(a request-bin works) and play the runtime yourself: copy `callback_url` from the request it received,\nthen\n\n```bash\ncurl -s -X POST \"$CALLBACK_URL\" -H 'content-type: application/json' \\\n -d '{\"status\":\"completed\",\"output_url\":\"https://cdn.example.com/x.mp4\",\"duration_s\":12.5,\"progress\":100,\"stage\":\"done\"}'\n# \u2192 { \"data\": { \"run_id\": \"run_\u2026\", \"generation_status\": \"completed\" } } \u2014 and the row says so\n```\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| runtime posted `completed` | `completed` | `status: completed` + every key it sent | committed |\n| runtime posted `failed` | `failed` | `status: failed`, `error` = its code + hint (written by `notify-ready`) | refunded |\n| runtime never called back | `failed` (`GenerationExpired`) at the deadline | `failed`, `error: GenerationExpired: the render did not finish before its deadline` | refunded |\n| runtime finished after the deadline | `failed` (`GenerationExpired`) \u2014 exact to the second; the late `completed` is answered `failed` / `expired: true` | `failed` | refunded (your compute was spent \u2014 budget the clocks) |\n| the final post was answered `429` (or `5xx`, or got no answer) | still open: nothing is settled until a post lands | unchanged | still held \u2014 `postCallback` sends it again after `Retry-After`; give up only at `deadline_at`, when the run refunds anyway |\n| a progress ping was answered `429` | unchanged | the previous progress stays | unchanged \u2014 drop the ping; the next one carries the newer state |\n| your endpoint answered `5xx` / timed out | start retried with backoff; terminal after the attempts | `processing` \u2192 `failed`, `error: RetryableHttp: the render endpoint kept failing to accept the render (retries exhausted)` (or `NetworkError: \u2026` when it could not be reached) | held until then, then refunded |\n| your endpoint answered another `4xx` (a bad token) | `failed` at once | `failed` | refunded |\n| the user had too few credits (at `request-render`) | ended at once (`ReserveInsufficient`), never started | `failed`, `error: insufficient_credits`; that `request_key` is spent; the caller got the `402`, no message | nothing held |\n| too many renders in flight, payments briefly unreachable, or a jobs-side fault | not created yet (the caller gets `429` / `503` with `queued: true` and `retry_after`, or `502` with `queued: true`) | `pending`, no run \u2014 **queued**: `redrive-pending` starts it when a slot frees up (or the app calls again with the **same** `request_key`) | held when it starts |\n| the user had too few credits when the re-driver started it | ended at once (`ReserveInsufficient`) | `failed`, `error: insufficient_credits`; the owner is told once (they last heard \"queued\") | nothing held |\n| still no free slot an hour later | never created | `failed`, `error: not_started: the render waited too long for a free slot` (written by `redrive-pending`); the owner is told once | nothing held |\n| the re-driver's start was refused (`400` / `401` / `403` / `422`: a non-https or private `render_url`, a wrong or revoked `vxil_jobs_key`) | not created | still `pending` \u2014 the tick stops and reports it (`stopped: { status, code }` in the function's logs); fix the setup and the next tick carries on; the one-hour bound still applies | nothing held |\n\n## The backlog: `maxConcurrent`, `429` and the re-driver\n\n`generation.maxConcurrent` is how many generation runs this project may have **open** at once: 20 by\ndefault, settable up to 200 in the jobs config. This blueprint sets 20; raise it to what your plan\nand your runtime can carry:\n\n```ts\njobs: { enabled: true, generation: { maxConcurrent: 50 /* 1\u2013200, default 20 */ } },\n```\n\nAt the cap a new start is refused with `429` and `Retry-After: 5`. **No run is created and nothing\nis held yet.** The same holds when the credits cannot be held because payments is briefly\nunreachable: the start is refused with `503 payments_unavailable` (nothing started, nothing held;\nit names a 15-second wait), `request-render` answers `503` with `queued: true`, and the re-driver starts\nthe row on a later tick with the same key (a fresh run). Never a free render: that only happens if\nyou opt in with `reserve_credits.on_unavailable: 'proceed'`. `request-render` passes the `429` on to the app with `queued: true` (and the\n`item_id` and `request_key`), and leaves the row `pending` with no `run_id`. That render is\n**queued, not refused**: the re-driver will start it, and hold its credits then, up to an hour\nlater. So the app must treat a `429` with `queued: true` as \"queued\" \u2014 show it, watch the row \u2014 and\nmust **never retry it with a new `request_key`**: that is a second render, and both are charged. A\nretry with the **same** `request_key` is always safe. Rows like that are the backlog, and two\nthings drain it:\n\n1. **`redrive-pending`, every minute.** It reads the oldest `pending` rows with no run that are at\n least 30 s old (`{ status: 'pending', created_at: { $lt: \u2026 }, run_id: null }`, sorted by\n `created_at`, 20 per tick; `status` and `created_at` are index slots, so the read stays cheap at\n any size) and starts each one with the same descriptor and the **same idempotency key** as\n `request-render`, so a row the app is re-driving at the same moment still gets one run. It\n **stops at the first `429`** (or `503 payments_unavailable`), since the rest of the batch would\n get the same answer, and the next tick carries on. Each try is recorded on the row (`redrive_attempts`, `redriven_at`). It\n sends the row's **stored** `deadline_at` (a row with none gets one stored first, only while none\n is), so its body is the same request as the first start's and jobs hands that run back. A `409\n generation_in_progress` (another start of the row is still placing its credit hold) is recorded\n and the tick goes on; a `422 idempotency_key_reused` that names a run (a run of this key exists\n under another body) links that run to the row. A `402`\n fails the row with `insufficient_credits`. Any other `400`, `401`, `403` or `422` also **stops** the tick\n and fails nothing: every re-driven row sends the same descriptor, so a refusal means the setup is\n wrong (a non-https or private `render_url`, a revoked key), not the row; the tick reports it as\n `stopped: { status, code }`. The only thing that fails a waiting row is age: a row still waiting\n after **one hour** is failed with `not_started: the render waited too long for a free slot`.\n Nothing was ever held, so nothing is refunded. In both the `402` and the one-hour case the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`), because the last thing they heard\n was \"queued\". (The function's scopes include `notifications:send` for that.)\n2. **The app**, calling `request-render` again with the same `request_key`, if it wants the render\n started sooner than the next tick.\n\n`overlap: 'skip'` keeps a slow tick from being doubled by the next one. On the Free plan, run it\nevery 15 minutes (see the plan note at the top).\n\n**Why it has its own key.** A credit hold placed by a function must name the signed-in user the\nfunction acts for, and a cron tick has none. So `redrive-pending` makes the start with\n`vxil_jobs_key`, an API key holding only `jobs:write`, the way your trusted server would. That is\nmore than \"hold credits and start runs\": `jobs:write` also lets the key cancel or replay any run of\nthis project, enqueue any job, and create or change schedules and flow rules, and it can hold\ncredits against any of your users and start runs against any public https endpoint. Treat it as a\nserver key: keep it only in this function's secrets, never in an app, and rotate it like any\nserver key. The re-driver never reads the\nprice from the row (a signed-in user can edit their own row): the hold is the function's fixed\n`RENDER_CREDITS`, and a row whose `render_key` is not `owner:request_key` is failed, not started.\n(A hand-written row like that which also breaks the `render_key_shape` hook cannot be written at\nall, so the tick counts it as `unwritable` and it keeps one of the 20 slots: fix or delete it by\nhand.)\n\n## A contract test for your runtime\n\nEvery key your runtime posts in the `completed` body is written onto the row, so every key must be a\ndeclared field of `renders`. Keep that true in your own CI with a test beside your runtime's code. It\nfails the day someone adds a key to the completion body and forgets the field:\n\n```ts\n// render-contract.test.ts \u2014 vitest, in YOUR repo (the one holding vxil.config.ts)\nimport { describe, expect, it } from 'vitest';\nimport config from './vxil.config';\n// the bodies your runtime (or coordinator) really posts: import the builders from that code,\n// so the test follows it instead of a hand-copied list\nimport { completedBody, progressBody } from './runtime/callback-bodies';\n\ndescribe('render callbacks', () => {\n const declared = new Set(Object.keys(config.cms!.collections!.renders!.fields!));\n\n it('every key of the completion body is a declared renders field', () => {\n const body = completedBody({ objectId: 'obj_test', durationS: 1 });\n expect(Object.keys(body).filter((k) => !declared.has(k))).toEqual([]);\n });\n\n it('a progress ping carries only the keys the mirror keeps', () => {\n const ping = progressBody({ progress: 40, stage: 'encoding' });\n expect(Object.keys(ping).filter((k) => !['status', 'progress', 'stage', 'message'].includes(k))).toEqual([]);\n });\n});\n```\n\nThe platform also has a fallback for the day that test is missing. When the row refuses a completion\nmirror because of an undeclared key, vxil writes the status alone, so the row still says\n`completed`, and the run reports what it dropped (`GET /v1/jobs/runs/{run_id}` \u2192 `mirror_error`,\nwith `fields_dropped`). The app no longer hangs on `processing`, but the dropped keys are not on the\nrow. The test is what keeps them there.\n\n## The bounds to design against\n\n- **Deadline**: the run's timeout is clamped to `generation.maxTimeoutMs` \u2014 one hour at most. A render\n that can take longer should be split (the next step starts from `notify-ready`), or tracked on your own\n row without a platform-held reserve.\n- **In flight**: `generation.maxConcurrent` renders at once (20 here and by default; up to 200). Over\n it, `request-render` answers `429` and the row waits for `redrive-pending`, or for a retry with the\n same `request_key` ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **Holds**: one render's reserve is clamped to `generation.maxReserveCredits` (50 here), and the sum of\n all open holds is capped by `generation.maxOutstandingReserveCredits` (5,000 here).\n- **Callback**: at most 256 KiB per post, JSON, every key a declared field of `renders`. About 120\n posts a minute per render plus a small allowance kept for the final post (both best-effort, per\n serving machine); progress at most every 5 s, and the final post retried until it lands\n ([callback limits](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)).\n\n## Your token, and where it lives\n\n`render_url` and `render_token` are function secrets; `request-render` reads them at invoke time and\nputs them on the generation run, which vxil stores with the run (your project only) for as long as the\njobs retention keeps it. The token is **never returned by a read**: `GET /v1/jobs/runs/{run_id}` (and\nthe dashboard and MCP reads built on it) shows the provider header names with every value as\n`[redacted]`. Rotate it in both places (`vxil secrets set functions/render_token` and your endpoint's\nenv); runs already started keep the token they were started with.\n\n## Evidence\n\n- **Read in vxil's code**: the start request body (`provider.body` + `payload` + `callback_url`), the\n 20-second start bound, the retry ladder, the status mirror writing every completion key onto the row,\n the hold committed on `completed` and released on `failed` / the deadline, and `error` / `hint`\n becoming `error_class` / `error_hint` on `job.generation.failed`.\n- **Read in Trigger.dev's and Modal's documentation, not executed from vxil**: the trigger endpoint\n `POST https://api.trigger.dev/api/v1/tasks/{taskId}/trigger` with `{ payload, options }` and the\n `idempotencyKey` / `ttl` / `tags` / `machine` options; `task({ id, machine, maxDuration, retry, run,\n onFailure })` and `retry` options, `maxDuration` being CPU time per attempt with no `onFailure` when\n it is exceeded, `metadata.set`, and the `ffmpeg()` / `puppeteer()` build extensions; Trigger.dev\n Cloud's US-hosted operational data; Modal's `@modal.fastapi_endpoint` and `.spawn()`. Check each\n vendor's current docs before you ship.\n",
19562
+ "readme": "# Render Farm \u2014 long renders on a runtime you rent, with vxil holding the credits, the deadline and the callback\n\n```bash\nmkdir my-renders && cd my-renders\nvxil init --template render-farm # init scaffolds into the CURRENT directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: set redrive-pending's schedule to '*/15 * * * *' in vxil.config.ts now (plan note below)\nprintf '%s' \"$RENDER_URL\" | vxil secrets set functions/render_url # your render endpoint (https)\nprintf '%s' \"$RENDER_TOKEN\" | vxil secrets set functions/render_token # a long random token it checks\n# the backlog re-driver's key: an API key of this backend holding ONLY jobs:write\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name render-redrive --scopes jobs:write --json | jq -r .api_key | vxil secrets set functions/vxil_jobs_key\nvxil push # collections + the three functions; writes vxil.types.ts (vx.fn typing)\n```\n\n(`jq` is needed for the mint line; without it the pipe carries nothing and `secrets set` answers\n`no value on stdin`. Or run `vxil keys mint \u2026 --json`, copy `api_key`, and paste it at the hidden prompt\nof `vxil secrets set functions/vxil_jobs_key`.)\n\n> **Plan note.** The functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n> On the Free plan a function cron may also fire at most every 15 minutes, so change `redrive-pending`'s\n> schedule to `'*/15 * * * *'` there. The blueprint is written for Developer and up, where it runs every\n> minute. Everything else works the same; a backlog just drains more slowly.\n\nA video render, a transcode, a headless-browser capture: minutes of CPU, ffmpeg or Chromium. That does\nnot fit in a vxil function (a delivered trigger gets about a minute), and vxil will not grow a container\ntier or a workflow engine to run it. So the work runs on **a runtime you rent** \u2014 Trigger.dev, Modal,\na container on your own cloud account \u2014 and vxil keeps the four things that must survive while it\nruns:\n\n| vxil holds | so that |\n|---|---|\n| **the credits** reserved for the render | a failed, abandoned or cancelled render gives them back, and a user can never start more than they can pay for |\n| **the deadline** | a render your runtime never reports on fails and refunds after 30 minutes (at most one hour) |\n| **the signed completion callback** | your runtime needs no vxil key: the URL it is handed is the credential for that one render |\n| **the status row** | the app reads (or subscribes to) one `renders` row: `pending \u2192 processing \u2192 completed | failed`, plus everything your runtime sent back |\n\n**What this blueprint teaches that the others do not:** the hand-off to **your own** long-running\nruntime through a **webhook-mode generation run** \u2014 the contract your endpoint and your worker must\nkeep, and two complete runtime options below. (`fal-media` shows the same lane against a vendor queue\nAPI; `job-runner` shows a provider call vxil polls.)\n\n## What you get\n\n- **`renders`** \u2014 one row per render, owned by the user who asked for it (`strictEndUserScope`: a\n signed-in user reads only their own). `render_key` is the owner + `:` + the client's `request_key`\n (the composition is enforced by a `beforeWrite` hook) and is **unique**, so a double tap or a retried\n request finds the first row instead of starting a second render. Your runtime's answer lands on the\n row: `output_url`, `duration_s`, `progress`, `stage`, `message`.\n- **`request-render`** (http function, end-user mode) \u2014 creates-or-finds the row, then starts ONE\n generation run: your endpoint (`render_url`), your token on its `Authorization` header, a status\n mirror onto the row, `reserve_credits` for the render (5 `render_credits`), a 30-minute deadline, and\n the `render_key` as the run's `idempotency_key` \u2014 so a re-driven start gets the same run back. That\n works only because every start of a row sends the **same body**. Jobs compares a fingerprint of\n the request \u2014 canonical JSON, with defaults applied and keys sorted, and the *values* of\n `provider.headers` / `completion.poll.headers` left out (so a rotated token is not a different\n request) \u2014 not its raw bytes. A different body under a used key answers `422\n idempotency_key_reused` (enforced on every project since 2026-10-05). So nothing in the\n descriptor is rebuilt per send \u2014 the run's `deadline_at`\n is computed **once**, stored on the row (`deadline_at`) when the row is created, and re-sent as\n stored by every start (cleared only when jobs says no run holds the key \u2014 a `429` or `503\n payments_unavailable` \u2014 so the next start stores a fresh one). Never put `Date.now()`, a random id or\n any other per-send value into a generation body you may re-send. The credits it holds are the\n function's fixed price, never a value read from the row.\n- **`redrive-pending`** (cron function, every minute, `overlap: 'skip'`) \u2014 drains the backlog. When\n the project already has `generation.maxConcurrent` renders in flight, `request-render` answers `429`\n (with `queued: true`) and the row waits `pending` with no run. This function starts those rows,\n oldest first, with the **same** idempotency key, and tells the owner when one never starts\n ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **`notify-ready`** (webhook function on `job.generation.`) \u2014 writes the failure cause onto the row (a\n platform class such as `GenerationExpired` gets a short human hint after it) and sends the owner one\n message per run (`Idempotency-Key: render-ready:<run_id>`). A render refused for too few credits\n keeps `insufficient_credits`; when `request-render` refused it, the caller already got the `402`\n and nothing is sent, and when the re-driver started it (the user last heard \"queued\"), the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`, the re-driver's own key). It\n **branches on `error_class`**, because not every `job.generation.failed` is a render that failed:\n `PaymentsUnavailable` (the credits could not be held at the start \u2014 the row is still queued and a\n new run follows) is skipped (and when a re-sent `request-render` raced that start and linked its\n run, the row is unlinked \u2014 `run_id: null`, only while it is still `pending` on that run \u2014 so\n `redrive-pending` starts it), and `Cancelled` (your own cancel) writes `error: cancelled` and sends\n nothing. Both are also lower-level events (`warn` / `info`), so they stay out of an immediate\n failure digest.\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed to try it).\n \"Credits\" are usage units you meter, not money.\n\nThe row is a **view** for the app; the run and the ledger are the truth. If your app's client key\ncarries `cms:write`, a signed-in user can edit their own `renders` row (say, set `status` to\n`completed`) \u2014 that changes nothing they are charged or given. Anything that grants something on\ncompletion should read the run (`GET /v1/jobs/runs/{run_id}`) or react to `job.generation.completed`,\nas `notify-ready` does \u2014 or give the client key only `cms:read`.\n\n### Upgrading a render-farm project scaffolded before 2026-10-04\n\nProjects created from an earlier copy of this blueprint compute `deadline_at` on every send. To\nkeep every start of a row the same request:\n\n1. add the `deadline_at: { type: 'datetime' }` field to `renders` in your `vxil.config.ts`;\n2. take the current `functions/request-render.ts` and `functions/redrive-pending.ts` (they store\n the deadline on the row and re-send the stored value), then `vxil push`.\n\nRows created before the upgrade have no stored deadline: their next start (a re-drive) stores a\nfresh one, so that one send differs from the run's first body. Such a send answers `422\nidempotency_key_reused` naming the run, which both functions handle by linking that run to the row.\n\n## The contract your runtime keeps\n\nWhatever runs the render, these are the only things it has to do:\n\n| Step | What arrives / what to send |\n|---|---|\n| **Start** | vxil `POST`s your `render_url` with `Authorization: Bearer <render_token>`, the header `x-vxil-run-id` and JSON `{ render_id, user_id, composition, props, payload: { generation_id, correlation_id, deadline_at }, callback_url }` (`user_id` is the render's owner; `deadline_at` is the row's stored deadline, the same on every start of that row). Check the token, **store or queue the work and answer `2xx` within 20 seconds** \u2014 never render inline, and never wait on a container's cold start. When you cannot take the work right now, say so at once with a `503` instead of holding the request open; the start is then sent again later, as after a timeout. `408` / `429` / `5xx` / a timeout is retried with backoff (30 s, then 1, 2 and 4 minutes, doubling up to 10 minutes; a `Retry-After` on your answer sets the wait, 1 s to 10 min, never past the deadline; `max_attempts` counts start calls) until the run's attempts are used \u2014 with the default five, the last try comes 7 to 8 minutes after the first, so leave room for that in the deadline. Any other `4xx` ends the run and refunds the credits. A start can arrive more than once (a lost answer is retried), so **dedupe by the run id** (`x-vxil-run-id`; in this blueprint `render_id` = `payload.generation_id` names the same single run), never by a hash of the content ([why](#one-render-one-run-dedupe-a-doubled-start)). |\n| **Progress** (optional, best-effort) | `POST callback_url` with `{\"status\": \"processing\", \"progress\": 40, \"stage\": \"encoding\"}`. A processing ping moves the row to `processing` and writes its `progress` (0\u2013100) / `stage` (\u2264 64 chars) / `message` (\u2264 200 chars) onto the row \u2014 `request-render` asks for that with `status_mirror.progress_fields` \u2014 so the app shows live progress by watching the row. Other keys on a ping are not written to the row (the run keeps the latest report, `GET /v1/jobs/runs/{run_id}` \u2192 `progress`). **At most one ping every 5 seconds**, sending the latest state; a ping that fails or answers `429` is simply dropped ([callback limits](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)). |\n| **Done** (must arrive) | `POST callback_url` with `{\"status\": \"completed\", \"output_url\": \"https://\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` \u2014 or, when the output is uploaded into this project's files, `{\"status\": \"completed\", \"output_file\": \"obj_\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` ([below](#keys-stay-out-of-the-container-the-recommended-shape)). At most 256 KiB, and **every key a declared field of `renders`** (add a field before you send a new key; [test it](#a-contract-test-for-your-runtime)). The credits are committed and every key is written onto the row. **Retry this post on `429`, `5xx` and network errors, honouring `Retry-After`, until `deadline_at`** \u2014 never drop it (`postCallback` below). |\n| **Failed** (must arrive) | `POST callback_url` with `{\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"ffmpeg exited 1: \u2026\"}`. The credits are refunded; `error` (a short code) and `hint` reach `notify-ready` as `error_class` / `error_hint` and land on the row's `error`. Retried exactly like `completed`. |\n| **Never answers** | the run fails at the deadline (`GenerationExpired`), the credits are refunded, and a callback after that changes nothing. |\n| **The deadline** | `payload.deadline_at` (an ISO time) is the row's stored deadline: treat it as the latest moment to finish. vxil's own deadline is the run's `timeout` (30 minutes) counted from when **that run** was queued; any callback that arrives at or after vxil's deadline ends the run as `GenerationExpired` (credits refunded), and a `completed` posted then is answered `{ generation_status: \"failed\", expired: true }` \u2014 the output exists, but the user was refunded and the row says failed. The stored value is computed when the row is created and re-sent unchanged, so on a row whose run started later (a re-drive after a lost answer or a `409`) `deadline_at` can be **earlier** than vxil's real stop time \u2014 even already past when the start arrives (a row may wait up to an hour for a slot). It is never later, so honouring it is always safe. Check it before each attempt starts: a render that cannot finish by then should post `failed` and stop. |\n\n`callback_url` needs no other credential \u2014 and nothing else should see it. A repeated `completed` or\n`failed` post is answered with the settled state and changes nothing, so your worker can safely retry\nits own callback on a network error. The output bytes stay where your runtime wrote them (your bucket,\nyour CDN) and vxil stores the keys, not the file, unless a coordinator uploads the output into this\nproject's files ([next section](#keys-stay-out-of-the-container-the-recommended-shape)).\n\nEach start request also carries an `X-Vxil-Jobs-Signature` header (verifiable with your project's\njobs signing secret, `GET /v1/jobs/signing-secret`) and `x-vxil-run-id`. This blueprint uses the\nbearer token because it is one string comparison in any language.\n\n### Callback limits: progress is best-effort, the final post must arrive\n\nEach render's `callback_url` has its **own** budget at vxil's edge: about 120 posts a minute for that\none run. Every post over it, pings included, is counted against a small separate allowance (about 20\na minute), and on that allowance only a `completed` or `failed` post is accepted. A runtime that pings\nevery 5 seconds never gets near either number. One that sends more than about 140 posts in a minute\nuses the allowance up as well, and its final post waits for the next minute. All of a project's\ncallbacks together also share a ceiling of about 3,000 a minute, plus about 300 a minute for final posts.\nEvery number here is best-effort (counted per serving machine). The signature in the URL identifies\nthe run, so fifty renders posting from one shared egress IP do not share a budget. Over a budget the\nanswer is `429 rate_limited` with `Retry-After: 30`.\n\nTwo rules follow, and the helpers below keep both:\n\n- **Progress is best-effort.** Post at most one `processing` ping every 5 seconds, carrying the latest\n state; a ping that fails or answers `429` is dropped, and the next one carries the newer state.\n- **The final post must arrive.** A `completed` or `failed` post that answers `429`, `5xx` or never\n gets an answer is sent again, after `Retry-After` when there is one, until `deadline_at`. A repeat\n of a final post is harmless: it is answered with the settled state and changes nothing. Any other\n `4xx` means the URL or the body is wrong, so retrying will not help.\n\n```ts\n// callbacks.ts \u2014 for the coordinator, a task, or any worker that posts to callback_url\n/** completed / failed: MUST arrive. Retries 429, 5xx and network errors (honouring Retry-After)\n * until `untilMs`; throws only when it cannot deliver in time, or on another 4xx. */\nexport async function postCallback(callbackUrl: string, body: Record<string, unknown>, untilMs: number): Promise<void> {\n for (let attempt = 0; ; attempt++) {\n let res: Response | undefined;\n try {\n res = await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n });\n } catch { /* a network error: send it again */ }\n if (res && res.ok) return;\n if (res && res.status !== 429 && res.status < 500) throw new Error(`callback refused: ${res.status}`);\n const retryAfterS = Number(res?.headers.get('retry-after'));\n const waitMs = retryAfterS > 0 ? retryAfterS * 1000 : Math.min(1000 * 2 ** attempt, 30_000);\n if (Date.now() + waitMs > untilMs) throw new Error(`callback not delivered in time (last answer: ${res?.status ?? 'none'})`);\n await new Promise((r) => setTimeout(r, waitMs));\n }\n}\n\n/** processing: best-effort, at most one post every `gapMs`; a refused or failed ping is dropped. */\nexport function progressReporter(callbackUrl: string, gapMs = 5_000) {\n let last = 0;\n return async (p: { progress?: number; stage?: string; message?: string }): Promise<void> => {\n if (Date.now() - last < gapMs) return; // coalesced: the next ping carries newer state\n last = Date.now();\n await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ status: 'processing', ...p }),\n }).catch(() => undefined);\n };\n}\n```\n\n### One render, one run: dedupe a doubled start\n\nvxil sends the start again when it did not get your answer: a timeout, a dropped connection, a `5xx`.\nSo the same start can reach you twice, even while the first copy is still being handled. Claim the\nwork under the **run id** (`x-vxil-run-id`) before you launch anything. Answer `202` to a repeat\nonly once the work is launched. While the first copy is still launching, answer `503`, so the start\nstays open and vxil sends it again; a `202` would end vxil's retries even if that first launch then failed. In this blueprint `render_id` (= `payload.generation_id`, the row's id)\nnames the same single run, because `request-render` starts one run per row.\n\nNever key the claim by a hash of the content (the composition and props). Two users who ask for the\nsame render produce two runs with one hash: the second start would find the first's claim and be\nrefused, or overwrite the first's `callback_url`, and that run would wait out its deadline and refund.\nIf you want identical renders to share their output, claim by run id and look the output up by hash\nas a separate step.\n\n## Keys stay out of the container (the recommended shape)\n\nThe render container is the part of your system that runs the most third-party code (ffmpeg,\nChromium, fonts and media from the user's props), so give it **no vxil key at all**. This is the\nshape the blueprint recommends, whatever runs the container:\n\n```\nvxil \u2500\u2500start\u2500\u2500\u25B6 coordinator \u2500\u2500launch\u2500\u2500\u25B6 container\n (render_url) \u2502 progress / failed \u2500\u2500\u2500\u2500\u2500\u2500\u25B6 callback_url (keyless)\n \u25B2 output bytes \u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u2514\u2500 mints the upload URL with ITS files:write key, PUTs the bytes,\n completes the object, POSTs \"completed\" + output_file \u2500\u2500\u25B6 callback_url\n```\n\n- **The files feature is on.** The blueprint's config enables it (`files: { enabled: true }`) and\n declares `output_file` as a `file` field; without it every upload-url call is refused and the\n render waits out its deadline. Its per-object ceiling, `maxObjectBytes`, is 100 MB by default,\n enough for the buffered coordinator below; raise it for long or high-bitrate renders.\n- **The coordinator** is your `render_url`: a small endpoint in your own account \u2014 an edge worker\n with a per-render lock, or a route on any thin server. It is the only piece that holds a vxil key,\n and that key holds only **`files:write`** (`vxil keys mint --name render-uploads --scopes files:write`).\n- **The container** gets the job, the keyless `callback_url` (for `processing` pings and for\n `failed`), an `output_url` on the coordinator and an **output ticket** for it: a token signed for\n that one render and useless after its deadline, sent on the `Authorization` header (never in the\n URL, where access logs would keep it).\n- **The upload happens once the size is known.** The container POSTs the finished file to its\n ticket. The coordinator reads it, mints the files upload URL for exactly that size\n (the quota pre-check uses it), PUTs the bytes, completes the object, and only then posts\n `completed` with the object id as `output_file`. A crash anywhere before that leaves the render\n open, and it refunds at the deadline like any other.\n\n```ts\n// coordinator.ts \u2014 your render_url. A standard fetch handler (an edge worker, or a Node 18+ adapter).\n// Env: RENDER_TOKEN (= the vxil secret render_token), TICKET_SECRET (a long random string),\n// VXIL_FILES_KEY (an API key holding ONLY files:write), VXIL_BASE (https://api.vxil.com).\nimport { postCallback } from './callbacks';\n\ntype Start = {\n render_id: string; user_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; deadline_at: string }; callback_url: string;\n};\ntype Ticket = { run_id: string; render_id: string; user_id: string; callback_url: string; exp: number };\ntype Env = { RENDER_TOKEN: string; TICKET_SECRET: string; VXIL_FILES_KEY: string; VXIL_BASE: string };\n\nconst enc = new TextEncoder();\nconst b64u = (b: ArrayBuffer | Uint8Array) =>\n btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\nconst unb64u = (s: string) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));\nconst hmacKey = (secret: string) =>\n crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);\nasync function sealTicket(env: Env, t: Ticket): Promise<string> {\n const body = b64u(enc.encode(JSON.stringify(t)));\n return `${body}.${b64u(await crypto.subtle.sign('HMAC', await hmacKey(env.TICKET_SECRET), enc.encode(body)))}`;\n}\nasync function openTicket(env: Env, raw: string): Promise<Ticket | null> {\n const [body, sig] = raw.split('.');\n if (!body || !sig) return null;\n try {\n // crypto.subtle.verify compares in constant time (a `!==` on the signature would not)\n if (!(await crypto.subtle.verify('HMAC', await hmacKey(env.TICKET_SECRET), unb64u(sig), enc.encode(body)))) return null;\n const t = JSON.parse(new TextDecoder().decode(unb64u(body))) as Ticket;\n return t.exp > Date.now() ? t : null;\n } catch {\n return null; // not base64url / not JSON\n }\n}\n/** The output's file extension, from the Content-Type the container sends. */\nconst EXT: Record<string, string> = {\n 'video/mp4': 'mp4', 'video/webm': 'webm', 'image/gif': 'gif', 'image/png': 'png', 'image/jpeg': 'jpg', 'application/pdf': 'pdf',\n};\n/** Sent with a 503: when to send the request again. */\nconst AGAIN_IN_30S = { 'retry-after': '30' };\nconst vxil = (env: Env, path: string, body?: unknown) => fetch(`${env.VXIL_BASE}${path}`, {\n method: 'POST',\n headers: { authorization: `Bearer ${env.VXIL_FILES_KEY}`, 'content-type': 'application/json' },\n ...(body ? { body: JSON.stringify(body) } : {}),\n});\n\nexport default {\n async fetch(req: Request, env: Env): Promise<Response> {\n const url = new URL(req.url);\n\n // 1. the start: check the token, launch the container, answer inside 20 s\n if (req.method === 'POST' && url.pathname === '/start') {\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) return new Response('unauthorized', { status: 401 });\n const s = (await req.json()) as Start;\n // a run started before request-render sent user_id (an older copy of this\n // blueprint): a server-mode upload must name its user, so refuse the start \u2014\n // a 4xx ends that run and refunds it, instead of a 422 at upload time\n if (!s.user_id) return new Response('start body has no user_id: redeploy request-render', { status: 400 });\n // one run, one render: claim the work under the RUN id before launching anything. The claim\n // says `launching` (and expires after a minute) until the launch succeeds, then `launched`.\n // A start vxil re-sends (a lost answer) is acknowledged with 202 only once the work is\n // `launched`; while another copy is still launching it gets 503, so vxil keeps retrying\n // instead of treating a launch that may yet fail as accepted.\n // Never claim by a content hash: two users' identical renders are two runs.\n const runId = req.headers.get('x-vxil-run-id') ?? s.payload.generation_id;\n const claimKey = `start:${runId}`;\n if (!(await store.claim(claimKey, 'launching', 60_000))) {\n if ((await store.get(claimKey)) === 'launched') return Response.json({ accepted: true, duplicate: true }, { status: 202 });\n return new Response('this render is still being launched', { status: 503, headers: AGAIN_IN_30S });\n }\n const ticket = await sealTicket(env, {\n run_id: runId, render_id: s.render_id, user_id: s.user_id, callback_url: s.callback_url,\n exp: Date.parse(s.payload.deadline_at), // useless once vxil stops waiting\n });\n try {\n await launchContainer({ // YOUR container platform's API: it must QUEUE\n run_id: runId, render_id: s.render_id, // the job and return at once \u2014 never wait.\n // Pass run_id as its idempotency key if it has one\n composition: s.composition, props: s.props ?? {},// here for a cold start\n deadline_at: s.payload.deadline_at,\n callback_url: s.callback_url, // for processing pings and `failed`\n output_url: `${url.origin}/output`, // POST the file here, with\n output_ticket: ticket, // Authorization: Bearer <output_ticket>\n });\n } catch {\n await store.release(claimKey); // not launched: let vxil's retry try again\n return new Response('cannot take the render right now', { status: 503, headers: AGAIN_IN_30S });\n }\n await store.put(claimKey, 'launched'); // no expiry: every later copy is a duplicate\n return Response.json({ accepted: true }, { status: 202 });\n }\n\n // 2. the output: the container POSTs the finished file here (again, on any non-2xx answer)\n if (req.method === 'POST' && url.pathname === '/output') {\n const t = await openTicket(env, (req.headers.get('authorization') ?? '').replace(/^Bearer /, ''));\n if (!t) return new Response('bad or expired ticket', { status: 403 });\n const contentType = (req.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();\n const ext = EXT[contentType];\n if (!ext) return new Response(`send the output's Content-Type (one of: ${Object.keys(EXT).join(', ')})`, { status: 415 });\n // a re-sent output after the upload already happened: skip straight to the settle\n let object_id = await store.get(`output:${t.run_id}`);\n if (!object_id) {\n // buffered: fine for outputs of tens of MB \u2014 see \"Very large outputs\" below\n const bytes = await req.arrayBuffer();\n const size = bytes.byteLength;\n if (size === 0) return new Response('empty output', { status: 400 });\n\n const minted = await vxil(env, '/v1/files/upload-url', {\n user_id: t.user_id, filename: `${t.render_id}.${ext}`, content_type: contentType, size_bytes: size,\n });\n if (!minted.ok) return new Response(`upload-url ${minted.status}`, { status: 502 }); // the container sends the file again\n const m = ((await minted.json()) as { data: { object_id: string; upload_url: string } }).data;\n const put = await fetch(m.upload_url, { method: 'PUT', headers: { 'content-type': contentType }, body: bytes });\n if (!put.ok) return new Response(`upload ${put.status}`, { status: 502 });\n const done = await vxil(env, `/v1/files/${encodeURIComponent(m.object_id)}/complete`);\n if (!done.ok) return new Response(`complete ${done.status}`, { status: 502 });\n object_id = m.object_id;\n await store.put(`output:${t.run_id}`, object_id);\n }\n\n // 3. settle the render on the keyless callback: a MUST-ARRIVE post. Retry here for up to a\n // minute (never past the deadline); if it still did not land, answer 503 and the container\n // sends the output again \u2014 the note above turns that into one more settle attempt.\n try {\n await postCallback(t.callback_url,\n { status: 'completed', output_file: object_id, progress: 100, stage: 'done' },\n Math.min(t.exp, Date.now() + 60_000));\n } catch (e) {\n return new Response(`callback: ${String(e)}`, { status: 503, headers: AGAIN_IN_30S });\n }\n return Response.json({ object_id });\n }\n return new Response('not found', { status: 404 });\n },\n};\n\ndeclare function launchContainer(job: Record<string, unknown>): Promise<void>; // your container platform's API\n/** Your coordinator's own small store: a key-value namespace, a table, a per-key lock. `claim` is\n * an insert-if-absent (of `value`, expiring after `ttlMs`) that answers true for the FIRST caller\n * only; `put` writes without an expiry. */\ndeclare const store: {\n claim(key: string, value: string, ttlMs: number): Promise<boolean>; release(key: string): Promise<void>;\n get(key: string): Promise<string | null>; put(key: string, value: string): Promise<void>;\n};\n```\n\nThe container's side is three kinds of HTTP call and no vxil key: `processing` pings to\n`callback_url`, the file to `output_url` (with `Authorization: Bearer <output_ticket>` and the\nfile's `Content-Type`), and `{\"status\": \"failed\", \u2026}` to `callback_url` if it gives up. The\ncontainer sends its pings through `progressReporter` and its `failed` through `postCallback`\n([callbacks.ts](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)), and it sends\nthe output again on any non-`2xx` answer until `deadline_at`. Five notes on the shape:\n\n- **A doubled start launches one container.** The coordinator claims `start:<run id>` as\n `launching` before it launches and marks it `launched` once the launch succeeded. A start vxil\n re-sends is answered `202` and launches nothing only when the claim says `launched`; while the\n first copy is still launching, the re-sent one is answered `503`, so vxil keeps the start alive.\n (A `202` there would end vxil's retries, and if the first launch then failed nothing would run\n and the render would refund at its deadline.) When the launch fails, the coordinator releases\n the claim and answers `503` at once, and vxil sends the start again later. A coordinator that\n dies mid-launch leaves a `launching` claim that expires after a minute, so a later copy launches;\n pass the run id as your container platform's idempotency key, where it has one, so that copy\n cannot start a second container if the first launch did go through.\n- **A retried output post** (the container saw a network error after the coordinator had uploaded)\n finds `output:<run id>` in the coordinator's store, skips the upload and only sends the\n `completed` post again. A repeated `completed` is answered with the settled state and changes\n nothing, so the final post can be retried as often as it takes.\n- **Very large outputs.** The coordinator above holds the whole file in memory, and passing gigabytes\n through it costs its bandwidth too. For big files, have the container report the size first\n (`POST /output-url` with its ticket on the `Authorization` header and `{ size_bytes }`) and let the coordinator answer with the\n presigned upload URL it minted; the container PUTs straight to it, and the coordinator completes\n the object and posts `completed` when the container says it is done. The container still holds no\n key: a presigned URL is good for one object for a few minutes.\n- **Upgrading an earlier copy of this blueprint.** Renders started before `request-render` put\n `user_id` in the start body have none, and a server-mode upload must name its user. The\n coordinator refuses such a start with a `400`, which ends that run and refunds it at once;\n redeploy `request-render` (`vxil push`) before you point `render_url` at the coordinator.\n- **Serving it.** The row now holds a files object id. Read it back with a signed download URL, or\n publish it from a settle function with a server key (`vx.files.publish(object_id)`) for a stable\n public URL served from the edge cache.\n\nOptions A and B below show the same contract with the runtime posting `completed` itself (an\n`output_url` in your own bucket). Either can adopt the coordinator: point `render_url` at it, and have\nstep 1 trigger the Trigger.dev task or spawn the Modal function.\n\n## Option A \u2014 Trigger.dev (v4)\n\nTrigger.dev runs the render as a task on a machine you pick, with ffmpeg or Chromium baked into the\nimage, retries, and its own run dashboard. Two pieces: a **relay endpoint** that turns vxil's start\nrequest into a Trigger.dev trigger, and the **task**.\n\n**Why a relay, and not `render_url` pointed straight at Trigger.dev's trigger API?** vxil sends\n`callback_url` beside `payload` at the top level of the body, and Trigger.dev's trigger API passes\nonly `payload` to the task \u2014 the task would never see where to report. The relay is ~30 lines and is\nalso where your `render_token` is checked. Host it anywhere that serves https: a serverless function on\nyour web host, a small edge worker, a route in your existing API.\n\n```ts\n// relay.ts \u2014 your render_url. Standard fetch handler (edge worker / serverless function / Node 18+ adapter).\n// Env: RENDER_TOKEN (the same value as the vxil secret render_token), TRIGGER_SECRET_KEY (tr_prod_\u2026 / tr_dev_\u2026).\ntype Start = {\n render_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; correlation_id?: string; deadline_at: string }; callback_url: string;\n};\n\nexport default {\n async fetch(req: Request, env: { RENDER_TOKEN: string; TRIGGER_SECRET_KEY: string }): Promise<Response> {\n if (req.method !== 'POST') return new Response('method not allowed', { status: 405 });\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) {\n return new Response('unauthorized', { status: 401 }); // a 4xx ends the vxil run (and refunds)\n }\n const s = (await req.json()) as Start;\n const res = await fetch('https://api.trigger.dev/api/v1/tasks/render-video/trigger', {\n method: 'POST',\n headers: { authorization: `Bearer ${env.TRIGGER_SECRET_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n payload: {\n render_id: s.render_id, composition: s.composition, props: s.props ?? {},\n callback_url: s.callback_url, deadline_at: s.payload.deadline_at,\n },\n options: {\n // a start vxil re-sends (a lost answer) triggers the SAME Trigger.dev run\n idempotencyKey: `render:${s.render_id}`,\n // a render still queued after 3 minutes is dropped (it never runs, and no\n // onFailure fires \u2014 vxil refunds it at its deadline). Part of the budget below.\n ttl: '3m',\n tags: [`render_${s.render_id}`],\n },\n }),\n });\n if (res.ok) return Response.json({ accepted: true }, { status: 202 });\n // Trigger.dev busy or down: answer 503 and vxil tries the start again; anything else ends the run\n return new Response(`trigger.dev ${res.status}`, { status: res.status === 429 || res.status >= 500 ? 503 : 400 });\n },\n};\n```\n\n```ts\n// trigger.config.ts \u2014 ffmpeg and Chromium in the task image\nimport { defineConfig } from '@trigger.dev/sdk';\nimport { ffmpeg } from '@trigger.dev/build/extensions/core';\nimport { puppeteer } from '@trigger.dev/build/extensions/puppeteer';\n\nexport default defineConfig({\n project: '<your project ref>',\n dirs: ['./trigger'],\n maxDuration: 600, // CPU seconds PER ATTEMPT \u2014 the task sets its own; see the budget below\n build: { extensions: [ffmpeg(), puppeteer()] }, // puppeteer also needs PUPPETEER_EXECUTABLE_PATH set in the Trigger.dev env\n});\n```\n\n```ts\n// trigger/render-video.ts \u2014 the task: render, upload, report to vxil\nimport { task, metadata, logger } from '@trigger.dev/sdk';\nimport { postCallback, progressReporter } from '../callbacks'; // the helpers above\n\ntype Payload = {\n render_id: string; composition: string; props: Record<string, unknown>;\n callback_url: string; deadline_at: string; // when vxil stops waiting (ISO)\n};\n\n/** The longest one attempt takes, wall clock, with margin. An attempt that cannot\n * finish before deadline_at does not start: it tells vxil, so the credits come back now. */\nconst ATTEMPT_WALL_MS = 12 * 60_000;\n\n/** completed / failed: retried on 429 / 5xx / network errors (Retry-After honoured) until the\n * deadline \u2014 a repeated final post is a no-op on vxil's side, so retrying is always safe. */\nconst report = (p: Payload, body: Record<string, unknown>) =>\n postCallback(p.callback_url, body, Date.parse(p.deadline_at));\n\nexport const renderVideo = task({\n id: 'render-video',\n machine: 'large-1x', // 4 vCPU / 8 GB \u2014 size to your renders\n maxDuration: 600, // CPU seconds per attempt (10 min) \u2014 not wall time\n retry: { maxAttempts: 2, minTimeoutInMs: 5_000, maxTimeoutInMs: 30_000 },\n run: async (p: Payload) => {\n if (Date.now() + ATTEMPT_WALL_MS > Date.parse(p.deadline_at)) {\n // too late to finish inside vxil's deadline: refund now, and do not retry\n await report(p, { status: 'failed', error: 'deadline', hint: 'no time left for another attempt' });\n return { skipped: 'deadline' };\n }\n metadata.set('stage', 'rendering'); // Trigger.dev's own run view\n const progress = progressReporter(p.callback_url); // best-effort, at most one ping per 5 s\n await progress({ stage: 'rendering', progress: 0 });\n\n // \u2026your render: drive Chromium for frames, run ffmpeg \u2014 call progress({ progress, stage })\n // as often as you like (it coalesces) \u2014 and write the file\n // to YOUR bucket keyed by render_id (so a retried attempt overwrites, not duplicates)\u2026\n const outputUrl = `https://cdn.example.com/renders/${p.render_id}.mp4`;\n const durationS = 31.2;\n logger.info('rendered', { render_id: p.render_id, outputUrl });\n if (Date.now() > Date.parse(p.deadline_at)) {\n // vxil has already failed and refunded this render: the post below is answered\n // with that settled state. Your sizing is off \u2014 widen the budget below.\n logger.warn('finished after the vxil deadline', { render_id: p.render_id });\n }\n\n await report(p, {\n status: 'completed', output_url: outputUrl, duration_s: durationS, progress: 100, stage: 'done',\n });\n return { output_url: outputUrl };\n },\n // after the last attempt THROWS: tell vxil, so the credits come back now, not at the\n // deadline. Not called when an attempt exceeds maxDuration or the run expires on its\n // ttl \u2014 those refund only at vxil's deadline.\n onFailure: async ({ payload, error }) => {\n await report(payload, {\n status: 'failed', error: 'render_failed', hint: String(error instanceof Error ? error.message : error).slice(0, 200),\n });\n },\n});\n```\n\n**Budget the wall clock.** Trigger.dev's limits and vxil's deadline are separate clocks, and only\nvxil's refunds. Size them so a render always ends \u2014 `completed` or `failed` \u2014 before vxil's deadline:\n\n```\nttl + maxAttempts \xD7 (longest attempt, wall clock) + retry backoff < timeout.after_ms\n3 min + 2 \xD7 12 min + \u2264 1 min = 28 min < 30 min\n```\n\n`maxDuration` counts **CPU time per attempt**, not wall time across the run, so it does not bound\nthe sum: the `deadline_at` check at the start of each attempt does. If your renders need more, raise\n`RENDER_DEADLINE_MS` in `request-render` (up to `generation.maxTimeoutMs`, one hour) and resize the\nrest to fit.\n\n**Be honest with yourself about three things before you ship on Trigger.dev Cloud:**\n\n- **Data residency.** Trigger.dev Cloud keeps its operational and log data \u2014 including each run's\n payload \u2014 in the US (us-east-1), even when the machines run elsewhere. Your render props and the\n `callback_url` pass through it. If that rules it out, self-host Trigger.dev for your own app, or use\n option B.\n- **The callback URL is a credential for one render.** It appears in Trigger.dev's run payload and\n dashboard. It can settle only that render, and stops mattering once the render is settled.\n- **Two clocks, and `onFailure` is not a guarantee.** Trigger.dev calls `onFailure` only after the last\n attempt throws. A run that exceeds `maxDuration`, or expires on its `ttl` before it starts, ends\n without it \u2014 vxil refunds those at its deadline, not sooner. Keep the budget above, and keep the\n `deadline_at` check, so a late attempt refunds early instead of finishing after the refund.\n\n## Option B \u2014 your own container runtime (Modal, Fly, a container on your own cloud account)\n\nSame contract, no relay: the endpoint you deploy **is** `render_url`. It must answer within 20 seconds,\nso it only checks the token, hands the job to a background worker and answers `2xx`; the worker renders\nand posts back. On Modal:\n\n```python\n# render_app.py \u2014 `modal deploy render_app.py`; render_url = the endpoint's https URL\nimport http.client, json, os, time, urllib.error, urllib.request\nfrom datetime import datetime, timedelta, timezone\nimport modal\nfrom fastapi import HTTPException, Request # also `pip install fastapi` where you run `modal deploy`\n\nimage = (modal.Image.debian_slim()\n .apt_install(\"ffmpeg\", \"chromium\")\n .pip_install(\"fastapi[standard]\"))\napp = modal.App(\"render-farm\", image=image)\nsecrets = [modal.Secret.from_name(\"render-farm\")] # RENDER_TOKEN\n\ndef _post(callback_url: str, body: dict) -> None:\n req = urllib.request.Request(callback_url, data=json.dumps(body).encode(),\n headers={\"content-type\": \"application/json\"}, method=\"POST\")\n with urllib.request.urlopen(req, timeout=30) as res:\n res.read()\n\ndef report(callback_url: str, body: dict, deadline: datetime) -> None:\n \"\"\"completed / failed: MUST arrive. Retries 429, 5xx and network errors (honouring\n Retry-After) until the deadline; a repeated final post changes nothing on vxil's side.\"\"\"\n attempt = 0\n while True:\n wait = min(2 ** attempt, 30)\n attempt += 1\n try:\n _post(callback_url, body)\n return\n except urllib.error.HTTPError as e:\n if e.code != 429 and e.code < 500:\n raise # another 4xx: the URL or the body is wrong\n ra = e.headers.get(\"retry-after\")\n wait = int(ra) if ra and ra.isdigit() else wait\n except (OSError, http.client.HTTPException):\n pass # no answer, or the connection dropped mid-answer\n # (URLError, a reset, RemoteDisconnected, IncompleteRead,\n # a timeout): send it again\n if datetime.now(timezone.utc) + timedelta(seconds=wait) > deadline:\n raise RuntimeError(\"callback not delivered before the deadline\")\n time.sleep(wait)\n\n_last_ping = {}\ndef ping(callback_url: str, body: dict) -> None:\n \"\"\"processing: best-effort, at most one every 5 s; a refused or failed ping is dropped.\"\"\"\n now = time.monotonic()\n if now - _last_ping.get(callback_url, 0.0) < 5:\n return\n _last_ping[callback_url] = now\n try:\n _post(callback_url, {\"status\": \"processing\", **body})\n except Exception:\n pass\n\nATTEMPT_WALL_S = 25 * 60 # = the timeout below; a call cut off there may never reach its except\n\n@app.function(cpu=4, memory=8192, timeout=ATTEMPT_WALL_S, secrets=secrets)\ndef render(job: dict) -> None:\n cb = job[\"callback_url\"]\n deadline = datetime.fromisoformat(job[\"payload\"][\"deadline_at\"].replace(\"Z\", \"+00:00\"))\n if datetime.now(timezone.utc) + timedelta(seconds=ATTEMPT_WALL_S) > deadline:\n # queued too long to finish before vxil stops waiting: refund now\n report(cb, {\"status\": \"failed\", \"error\": \"deadline\", \"hint\": \"started too late to finish\"}, deadline)\n return\n try:\n ping(cb, {\"stage\": \"rendering\", \"progress\": 0})\n # \u2026render with ffmpeg / chromium (ping(cb, {...}) as often as you like),\n # upload to YOUR bucket keyed by job[\"render_id\"]\u2026\n output_url = f\"https://cdn.example.com/renders/{job['render_id']}.mp4\"\n except Exception as e: # the RENDER failed: tell vxil now, so the credits come back before the deadline\n report(cb, {\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": str(e)[:200]}, deadline)\n raise\n # the output exists: only `completed` may follow. Outside the try on purpose, so a\n # trouble delivering it can never turn into a `failed` post that refunds a finished render.\n report(cb, {\"status\": \"completed\", \"output_url\": output_url, \"duration_s\": 31.2,\n \"progress\": 100, \"stage\": \"done\"}, deadline)\n\n@app.function(secrets=secrets)\n@modal.fastapi_endpoint(method=\"POST\")\nasync def start(request: Request):\n if request.headers.get(\"authorization\") != f\"Bearer {os.environ['RENDER_TOKEN']}\":\n raise HTTPException(status_code=401, detail=\"unauthorized\") # a 4xx ends the vxil run\n job = await request.json()\n await render.spawn.aio(job) # queued; returns at once, well inside the 20-second window\n return {\"accepted\": True}\n```\n\n`spawn` queues the call and returns immediately, so the endpoint answers in well under a second. A\nstart can arrive twice (a lost answer is retried), so dedupe on the run id\n(`request.headers[\"x-vxil-run-id\"]`; never a hash of the props): keep the run ids you have spawned in\na `modal.Dict`, or make the render overwrite the same output key.\n\nAnything else that can (1) answer an https POST in under 20 s, (2) run the work in the background and\n(3) POST JSON to a URL fits the same contract: a Fly Machine started per job, a container service with\na queue in front, your own GPU box. vxil does not care what runs the render \u2014 only that the start is\nacknowledged quickly and the callback eventually comes.\n\n## Run it\n\nGive a user some credits from your server (or sell `render_pack_100` through your payments provider).\n`$KEY` is a server key: `vxil env pull && set -a && . ./.env.local && set +a && KEY=$VXIL_API_KEY`.\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"render_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser):\n\n**The two values the browser needs.** `VXIL_PUBLISHABLE_KEY` is a **public-class** key, the kind that\nlives in a browser or an app and only works alongside a signed-in user's session:\n\n```bash\nvxil keys mint --name render-browser --class public --scopes functions:invoke,cms:read --json\n```\n\n`USER_SESSION` is that user's session token. This blueprint does not enable sign-in: add\n`auth: { methods: { emailPassword: true } }` to `features`, push, and sign the user in with a second,\nsign-in-only key (`vxil keys mint --name render-signin --scopes auth:signin --json`; it cannot be\npublic-class, because it runs before there is a session) \u2014 vxil.com/docs/guide/06-feature-catalog, auth,\n\"The end-user identity loop, worked end to end\". The `user_id` you grant credits to is that signed-in\nuser's id.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['request-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['request-render']({\n composition: 'promo-30s', request_key: 'promo-1', props: { headline: 'Spring sale' },\n});\n// \u2192 { item_id, run_id, credits: 5 }\n// (or { duplicate: true, request_key, item_id, run_id, status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.status \u2192 'completed', row.output_url \u2192 'https://cdn.example.com/renders/\u2026.mp4'\n}\n```\n\n**Try it before you have a runtime.** Point `render_url` at any https endpoint that answers `2xx`\n(a request-bin works) and play the runtime yourself: copy `callback_url` from the request it received,\nthen\n\n```bash\ncurl -s -X POST \"$CALLBACK_URL\" -H 'content-type: application/json' \\\n -d '{\"status\":\"completed\",\"output_url\":\"https://cdn.example.com/x.mp4\",\"duration_s\":12.5,\"progress\":100,\"stage\":\"done\"}'\n# \u2192 { \"data\": { \"run_id\": \"run_\u2026\", \"generation_status\": \"completed\" } } \u2014 and the row says so\n```\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| runtime posted `completed` | `completed` | `status: completed` + every key it sent | committed |\n| runtime posted `failed` | `failed` | `status: failed`, `error` = its code + hint (written by `notify-ready`) | refunded |\n| runtime never called back | `failed` (`GenerationExpired`) at the deadline | `failed`, `error: GenerationExpired: the render did not finish before its deadline` | refunded |\n| runtime finished after the deadline | `failed` (`GenerationExpired`) \u2014 exact to the second; the late `completed` is answered `failed` / `expired: true` | `failed` | refunded (your compute was spent \u2014 budget the clocks) |\n| the final post was answered `429` (or `5xx`, or got no answer) | still open: nothing is settled until a post lands | unchanged | still held \u2014 `postCallback` sends it again after `Retry-After`; give up only at `deadline_at`, when the run refunds anyway |\n| a progress ping was answered `429` | unchanged | the previous progress stays | unchanged \u2014 drop the ping; the next one carries the newer state |\n| your endpoint answered `5xx` / timed out | start retried with backoff; terminal after the attempts | `processing` \u2192 `failed`, `error: RetryableHttp: the render endpoint kept failing to accept the render (retries exhausted)` (or `NetworkError: \u2026` when it could not be reached) | held until then, then refunded |\n| your endpoint answered another `4xx` (a bad token) | `failed` at once | `failed` | refunded |\n| the user had too few credits (at `request-render`) | ended at once (`ReserveInsufficient`), never started | `failed`, `error: insufficient_credits`; that `request_key` is spent; the caller got the `402`, no message | nothing held |\n| too many renders in flight, payments briefly unreachable, or a jobs-side fault | not created yet (the caller gets `429` / `503` with `queued: true` and `retry_after`, or `502` with `queued: true`) | `pending`, no run \u2014 **queued**: `redrive-pending` starts it when a slot frees up (or the app calls again with the **same** `request_key`) | held when it starts |\n| the user had too few credits when the re-driver started it | ended at once (`ReserveInsufficient`) | `failed`, `error: insufficient_credits`; the owner is told once (they last heard \"queued\") | nothing held |\n| still no free slot an hour later | never created | `failed`, `error: not_started: the render waited too long for a free slot` (written by `redrive-pending`); the owner is told once | nothing held |\n| the re-driver's start was refused (`400` / `401` / `403` / `422`: a non-https or private `render_url`, a wrong or revoked `vxil_jobs_key`) | not created | still `pending` \u2014 the tick stops and reports it (`stopped: { status, code }` in the function's logs); fix the setup and the next tick carries on; the one-hour bound still applies | nothing held |\n\n## The backlog: `maxConcurrent`, `429` and the re-driver\n\n`generation.maxConcurrent` is how many generation runs this project may have **open** at once: 20 by\ndefault, settable up to 200 in the jobs config. This blueprint sets 20; raise it to what your plan\nand your runtime can carry:\n\n```ts\njobs: { enabled: true, generation: { maxConcurrent: 50 /* 1\u2013200, default 20 */ } },\n```\n\nAt the cap a new start is refused with `429` and `Retry-After: 5`. **No run is created and nothing\nis held yet.** The same holds when the credits cannot be held because payments is briefly\nunreachable: the start is refused with `503 payments_unavailable` (nothing started, nothing held;\nit names a 15-second wait), `request-render` answers `503` with `queued: true`, and the re-driver starts\nthe row on a later tick with the same key (a fresh run). Never a free render: that only happens if\nyou opt in with `reserve_credits.on_unavailable: 'proceed'`. `request-render` passes the `429` on to the app with `queued: true` (and the\n`item_id` and `request_key`), and leaves the row `pending` with no `run_id`. That render is\n**queued, not refused**: the re-driver will start it, and hold its credits then, up to an hour\nlater. So the app must treat a `429` with `queued: true` as \"queued\" \u2014 show it, watch the row \u2014 and\nmust **never retry it with a new `request_key`**: that is a second render, and both are charged. A\nretry with the **same** `request_key` is always safe. Rows like that are the backlog, and two\nthings drain it:\n\n1. **`redrive-pending`, every minute.** It reads the oldest `pending` rows with no run that are at\n least 30 s old (`{ status: 'pending', created_at: { $lt: \u2026 }, run_id: null }`, sorted by\n `created_at`, 20 per tick; `status` and `created_at` are index slots, so the read stays cheap at\n any size) and starts each one with the same descriptor and the **same idempotency key** as\n `request-render`, so a row the app is re-driving at the same moment still gets one run. It\n **stops at the first `429`** (or `503 payments_unavailable`), since the rest of the batch would\n get the same answer, and the next tick carries on. Each try is recorded on the row (`redrive_attempts`, `redriven_at`). It\n sends the row's **stored** `deadline_at` (a row with none gets one stored first, only while none\n is), so its body is the same request as the first start's and jobs hands that run back. A `409\n generation_in_progress` (another start of the row is still placing its credit hold) is recorded\n and the tick goes on; a `422 idempotency_key_reused` that names a run (a run of this key exists\n under another body) links that run to the row. A `402`\n fails the row with `insufficient_credits`. Any other `400`, `401`, `403` or `422` also **stops** the tick\n and fails nothing: every re-driven row sends the same descriptor, so a refusal means the setup is\n wrong (a non-https or private `render_url`, a revoked key), not the row; the tick reports it as\n `stopped: { status, code }`. The only thing that fails a waiting row is age: a row still waiting\n after **one hour** is failed with `not_started: the render waited too long for a free slot`.\n Nothing was ever held, so nothing is refunded. In both the `402` and the one-hour case the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`), because the last thing they heard\n was \"queued\". (The function's scopes include `notifications:send` for that.)\n2. **The app**, calling `request-render` again with the same `request_key`, if it wants the render\n started sooner than the next tick.\n\n`overlap: 'skip'` keeps a slow tick from being doubled by the next one. On the Free plan, run it\nevery 15 minutes (see the plan note at the top).\n\n**Why it has its own key.** A credit hold placed by a function must name the signed-in user the\nfunction acts for, and a cron tick has none. So `redrive-pending` makes the start with\n`vxil_jobs_key`, an API key holding only `jobs:write`, the way your trusted server would. That is\nmore than \"hold credits and start runs\": `jobs:write` also lets the key cancel or replay any run of\nthis project, enqueue any job, and create or change schedules and flow rules, and it can hold\ncredits against any of your users and start runs against any public https endpoint. Treat it as a\nserver key: keep it only in this function's secrets, never in an app, and rotate it like any\nserver key. The re-driver never reads the\nprice from the row (a signed-in user can edit their own row): the hold is the function's fixed\n`RENDER_CREDITS`, and a row whose `render_key` is not `owner:request_key` is failed, not started.\n(A hand-written row like that which also breaks the `render_key_shape` hook cannot be written at\nall, so the tick counts it as `unwritable` and it keeps one of the 20 slots: fix or delete it by\nhand.)\n\n## A contract test for your runtime\n\nEvery key your runtime posts in the `completed` body is written onto the row, so every key must be a\ndeclared field of `renders`. Keep that true in your own CI with a test beside your runtime's code. It\nfails the day someone adds a key to the completion body and forgets the field:\n\n```ts\n// render-contract.test.ts \u2014 vitest, in YOUR repo (the one holding vxil.config.ts)\nimport { describe, expect, it } from 'vitest';\nimport config from './vxil.config';\n// the bodies your runtime (or coordinator) really posts: import the builders from that code,\n// so the test follows it instead of a hand-copied list\nimport { completedBody, progressBody } from './runtime/callback-bodies';\n\ndescribe('render callbacks', () => {\n const declared = new Set(Object.keys(config.cms!.collections!.renders!.fields!));\n\n it('every key of the completion body is a declared renders field', () => {\n const body = completedBody({ objectId: 'obj_test', durationS: 1 });\n expect(Object.keys(body).filter((k) => !declared.has(k))).toEqual([]);\n });\n\n it('a progress ping carries only the keys the mirror keeps', () => {\n const ping = progressBody({ progress: 40, stage: 'encoding' });\n expect(Object.keys(ping).filter((k) => !['status', 'progress', 'stage', 'message'].includes(k))).toEqual([]);\n });\n});\n```\n\nThe platform also has a fallback for the day that test is missing. When the row refuses a completion\nmirror because of an undeclared key, vxil writes the status alone, so the row still says\n`completed`, and the run reports what it dropped (`GET /v1/jobs/runs/{run_id}` \u2192 `mirror_error`,\nwith `fields_dropped`). The app no longer hangs on `processing`, but the dropped keys are not on the\nrow. The test is what keeps them there.\n\n## The bounds to design against\n\n- **Deadline**: the run's timeout is clamped to `generation.maxTimeoutMs` \u2014 one hour at most. A render\n that can take longer should be split (the next step starts from `notify-ready`), or tracked on your own\n row without a platform-held reserve.\n- **In flight**: `generation.maxConcurrent` renders at once (20 here and by default; up to 200). Over\n it, `request-render` answers `429` and the row waits for `redrive-pending`, or for a retry with the\n same `request_key` ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **Holds**: one render's reserve is clamped to `generation.maxReserveCredits` (50 here), and the sum of\n all open holds is capped by `generation.maxOutstandingReserveCredits` (5,000 here).\n- **Callback**: at most 256 KiB per post, JSON, every key a declared field of `renders`. About 120\n posts a minute per render plus a small allowance kept for the final post (both best-effort, per\n serving machine); progress at most every 5 s, and the final post retried until it lands\n ([callback limits](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)).\n\n## Your token, and where it lives\n\n`render_url` and `render_token` are function secrets; `request-render` reads them at invoke time and\nputs them on the generation run, which vxil stores with the run (your project only) for as long as the\njobs retention keeps it. The token is **never returned by a read**: `GET /v1/jobs/runs/{run_id}` (and\nthe dashboard and MCP reads built on it) shows the provider header names with every value as\n`[redacted]`. Rotate it in both places (`vxil secrets set functions/render_token` and your endpoint's\nenv); runs already started keep the token they were started with.\n\n## Evidence\n\n- **Read in vxil's code**: the start request body (`provider.body` + `payload` + `callback_url`), the\n 20-second start bound, the retry ladder, the status mirror writing every completion key onto the row,\n the hold committed on `completed` and released on `failed` / the deadline, and `error` / `hint`\n becoming `error_class` / `error_hint` on `job.generation.failed`.\n- **Read in Trigger.dev's and Modal's documentation, not executed from vxil**: the trigger endpoint\n `POST https://api.trigger.dev/api/v1/tasks/{taskId}/trigger` with `{ payload, options }` and the\n `idempotencyKey` / `ttl` / `tags` / `machine` options; `task({ id, machine, maxDuration, retry, run,\n onFailure })` and `retry` options, `maxDuration` being CPU time per attempt with no `onFailure` when\n it is exceeded, `metadata.set`, and the `ffmpeg()` / `puppeteer()` build extensions; Trigger.dev\n Cloud's US-hosted operational data; Modal's `@modal.fastapi_endpoint` and `.spawn()`. Check each\n vendor's current docs before you ship.\n",
19468
19563
  "functions": {
19469
19564
  "notify-ready.ts": "// notify-ready.ts \u2014 job.generation.completed | failed \u2192 tell the render's owner\n// (a vxil function, webhook trigger on `job.generation.`).\n//\n// The status mirror has already written the row (status, and on completion\n// every key your runtime sent back). This function adds the two things a\n// mirror cannot: the failure cause on the row, and a message to the user.\n//\n// AT-LEAST-ONCE: an event can be delivered again. And because the trigger\n// declares `retry: { maxAttempts: 3 }`, a non-2xx answer from here goes back\n// to the jobs ladder for another attempt (without `retry` it would simply be\n// acknowledged). Every attempt carries the same event. The notification carries an\n// Idempotency-Key of one per RUN, so a redelivery sends nothing twice, and the\n// row patch writes the same value again.\n\nimport type { JobGenerationSettledEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype RenderRow = {\n owner?: string; composition?: string; output_url?: string; error?: string; redrive_attempts?: number;\n run_id?: string | null; status?: string;\n};\n\n/** Platform error classes settle with no hint; these are the ones a render\n * can end with, in words for the row (the class stays first, for code). */\nconst PLATFORM_HINTS: Record<string, string> = {\n GenerationExpired: 'the render did not finish before its deadline',\n RetryableHttp: 'the render endpoint kept failing to accept the render (retries exhausted)',\n NetworkError: 'the render endpoint could not be reached (retries exhausted)',\n};\n\nfunction patchError(base: string, H: Record<string, string>, id: string, error: string): Promise<Response> {\n return fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(id)}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: { error } }),\n });\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as WebhookFunctionEnvelope<JobGenerationSettledEventPayload>;\n const d = env.payload?.data;\n // job.generation.queued carries no status; a truncated event has no fields\n if (!d || 'truncated' in d || !('status' in d) || !d.generation_id) return Response.json({ skipped: true });\n const cms = env.scoped_jwts?.cms;\n const notifications = env.scoped_jwts?.notifications;\n if (!cms || !notifications) return Response.json({ error: 'missing cms/notifications scope' }, { status: 403 });\n const base = env.vxil_base;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n const rowRes = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(d.generation_id)}`, { headers: H });\n // another job's generation event (not a render) \u2014 nothing to do\n if (rowRes.status === 404) return Response.json({ skipped: 'not a render' });\n if (!rowRes.ok) return Response.json({ error: `render read: ${rowRes.status}` }, { status: 502 }); // another attempt (header note)\n const row = ((await rowRes.json()) as { data: { data: RenderRow } }).data.data;\n if (!row.owner) return Response.json({ skipped: 'no owner' });\n\n if (d.status === 'failed') {\n // BRANCH ON error_class \u2014 not every `failed` is a render that failed:\n // \u2022 PaymentsUnavailable: the credits could not be held when the run was\n // started, so it ended before it began (nothing held). The start\n // answered 503 and the row is still pending \u2014 redrive-pending starts\n // it on a later tick (a NEW run). Nothing to tell the owner.\n // ONE race to undo: a request-render that re-sent the same key while\n // that start was still waiting on payments was handed THIS run (202,\n // deduplicated) and linked it \u2014 and redrive-pending only picks rows\n // with no run_id. Unlink it (only while the row is still pending on\n // this very run), so the re-driver starts it.\n if (d.error_class === 'PaymentsUnavailable') {\n if (row.run_id === d.run_id && (row.status ?? 'pending') === 'pending') {\n const unlinked = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(d.generation_id)}`, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({ data: { run_id: null }, if: { run_id: d.run_id, status: 'pending' } }),\n });\n // 409: the row moved on meanwhile (re-driven, failed) \u2014 nothing to undo\n if (!unlinked.ok && unlinked.status !== 409) {\n return Response.json({ error: `render patch: ${unlinked.status}` }, { status: 502 }); // another attempt (header note)\n }\n return Response.json({ skipped: 'not started: payments unavailable', unlinked: unlinked.ok });\n }\n return Response.json({ skipped: 'not started: payments unavailable' });\n }\n // \u2022 Cancelled: your own cancel (POST /v1/jobs/runs/{id}/cancel); the\n // credits were released. The row says so; the owner is not told \"try\n // again\" \u2014 tell them from where you cancelled, if at all.\n if (d.error_class === 'Cancelled') {\n if (row.error !== 'cancelled') {\n const patched = await patchError(base, H, d.generation_id, 'cancelled');\n if (!patched.ok) return Response.json({ error: `render patch: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n }\n return Response.json({ ok: true, notified: false });\n }\n // Not enough credits. A render request-render started itself already\n // answered the user (402) and wrote error: 'insufficient_credits' \u2014 keep\n // that code on the row (write it only if that write was lost) and send\n // nothing. A render the RE-DRIVER started is different: the user last\n // heard 429 \"queued\", so they are told \u2014 with the re-driver's own key\n // (render-not-started:<item_id>), so the two never both send.\n if (d.error_class === 'ReserveInsufficient') {\n if (!row.error) {\n const patched = await patchError(base, H, d.generation_id, 'insufficient_credits');\n if (!patched.ok) return Response.json({ error: `render patch: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n }\n if (!(typeof row.redrive_attempts === 'number' && row.redrive_attempts > 0)) {\n return Response.json({ ok: true, notified: false });\n }\n return send(base, notifications, `render-not-started:${d.generation_id}`, row.owner, {\n subject: 'Your render could not start',\n paragraph: `\"${row.composition ?? 'Your render'}\" was queued, but there were not enough credits when its turn came. Nothing was charged \u2014 top up and try again.`,\n });\n }\n // the cause the run settled with: your runtime's `error` (+ `hint`), or\n // the platform's own class \u2014 which carries no hint, so a short human\n // one is added for the ones a user can meet (PLATFORM_HINTS)\n const hint = d.error_hint ?? (d.error_class ? PLATFORM_HINTS[d.error_class] : undefined);\n const cause = [d.error_class, hint].filter(Boolean).join(': ') || 'render failed';\n if (row.error !== cause) {\n const patched = await patchError(base, H, d.generation_id, cause.slice(0, 500));\n if (!patched.ok) return Response.json({ error: `render patch: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n }\n }\n\n return send(base, notifications, `render-ready:${d.run_id}`, row.owner, d.status === 'completed'\n ? { subject: 'Your render is ready', paragraph: `\"${row.composition ?? 'Your render'}\" finished. Open the app to watch it.` }\n : { subject: 'Your render could not finish', paragraph: 'Nothing was charged \u2014 the credits are back on your balance. Try again in a minute.' });\n },\n};\n\n/** One transactional message; a non-2xx answer asks the ladder for another attempt. */\nasync function send(\n base: string, token: string, idempotencyKey: string, userId: string,\n data: { subject: string; paragraph: string },\n): Promise<Response> {\n const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'idempotency-key': idempotencyKey },\n body: JSON.stringify({ user_id: userId, template: 'transactional', data }),\n });\n return sent.ok\n ? Response.json({ ok: true, notified: true })\n : Response.json({ error: `notifications send: ${sent.status}` }, { status: 502 }); // another attempt (header note)\n}\n",
19470
19565
  "redrive-pending.ts": "// redrive-pending.ts \u2014 the BACKLOG RE-DRIVER (a vxil function, cron trigger,\n// every minute; `overlap: 'skip'`).\n//\n// cron-walk: drains-filter \u2014 every row it starts is PATCHed with its run_id (and\n// every row it gives up on to status 'failed'), which takes it out of the\n// `{ status: 'pending', run_id: null }` read; a 429 stops the tick early.\n//\n// Why it exists: at the generation concurrency cap (`generation.maxConcurrent`\n// open runs \u2014 20 in this blueprint, up to 200) request-render answers 429 and\n// leaves the row `pending` with NO run. Without this function only the caller\n// re-drives it (the same request_key again). With it, a backlog drains on its\n// own: each tick reads the oldest pending rows that have had no run for at\n// least REDRIVE_AFTER_MS, and starts each one with the SAME descriptor and the\n// SAME idempotency key (the row's render_key) request-render uses \u2014 so a row a\n// user is re-driving at the same moment still gets exactly one run.\n//\n// per row, by the jobs answer:\n// 202 (new run, or an open one handed back) \u2192 PATCH run_id (the status\n// mirror settles the row from here)\n// 202 deduplicated, generation_status 'failed' \u2192 PATCH run_id + status 'failed'\n// 402 (too few credits) \u2192 PATCH status 'failed',\n// error 'insufficient_credits',\n// and TELL the owner (they last\n// heard \"queued\", not \"refused\")\n// 429 (cap reached / too many credits held), \u2192 STOP the tick; the rest wait\n// or 503 payments_unavailable (credits could for the next one (the wait\n// not be held: nothing started or held) header is reported; the same\n// key starts a fresh run then)\n// 409 generation_in_progress (another start \u2192 record the attempt, go on (the\n// of this row is still placing its hold) next tick gets its run back)\n// 422 idempotency_key_reused with a run_id \u2192 PATCH that run_id: a run of this\n// (the key's run was sent another body) row's key exists, whatever its body\n// 400 / 401 / 403 / 422 \u2192 STOP the tick, report it. Every\n// re-driven row sends the same\n// descriptor apart from its own\n// (already bounded) composition and\n// props, so a refusal is a SETUP\n// error \u2014 a non-https or private\n// render_url, a wrong or revoked\n// key, a config change \u2014 that\n// would fail every row the same\n// way. Nothing is failed for it:\n// fix the setup and the next tick\n// carries on.\n// 5xx / network \u2192 record the attempt, go on\n// A row still pending with no run after BACKLOG_MAX_AGE_MS is the ONLY thing\n// the re-driver gives up on: status 'failed', error 'not_started: \u2026' (nothing\n// was ever held for it), and the owner is told once\n// (Idempotency-Key render-not-started:<item_id>).\n// Every try is recorded on the row: redrive_attempts, redriven_at.\n//\n// THE SAME BODY, EVERY SEND: jobs hands an existing run back only to a re-send\n// of the SAME request (a canonical-JSON fingerprint \u2014 defaults applied, keys\n// sorted, provider/poll header VALUES left out \u2014 not byte-for-byte). A\n// different one answers 422 idempotency_key_reused (enforced on every\n// project since 2026-10-05). So the run's deadline_at is\n// the row's STORED `deadline_at` (request-render writes it with the row), never\n// recomputed here; a row with none (no run holds its key) gets a fresh one\n// stored BEFORE the send, and a 429 / 503 payments_unavailable (no run holds\n// the key) clears it again so the next try computes a fresh one.\n//\n// A HAND-WRITTEN ROW that breaks render_key_shape (written before the hook\n// existed, or by a path that bypassed it) cannot be patched either \u2014 the\n// hook judges the merged row \u2014 so it would stay pending and take one of the\n// tick's slots for good. Fix or delete such rows by hand; the tick reports\n// them as `unwritable`.\n\n// THE KEY: a function-originated credit hold must name the user it acts for,\n// and a cron tick has no signed-in user \u2014 so the jobs call is made with\n// `vxil_jobs_key`, an API key of this same backend holding ONLY `jobs:write`\n// (a trusted server key may hold credits for any of your users; README \"The\n// backlog\"). The rows are read and written with the function's own scoped cms\n// token. The credits held are this blueprint's fixed price, never the row's\n// `credits` field \u2014 a signed-in user can edit their own row, so nothing the\n// re-driver charges or starts is read from a value they could have lowered.\n// The owner is told with the function's scoped notifications token.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\n/** What one render costs \u2014 the SAME number as request-render's RENDER_CREDITS. */\nconst RENDER_CREDITS = 5;\n/** The deadline \u2014 the SAME number as request-render's RENDER_DEADLINE_MS. */\nconst RENDER_DEADLINE_MS = 1_800_000;\n/** A row is the re-driver's only once request-render has had time to start it. */\nconst REDRIVE_AFTER_MS = 30_000;\n/** Rows started per tick (bounded: a tick is one function invocation). */\nconst MAX_REDRIVE_PER_TICK = 20;\n/** A row still waiting for a run after this long is failed (nothing is held). */\nconst BACKLOG_MAX_AGE_MS = 3_600_000;\n\ntype Env = CronFunctionEnvelope;\ntype RenderRow = {\n item_id: string;\n created_at?: string;\n data: {\n render_key?: string; request_key?: string; owner?: string; composition?: string;\n props?: Record<string, unknown>; created_at?: string; redrive_attempts?: number;\n deadline_at?: string | null;\n };\n};\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notifications = env.scoped_jwts?.notifications;\n const jobsKey = env.secrets?.vxil_jobs_key;\n const renderUrl = env.secrets?.render_url;\n const renderToken = env.secrets?.render_token;\n if (!cms || !notifications) return Response.json({ error: 'missing cms/notifications scope' }, { status: 403 });\n if (!jobsKey || !renderUrl || !renderToken) {\n return Response.json(\n { error: 'store the secrets: vxil secrets set functions/vxil_jobs_key (an API key holding only jobs:write), functions/render_url, functions/render_token' },\n { status: 503 },\n );\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = Date.now();\n\n // the oldest pending rows with no run: `status` (s4) and `created_at` (t1)\n // are index slots, so the slots pick the rows and the `run_id: null` test\n // only runs over what they picked\n const filter = encodeURIComponent(JSON.stringify({\n status: 'pending',\n created_at: { $lt: new Date(now - REDRIVE_AFTER_MS).toISOString() },\n run_id: null,\n }));\n const listed = await fetch(\n `${base}/v1/cms/items/renders?filter=${filter}&sort=created_at&limit=${MAX_REDRIVE_PER_TICK}`,\n { headers: H },\n );\n if (!listed.ok) return Response.json({ error: `renders read: ${listed.status}` }, { status: 502 });\n const rows = ((await listed.json()) as { data?: { items?: RenderRow[] } }).data?.items ?? [];\n\n const out = { scanned: rows.length, started: 0, failed: 0, retry_later: 0, given_up: 0, unwritable: 0,\n notified: 0, notify_failed: 0,\n stopped: null as null | { status: number; code: string | null; retry_after: string | null } };\n const tell = async (row: RenderRow, why: 'credits' | 'waited') => {\n if (await notifyNotStarted(base, notifications, row, why)) out.notified += 1;\n else out.notify_failed += 1;\n };\n\n for (const row of rows) {\n const d = row.data;\n const attempts = (typeof d.redrive_attempts === 'number' ? d.redrive_attempts : 0) + 1;\n const createdAt = Date.parse(d.created_at ?? row.created_at ?? '');\n const mark = { redrive_attempts: attempts, redriven_at: new Date(now).toISOString() };\n\n // a row that cannot be a render request-render made (a hand-written row\n // missing its key parts), or one that has waited too long: fail it \u2014 no\n // run exists, so nothing is held and nothing is refunded\n const shapeOk = !!d.owner && !!d.request_key && !!d.composition\n && d.render_key === `${d.owner}:${d.request_key}`\n && d.composition.length <= 120 && JSON.stringify(d.props ?? {}).length <= 16_384;\n if (!shapeOk || (Number.isFinite(createdAt) && now - createdAt > BACKLOG_MAX_AGE_MS)) {\n const error = shapeOk\n ? 'not_started: the render waited too long for a free slot'\n : 'not_started: the row is not a render request-render created';\n // only while it is STILL pending with no run (a racing start wins)\n if (await patchRow(base, H, row.item_id, { ...mark, status: 'failed', error }, { status: 'pending', run_id: null })) {\n out.given_up += 1;\n // the owner last heard \"queued\" (a 429): say it will not happen. A\n // malformed row's owner is not trusted, so it is only failed.\n if (shapeOk) await tell(row, 'waited');\n } else if (!shapeOk) {\n out.unwritable += 1; // header note: fix it by hand\n }\n continue;\n }\n\n // the row's STORED deadline \u2014 the body every earlier start sent. None\n // stored: store a fresh one first, so a lost answer is re-sent the same\n // (a row that will not take it waits for the next tick).\n let deadlineAt = d.deadline_at;\n if (!deadlineAt) {\n deadlineAt = new Date(now + RENDER_DEADLINE_MS).toISOString();\n // only while none is stored (a racing request-render may have just stored its own)\n if (!(await patchRow(base, H, row.item_id, { ...mark, deadline_at: deadlineAt }, { deadline_at: null }))) {\n out.retry_later += 1;\n continue;\n }\n }\n const enq = await startRun(base, jobsKey, renderUrl, renderToken, {\n itemId: row.item_id, user: d.owner!, renderKey: d.render_key!, requestKey: d.request_key!,\n composition: d.composition!, props: d.props ?? {}, deadlineAt,\n });\n if (!enq) { // network: try again next tick\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n if (enq.status === 402) {\n if (await patchRow(base, H, row.item_id, { ...mark, status: 'failed', error: 'insufficient_credits' })) await tell(row, 'credits');\n out.failed += 1;\n continue;\n }\n const failure = enq.status === 429 || (enq.status >= 400 && enq.status !== 402)\n ? ((await enq.json().catch(() => ({}))) as { error?: { code?: string; run_id?: string } }).error\n : undefined;\n const code = failure?.code ?? null;\n if (enq.status === 422 && code === 'idempotency_key_reused' && failure?.run_id) {\n // a run already holds this row's key under another body (a start that\n // raced a deadline reset, or a row from before the stored deadline):\n // it IS this row's render \u2014 link it, never fail the row for it\n await patchRow(base, H, row.item_id, { ...mark, run_id: failure.run_id });\n out.started += 1;\n continue;\n }\n if (enq.status === 409 && code === 'generation_in_progress') {\n // another start of this row is still placing its credit hold: its run\n // comes back to the next tick's same-body re-send\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n if (enq.status === 429 || code === 'payments_unavailable') {\n // no run holds this key: the next try computes a fresh deadline\n await patchRow(base, H, row.item_id, { ...mark, deadline_at: null });\n out.stopped = { status: enq.status, code: enq.status === 429 ? null : code, retry_after: enq.headers.get('retry-after') };\n break;\n }\n if (enq.status >= 400 && enq.status < 500) {\n // 429: at the cap \u2014 the rest of the batch would get the same answer.\n // 503 payments_unavailable: the credits could not be held right now\n // (nothing was started or held) \u2014 the rest would get the same answer;\n // the next tick re-sends the SAME key and gets a fresh run.\n // 400 / 401 / 403 / 422: a setup error (header note) \u2014 failing rows for\n // it would be wrong and could not be undone. Either way: record the\n // try, stop, and let the next tick (Retry-After: seconds) go on.\n await patchRow(base, H, row.item_id, mark);\n out.stopped = { status: enq.status, code: enq.status === 429 ? null : code, retry_after: enq.headers.get('retry-after') };\n break;\n }\n if (!enq.ok) { // 5xx: try again next tick\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n const run = ((await enq.json()) as { data: { run_id: string; generation_status?: string; deduplicated?: boolean } }).data;\n if (run.deduplicated && run.generation_status === 'failed') {\n // the run already ENDED (its row write was lost): record it as failed\n await patchRow(base, H, row.item_id, { ...mark, run_id: run.run_id, status: 'failed' });\n out.failed += 1;\n continue;\n }\n await patchRow(base, H, row.item_id, { ...mark, run_id: run.run_id });\n out.started += 1;\n }\n // a 2xx either way: a cron tick is never retried (the next tick is the retry)\n return Response.json(out);\n },\n};\n\ninterface Start {\n itemId: string; user: string; renderKey: string; requestKey: string;\n composition: string; props: Record<string, unknown>;\n /** the row's stored deadline (ISO) \u2014 never recomputed per send */\n deadlineAt: string;\n}\n\n/** The SAME webhook-mode generation request-render starts (the CI gate keeps\n * the two descriptors equal), sent with the jobs:write server key. Null on a\n * network error. */\nasync function startRun(base: string, key: string, renderUrl: string, renderToken: string, a: Start): Promise<Response | null> {\n return fetch(`${base}/v1/jobs/generation`, {\n // tenant-key: jobs:write via secret:vxil_jobs_key \u2014 called with the server\n // key, not the function's scoped token (header note); the CI gate checks\n // the secret is declared instead of a jobs scope\n method: 'POST',\n headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: 'render',\n provider: {\n url: renderUrl,\n method: 'POST',\n headers: { authorization: `Bearer ${renderToken}` },\n body: { render_id: a.itemId, user_id: a.user, composition: a.composition, props: a.props },\n },\n completion: { mode: 'webhook', status_path: 'status' },\n status_mirror: {\n feature: 'cms', collection: 'renders', record_id: a.itemId, column: 'status',\n progress_fields: ['progress', 'stage', 'message'],\n },\n reserve_credits: { amount: RENDER_CREDITS, user_id: a.user, credit_type: 'render_credits', reason: `render ${a.composition}` },\n timeout: { after_ms: RENDER_DEADLINE_MS },\n payload: { generation_id: a.itemId, correlation_id: a.requestKey, deadline_at: a.deadlineAt },\n idempotency_key: a.renderKey,\n }),\n }).catch(() => null);\n}\n\n/** PATCH a render row (optionally only while `when` still holds); true when it landed. */\nasync function patchRow(\n base: string, H: Record<string, string>, itemId: string,\n data: Record<string, unknown>, when?: Record<string, unknown>,\n): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify(when ? { data, if: when } : { data }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n\n/** Tell the owner a render they were told was queued will not start. One\n * message per render (Idempotency-Key render-not-started:<item_id> \u2014 the SAME\n * key notify-ready uses for a re-driven credit refusal, so the two never both\n * send). True when it was accepted. */\nasync function notifyNotStarted(base: string, token: string, row: RenderRow, why: 'credits' | 'waited'): Promise<boolean> {\n const name = row.data.composition ?? 'Your render';\n const res = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`, 'content-type': 'application/json',\n 'idempotency-key': `render-not-started:${row.item_id}`,\n },\n body: JSON.stringify({\n user_id: row.data.owner,\n template: 'transactional',\n data: why === 'credits'\n ? { subject: 'Your render could not start', paragraph: `\"${name}\" was queued, but there were not enough credits when its turn came. Nothing was charged \u2014 top up and try again.` }\n : { subject: 'Your render could not start', paragraph: `\"${name}\" waited over an hour for a free slot and was cancelled. Nothing was charged \u2014 try again later.` },\n }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n",
@@ -19488,7 +19583,7 @@ export default defineConfig({
19488
19583
  "hasFunctions": true,
19489
19584
  "byoKeys": [],
19490
19585
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Fan-out / Fan-in\" \u2014 split one request into parts, run every part as its\n// own background job, and aggregate ONCE when the last part has ended.\n//\n// start-report (your function, http)\n// \u2192 creates-or-finds the `reports` row (unique request_key)\n// \u2192 writes one `report_parts` row per part (unique part_key)\n// \u2192 POST /v1/jobs/enqueue-batch with batch_id = 'report:<item_id>' and\n// batch_total = the number of parts \u2014 each part a job that runs\n// compute-part, idempotent per part\n// compute-part (queue trigger, retried up to 3 times)\n// \u2192 computes ITS part and writes the result onto its `report_parts` row\n// vxil\n// \u2192 when EVERY part's run has ended (succeeded, dead-lettered, expired or\n// cancelled) it emits ONE `job.batch.completed` event:\n// { batch_id, total, succeeded, dead_lettered, cancelled }\n// finish-report (webhook trigger on `job.batch.`)\n// \u2192 reads the parts, aggregates, and finishes the `reports` row\n// (status 'done', or 'partial' when some parts failed)\n//\n// vxil runs no \"next step\" for you: the batch is a counter and one event. The\n// aggregation is your own function, so the platform stays a queue, not a\n// workflow engine \u2014 and every step here is safe to run twice.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: { enabled: true },\n cms: {\n // a row is live the moment it is written\n draftPublish: false,\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n reports: {\n singular: 'report',\n fields: {\n // THE RESUME ANCHOR: a repeated start with the same key is a 409 that\n // start-report reads back and resumes \u2014 never a second report\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n // splitting \u2192 running \u2192 done | partial\n status: { type: 'string', indexSlot: 's2' },\n // the input, kept so a resumed start fans out the SAME parts\n numbers: { type: 'json' },\n part_size: { type: 'int' },\n parts_total: { type: 'int', indexSlot: 'n1' },\n // written by finish-report\n parts_done: { type: 'int' },\n parts_failed: { type: 'int' },\n total: { type: 'float' },\n batch_id: { type: 'text' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n completed_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n report_parts: {\n singular: 'report_part',\n fields: {\n // '<report item_id>:<index>' \u2014 a re-created part is a 409, so a\n // resumed start never doubles a part\n part_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n // the parent `reports` item_id: finish-report reads the parts by it\n report: { type: 'string', required: true, indexSlot: 's2' },\n // pending \u2192 done (a part whose job dead-lettered stays pending)\n status: { type: 'string', indexSlot: 's3' },\n index: { type: 'int', indexSlot: 'n1' },\n numbers: { type: 'json' },\n // written by compute-part\n result: { type: 'float' },\n attempt_key: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // Starts (or resumes) ONE report. Invoke it with a server key:\n // vxil functions invoke start-report --data '{ \"request_key\": \"q3\", \"numbers\": [1,2,3,4,5], \"part_size\": 2 }'\n 'start-report': {\n entry: './functions/start-report.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n egressAllow: [],\n signature: {\n input: { request_key: 'string', numbers: 'number[]', part_size: 'number | undefined' },\n output:\n '{ report_id: string; batch_id: string; parts: number; status: string; resumed?: boolean }',\n },\n },\n\n // ONE part, delivered by the job start-report enqueued for it. A non-2xx\n // answer is retried (up to 3 attempts); a part that still fails ends its\n // run dead-lettered \u2014 counted in job.batch.completed, never a stuck batch.\n 'compute-part': {\n entry: './functions/compute-part.ts',\n trigger: { kind: 'queue', source: 'report-parts', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // job.batch.completed \u2192 aggregate the parts and finish the report. The\n // event is emitted once per batch; its delivery is at-least-once, so the\n // finishing write is conditional (`if: { status: 'running' }`).\n 'finish-report': {\n entry: './functions/finish-report.ts',\n trigger: { kind: 'webhook', source: 'job.batch.', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n});\n",
19491
- "readme": '# Fan-out / Fan-in\n\nSplit one request into many parts, run every part as its own background job\nwith retries, and aggregate **exactly once** when the last part has ended. The\npattern behind "summarise 300 documents, then write the report", "render 40\nthumbnails, then publish the album", or "price 500 items, then total the quote".\n\n```\nstart-report (http) compute-part (queue) \xD7N finish-report (webhook)\n reports row \u2500\u2500\u25BA N report_parts rows \u2500\u2500\u25BA one job per part \u2500\u2500\u25BA each writes its result\n one enqueue-batch with \u2502\n batch_id = report:<id> \u25BC\n every part ended \u2192 job.batch.completed (once)\n \u2502\n \u25BC\n aggregate the rows, finish the report\n```\n\n**What it teaches that no other blueprint does:** the **batch** \u2014 a\n`batch_id` on enqueue that ties many runs together, and the one\n`job.batch.completed` event vxil emits when every run in it is terminal. There\nis no workflow engine and no "next step" run by the platform: the batch is a\ncounter and an event, and the aggregation is your own function.\n\n## Try it\n\n```bash\nvxil init --template fan-in my-fan-in && cd my-fan-in\nvxil push\nvxil functions invoke start-report --data \'{ "request_key": "q3-totals", "numbers": [1,2,3,4,5,6,7,8,9,10], "part_size": 3 }\'\n# 202 { "report_id": "itm_\u2026", "batch_id": "report:itm_\u2026", "parts": 4, "status": "running" }\n```\n\nA few seconds later the `reports` row reads `status: "done"`, `total: 55`,\n`parts_done: 4`. Watch the batch itself:\n\n```bash\nvxil api GET /v1/jobs/batches/report:itm_\u2026\n# { "state": "completed", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0,\n# "runs_by_state": { "succeeded": 4 }, "event_emitted_at": "\u2026" }\nvxil api GET \'/v1/jobs/runs?batch_id=report:itm_\u2026\'\n```\n\n## How the pieces fit\n\n| Step | Where | What makes it safe to run twice |\n| --- | --- | --- |\n| Create or find the report | `start-report` | `request_key` is `unique`: a repeat is a `409` that is read back and **resumed** |\n| Write one row per part | `start-report` | `part_key` (`<report id>:<index>`) is `unique`: a repeat is a `409` |\n| Enqueue one job per part | `start-report` \u2192 `POST /v1/jobs/enqueue-batch` | each job\'s `idempotency_key` is `<batch_id>:<index>`: a repeat hands back the same run |\n| Compute one part | `compute-part` | a part already `done` answers at once; the result written is the same every time |\n| Aggregate once | `finish-report` on `job.batch.completed` | the finishing write is conditional (`if: { status: \'running\' }`): a redelivered event\'s write is a `409` |\n\n### The batch\n\n`start-report` enqueues every part with the same `batch_id` (`report:<item id>`)\nand declares `batch_total` \u2014 the number of parts. vxil counts the batch\'s runs\nin the same transaction that ends each one. When the last one ends \u2014\n**succeeded, dead-lettered (retries exhausted), expired or cancelled** \u2014 it\nwrites one `job.batch.completed`:\n\n```json\n{ "batch_id": "report:itm_\u2026", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0 }\n```\n\n- **Once per batch.** A completed batch takes no new runs (`409 batch_closed`);\n replaying a part later creates a new run outside the batch, so the event is\n never sent twice. Its *delivery* to your function is at-least-once, which is\n why `finish-report` is re-entrant.\n- **Big fan-outs.** One enqueue-batch call takes 100 jobs; `start-report` sends\n up to five calls (500 parts). `batch_total` keeps the batch open until the\n last call has enqueued, so a fast first part can never complete the batch\n early. A different `batch_total` on a later call is `409 batch_total_mismatch`.\n- **A part that never starts** (the project at its concurrency cap, a long\n backlog) is ended after `ttl_seconds` (`PART_TTL_SECONDS`, one hour) as\n `Expired` and counted in `dead_lettered` \u2014 the report finishes `partial`\n instead of waiting forever.\n\n### Failure\n\n`compute-part` declares `retry: { maxAttempts: 3 }`: a non-2xx answer is tried\nagain with backoff, and a part that still fails ends its run dead-lettered. The\nbatch still completes; `finish-report` marks the report **`partial`** with\n`parts_failed`. To retry a failed part, replay its run (`vxil api POST\n/v1/jobs/runs/<run_id>/replay`) \u2014 the part row updates, but the report keeps\nthe result it was finished with; start a new report (a new `request_key`) for a\nfresh total.\n\n### Resuming\n\nCall `start-report` again with the same `request_key` after a lost answer or a\ncrash: it reads the **stored** input (a different `numbers` on the repeat is\nignored), writes only the missing part rows, re-sends the enqueue (a no-op for\nparts that already have a run), and answers `resumed: true`. A finished report\nanswers its final `status`.\n\n## Make it yours\n\n- **The work** is the one marked line in `functions/compute-part.ts`. Anything a\n function can do fits: call an API, run a model, transform a file.\n- **The aggregate** is in `functions/finish-report.ts`; it reads results from the\n part rows, never from events, so nothing is lost when a delivery repeats.\n- **Throughput:** the project\'s jobs concurrency setting and a\n [flow rule](../../docs/guide/06-feature-catalog.md) bound how many parts run at\n once \u2014 the fan-out queues, it never overloads a downstream API.\n- **Watching progress:** `GET /v1/jobs/batches/{batch_id}` gives open / succeeded\n / dead-lettered counts while the batch runs; the dashboard\'s Jobs page lists\n its runs (`?batch_id=`).\n\n## Limits\n\n| | |\n| --- | --- |\n| Parts per report (this blueprint) | 500 (`MAX_PARTS`) |\n| Jobs per enqueue-batch call | 100 |\n| Runs per batch | 100,000 |\n| `batch_id` | 1\u2013200 characters, `a-z A-Z 0-9 _ . : -` |\n| Part start deadline | 1 hour (`PART_TTL_SECONDS`, 60 s \u2013 31 days) |\n',
19586
+ "readme": '# Fan-out / Fan-in\n\nSplit one request into many parts, run every part as its own background job\nwith retries, and aggregate **exactly once** when the last part has ended. The\npattern behind "summarise 300 documents, then write the report", "render 40\nthumbnails, then publish the album", or "price 500 items, then total the quote".\n\n```\nstart-report (http) compute-part (queue) \xD7N finish-report (webhook)\n reports row \u2500\u2500\u25BA N report_parts rows \u2500\u2500\u25BA one job per part \u2500\u2500\u25BA each writes its result\n one enqueue-batch with \u2502\n batch_id = report:<id> \u25BC\n every part ended \u2192 job.batch.completed (once)\n \u2502\n \u25BC\n aggregate the rows, finish the report\n```\n\n**What it teaches that no other blueprint does:** the **batch** \u2014 a\n`batch_id` on enqueue that ties many runs together, and the one\n`job.batch.completed` event vxil emits when every run in it is terminal. There\nis no workflow engine and no "next step" run by the platform: the batch is a\ncounter and an event, and the aggregation is your own function.\n\n## Try it\n\n```bash\nmkdir my-fan-in && cd my-fan-in # `vxil init` writes into the current directory\nvxil init --template fan-in\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # the two collections + the three functions; writes vxil.types.ts\nvxil functions invoke start-report --data \'{ "request_key": "q3-totals", "numbers": [1,2,3,4,5,6,7,8,9,10], "part_size": 3 }\'\n# \u2192 { "report_id": "itm_\u2026", "batch_id": "report:itm_\u2026", "parts": 4, "status": "running" }\n```\n\n`start-report` answers `202 Accepted`; `vxil functions invoke` prints only the body, not the status.\n\n> **Plan note.** The three functions fit the Free plan (5 functions, no cron here) once the project\'s\n> workload is `staging` or `development`, which is the `vxil projects workload` step above. On a Free\n> `production` project, `vxil push` stops before it writes anything with `402 tier_required`, naming the\n> ways out: change the workload, upgrade to Developer, or `vxil push --skip-functions`. Free also caps\n> CPU at 50 ms per function call (waiting on `fetch` does not count): keep the work in `compute-part`\n> small per part, or the call is stopped with `503 cpu_limit_exceeded`, and a part that always\n> overruns ends dead-lettered (the report then finishes `partial`).\n\nA few seconds later the `reports` row reads `status: "done"`, `total: 55`,\n`parts_done: 4`. Watch the batch itself:\n\n```bash\nvxil api GET /v1/jobs/batches/report:itm_\u2026\n# { "state": "completed", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0,\n# "runs_by_state": { "succeeded": 4 }, "event_emitted_at": "\u2026" }\nvxil api GET \'/v1/jobs/runs?batch_id=report:itm_\u2026\'\n```\n\n## How the pieces fit\n\n| Step | Where | What makes it safe to run twice |\n| --- | --- | --- |\n| Create or find the report | `start-report` | `request_key` is `unique`: a repeat is a `409` that is read back and **resumed** |\n| Write one row per part | `start-report` | `part_key` (`<report id>:<index>`) is `unique`: a repeat is a `409` |\n| Enqueue one job per part | `start-report` \u2192 `POST /v1/jobs/enqueue-batch` | each job\'s `idempotency_key` is `<batch_id>:<index>`: a repeat hands back the same run |\n| Compute one part | `compute-part` | a part already `done` answers at once; the result written is the same every time |\n| Aggregate once | `finish-report` on `job.batch.completed` | the finishing write is conditional (`if: { status: \'running\' }`): a redelivered event\'s write is a `409` |\n\n### The batch\n\n`start-report` enqueues every part with the same `batch_id` (`report:<item id>`)\nand declares `batch_total` \u2014 the number of parts. vxil counts the batch\'s runs\nin the same transaction that ends each one. When the last one ends \u2014\n**succeeded, dead-lettered (retries exhausted), expired or cancelled** \u2014 it\nwrites one `job.batch.completed`:\n\n```json\n{ "batch_id": "report:itm_\u2026", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0 }\n```\n\n- **Once per batch.** A completed batch takes no new runs (`409 batch_closed`);\n replaying a part later creates a new run outside the batch, so the event is\n never sent twice. Its *delivery* to your function is at-least-once, which is\n why `finish-report` is re-entrant.\n- **Big fan-outs.** One enqueue-batch call takes 100 jobs; `start-report` sends\n up to five calls (500 parts). `batch_total` keeps the batch open until the\n last call has enqueued, so a fast first part can never complete the batch\n early. A different `batch_total` on a later call is `409 batch_total_mismatch`.\n- **A part that never starts** (the project at its concurrency cap, a long\n backlog) is ended after `ttl_seconds` (`PART_TTL_SECONDS`, one hour) as\n `Expired` and counted in `dead_lettered` \u2014 the report finishes `partial`\n instead of waiting forever.\n\n### Failure\n\n`compute-part` declares `retry: { maxAttempts: 3 }`: a non-2xx answer is tried\nagain with backoff, and a part that still fails ends its run dead-lettered. The\nbatch still completes; `finish-report` marks the report **`partial`** with\n`parts_failed`. To retry a failed part, replay its run (`vxil api POST\n/v1/jobs/runs/<run_id>/replay`) \u2014 the part row updates, but the report keeps\nthe result it was finished with; start a new report (a new `request_key`) for a\nfresh total.\n\n### Resuming\n\nCall `start-report` again with the same `request_key` after a lost answer or a\ncrash: it reads the **stored** input (a different `numbers` on the repeat is\nignored), writes only the missing part rows, re-sends the enqueue (a no-op for\nparts that already have a run), and answers `resumed: true`. A finished report\nanswers its final `status`.\n\n## Make it yours\n\n- **The work** is the one marked line in `functions/compute-part.ts`. Anything a\n function can do fits: call an API, run a model, transform a file.\n- **The aggregate** is in `functions/finish-report.ts`; it reads results from the\n part rows, never from events, so nothing is lost when a delivery repeats.\n- **Throughput:** the project\'s jobs concurrency setting and a\n flow rule (vxil.com/docs/guide/06-feature-catalog, the jobs section) bound how many parts run at\n once \u2014 the fan-out queues, it never overloads a downstream API.\n- **Watching progress:** `GET /v1/jobs/batches/{batch_id}` gives open / succeeded\n / dead-lettered counts while the batch runs; the dashboard\'s Jobs page lists\n its runs (`?batch_id=`).\n\n## Limits\n\n| | |\n| --- | --- |\n| Parts per report (this blueprint) | 500 (`MAX_PARTS`) |\n| Jobs per enqueue-batch call | 100 |\n| Runs per batch | 100,000 |\n| `batch_id` | 1\u2013200 characters, `a-z A-Z 0-9 _ . : -` |\n| Part start deadline | 1 hour (`PART_TTL_SECONDS`, 60 s \u2013 31 days) |\n',
19492
19587
  "functions": {
19493
19588
  "compute-part.ts": "// compute-part.ts \u2014 ONE part of the fan-out (a vxil function, queue trigger).\n//\n// Delivered by the job start-report enqueued for this part; the envelope's\n// `payload` is { report_id, part_key }. The \"work\" here is a sum \u2014 replace it\n// with yours (an API call, a model call, a file to process). Keep the result\n// on the part's own row: finish-report aggregates from the rows, not from\n// events, so nothing is lost if a delivery is repeated.\n//\n// AT-LEAST-ONCE: the same part can be delivered again (a retry, a redelivery).\n// A part already `done` answers at once; otherwise the result is computed and\n// written \u2014 the same value every time, so a second write changes nothing.\n// A non-2xx answer is retried (the trigger declares `retry: { maxAttempts: 3 }`);\n// a part that keeps failing ends its run dead-lettered, and the batch still\n// completes \u2014 finish-report then marks the report `partial`.\n\nimport type { QueueFunctionEnvelope } from '@vxil/sdk';\n\ntype Payload = { report_id?: string; part_key?: string };\ntype PartRow = { item_id: string; data: { status?: string; numbers?: unknown } };\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as QueueFunctionEnvelope<Payload>;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const partKey = typeof env.payload?.part_key === 'string' ? env.payload.part_key : '';\n // a malformed delivery can never succeed: acknowledge it (2xx) so it is not retried\n if (!partKey) return Response.json({ skipped: 'no part_key' });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n const filter = encodeURIComponent(JSON.stringify({ part_key: partKey }));\n const found = await fetch(`${base}/v1/cms/items/report_parts?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return Response.json({ error: `part read: ${found.status}` }, { status: 502 }); // another attempt (header note)\n const part = ((await found.json()) as { data?: { items?: PartRow[] } }).data?.items?.[0];\n if (!part) return Response.json({ skipped: 'part not found' });\n if (part.data.status === 'done') return Response.json({ part_key: partKey, already: true });\n\n const nums = Array.isArray(part.data.numbers) ? part.data.numbers.filter((n): n is number => typeof n === 'number') : [];\n const result = nums.reduce((a, b) => a + b, 0); // \u2190 your work goes here\n\n const patched = await fetch(`${base}/v1/cms/items/report_parts/${encodeURIComponent(part.item_id)}`, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({ data: { status: 'done', result, attempt_key: env.idempotency_key } }),\n });\n if (!patched.ok) return Response.json({ error: `part write: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n return Response.json({ part_key: partKey, result });\n },\n};\n",
19494
19589
  "finish-report.ts": "// finish-report.ts \u2014 FAN-IN: job.batch.completed \u2192 aggregate the parts and\n// finish the report (a vxil function, webhook trigger on `job.batch.`).\n//\n// vxil emits job.batch.completed ONCE per batch, when every run in it has\n// ended: { batch_id, total, succeeded, dead_lettered, cancelled }. Its\n// DELIVERY is at-least-once (a retry after a non-2xx answer, a redelivery), so\n// this function is re-entrant: it reads the parts, computes the same answer\n// every time, and finishes the report with a CONDITIONAL write\n// (`if: { status: 'running' }`) \u2014 the second delivery's write is a 409 and\n// changes nothing.\n//\n// Batches that are not reports (another feature of your app using batch_id)\n// are skipped by their prefix.\n\nimport type { JobBatchCompletedEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype ReportRow = { data: { status?: string; parts_total?: number } };\ntype PartRow = { data: { status?: string; result?: number } };\n\n/** The parts read per page while aggregating. */\nconst PAGE = 100;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as WebhookFunctionEnvelope<JobBatchCompletedEventPayload>;\n const d = env.payload?.data;\n if (!d || 'truncated' in d || typeof d.batch_id !== 'string') return Response.json({ skipped: true });\n if (!d.batch_id.startsWith('report:')) return Response.json({ skipped: 'not a report batch' });\n const reportId = d.batch_id.slice('report:'.length);\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const base = env.vxil_base;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const reportUrl = `${base}/v1/cms/items/reports/${encodeURIComponent(reportId)}`;\n\n const rep = await fetch(reportUrl, { headers: H });\n if (rep.status === 404) return Response.json({ skipped: 'report gone' });\n if (!rep.ok) return Response.json({ error: `report read: ${rep.status}` }, { status: 502 }); // another attempt (header note)\n const report = ((await rep.json()) as { data: ReportRow }).data;\n if (report.data.status !== 'running') return Response.json({ skipped: `report is ${report.data.status ?? 'unknown'}` });\n\n // aggregate from the rows (the source of truth), page by page\n let total = 0;\n let done = 0;\n let cursor: string | undefined;\n const filter = encodeURIComponent(JSON.stringify({ report: reportId }));\n for (;;) {\n const page = await fetch(\n `${base}/v1/cms/items/report_parts?filter=${filter}&limit=${PAGE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,\n { headers: H },\n );\n if (!page.ok) return Response.json({ error: `parts read: ${page.status}` }, { status: 502 }); // another attempt (header note)\n const body = (await page.json()) as { data?: { items?: PartRow[]; next_cursor?: string | null } };\n for (const p of body.data?.items ?? []) {\n if (p.data.status === 'done' && typeof p.data.result === 'number') {\n total += p.data.result;\n done += 1;\n }\n }\n cursor = body.data?.next_cursor ?? undefined;\n if (!cursor) break;\n }\n const partsTotal = report.data.parts_total ?? d.total;\n const failed = Math.max(0, partsTotal - done);\n\n const finished = await fetch(reportUrl, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({\n data: {\n status: failed === 0 ? 'done' : 'partial',\n total, parts_done: done, parts_failed: failed, completed_at: new Date().toISOString(),\n },\n if: { status: 'running' },\n }),\n });\n if (finished.status === 409) return Response.json({ skipped: 'already finished' });\n if (!finished.ok) return Response.json({ error: `report write: ${finished.status}` }, { status: 502 }); // another attempt (header note)\n return Response.json({\n report_id: reportId, status: failed === 0 ? 'done' : 'partial', total, parts_done: done, parts_failed: failed,\n runs: { succeeded: d.succeeded, dead_lettered: d.dead_lettered, cancelled: d.cancelled },\n });\n },\n};\n",
@@ -19543,7 +19638,7 @@ export default defineConfig({
19543
19638
  },
19544
19639
  },
19545
19640
  },
19546
- notifications: { provider: 'mock', fromEmail: 'library@example.app' },
19641
+ notifications: { provider: 'mock', fromEmail: 'library@example.com' },
19547
19642
  },
19548
19643
 
19549
19644
  // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500
@@ -19607,7 +19702,7 @@ export default defineConfig({
19607
19702
  },
19608
19703
  });
19609
19704
  `,
19610
- "readme": '# Research Library template\n\nA knowledge base for research/reference content \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions (all `cms` collections you own and can edit \u2014 all `public: true`):**\n- `topics` \u2014 subject areas (name, slug, description).\n- `sources` \u2014 cited works (title, url, author, kind, year \u2192 `topics`); a Lane-A hook keeps the year sane.\n- `notes` \u2014 your writing, cross-linked to a `source` and a `topic` (slot-bound so you can filter notes by either \u2014 the single-hop join in vxil.com/docs/guide/04-data-with-cms). `tags` is a JSON array (`$arrayContains`-searchable).\n\nAll three are **`public: true`** \u2014 a published research library reads over the keyless public-delivery lane\n(see below); keep a note or source as a DRAFT while it is a work-in-progress and it stays private.\n\n**Use it:**\n\n```bash\nvxil init --template research-library\nvxil quickstart --env staging # a new backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks\nvxil gen # typed SDK + MCP catalog\n```\n\n**What to learn from this:**\n- **A bounded citation graph** \u2014 `notes.source`/`notes.topic` are slot-bound relations, so a note list can\n filter one hop into the target collection: the single-hop dotted-key join (guide ch. 4, relational depth).\n- **Lane-A validate as a tenant-owned invariant** \u2014 the "sane year" rule is an AST-checked safe expression in\n YOUR config, run inside the write transaction (vxil.com/docs/guide/07-validation-and-hooks).\n- **`json` tags** \u2014 unslotted but `$arrayContains`-searchable \u2014 the platform indexes it for you (guide ch. 4, querying).\n\n```bash\n# every note whose cited source is a book, with a server key \u2014 the single-hop dotted join (guide ch. 4)\ncurl -G "https://api.vxil.com/v1/cms/items/notes" \\\n -H "Authorization: Bearer $VXIL_API_KEY" \\\n --data-urlencode \'filter={"source.kind":"book"}\'\n```\n\n**The public library \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because every collection is `public: true`, a\nreader front-end serves the published library with **no API key** \u2014 the edge forces `status = \'published\'`\n(drafts stay private), edge-caches the response, and serves the safe query subset (`filter`/`sort`/`limit`).\n\n```bash\n# the public citation index \u2014 NO api key\ncurl "https://api.vxil.com/v1/cms/public/$TENANT/sources?filter=%7B%22kind%22%3A%22book%22%7D&limit=50"\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 `templates/docs-site/`\n(a pure public-content site) \xB7 `templates/blog/` \xB7 `templates/notes/`.\n\n**Own the shape.** After `init` the config is yours \u2014 add fields, edit the hook, add collections. Nothing is\nlocked; the "sane year" invariant rides as a tenant-owned Lane-A `validate` hook you can see and change (the\nrequired titles are plain `required: true` field flags). To narrow the public surface, drop `public: true`\nfrom any collection you want to keep behind an API key \u2014 the flag is per-collection.\n',
19705
+ "readme": '# Research Library template\n\nA knowledge base for research/reference content \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions (all `cms` collections you own and can edit \u2014 all `public: true`):**\n- `topics` \u2014 subject areas (name, slug, description).\n- `sources` \u2014 cited works (title, url, author, kind, year \u2192 `topics`); a Lane-A hook keeps the year sane.\n- `notes` \u2014 your writing, cross-linked to a `source` and a `topic` (slot-bound so you can filter notes by either \u2014 the single-hop join in vxil.com/docs/guide/04-data-with-cms). `tags` is a JSON array (`$arrayContains`-searchable).\n\nAll three are **`public: true`** \u2014 a published research library reads over the keyless public-delivery lane\n(see below); keep a note or source as a DRAFT while it is a work-in-progress and it stays private.\n\n**Use it:**\n\n```bash\nmkdir my-library && cd my-library\nvxil init --template research-library\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the collections + hooks,\n # writes vxil.types.ts (or `vxil link <slug> --env staging` + `vxil push` for an existing backend)\nvxil seed # two topics (as drafts)\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_API_KEY + VXIL_TENANT_ID for the curl examples below\n```\n\nSeeded rows are drafts: publish one with `vxil api POST /v1/cms/items/topics/<item_id>/publish` (ids from\n`vxil api GET /v1/cms/items/topics`) before you look for it on the public lane. After you edit\n`vxil.config.ts`, `vxil push` applies it and regenerates the types.\n\n**What to learn from this:**\n- **A bounded citation graph** \u2014 `notes.source`/`notes.topic` are slot-bound relations, so a note list can\n filter one hop into the target collection: the single-hop dotted-key join (guide ch. 4, relational depth).\n- **Lane-A validate as a tenant-owned invariant** \u2014 the "sane year" rule is an AST-checked safe expression in\n YOUR config, run inside the write transaction (vxil.com/docs/guide/07-validation-and-hooks).\n- **`json` tags** \u2014 unslotted but `$arrayContains`-searchable \u2014 the platform indexes it for you (guide ch. 4, querying).\n\n```bash\n# every note whose cited source is a book, with a server key \u2014 the single-hop dotted join (guide ch. 4)\ncurl -G "https://api.vxil.com/v1/cms/items/notes" \\\n -H "Authorization: Bearer $VXIL_API_KEY" \\\n --data-urlencode \'filter={"source.kind":"book"}\'\n```\n\n**The public library \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because every collection is `public: true`, a\nreader front-end serves the published library with **no API key** \u2014 the edge forces `status = \'published\'`\n(drafts stay private), caches the response for up to 60 seconds, and serves the safe query subset\n(`filter`/`sort`/`limit`/`cursor`; dotted join keys such as `source.kind` work only on the keyed list above).\n\n```bash\n# the public citation index \u2014 NO api key\ncurl "https://api.vxil.com/v1/cms/public/$VXIL_TENANT_ID/sources?filter=%7B%22kind%22%3A%22book%22%7D&limit=50"\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 the **docs-site**\nblueprint (a pure public-content site) \xB7 the **blog** and **notes** blueprints.\n\n**Own the shape.** After `init` the config is yours \u2014 add fields, edit the hook, add collections. Nothing is\nlocked; the "sane year" invariant rides as a tenant-owned Lane-A `validate` hook you can see and change (the\nrequired titles are plain `required: true` field flags). To narrow the public surface, drop `public: true`\nfrom any collection you want to keep behind an API key \u2014 the flag is per-collection.\n',
19611
19706
  "functions": {}
19612
19707
  },
19613
19708
  {
@@ -19626,7 +19721,7 @@ export default defineConfig({
19626
19721
  "hasFunctions": false,
19627
19722
  "byoKeys": [],
19628
19723
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Gallery\" \u2014 an image / media gallery (albums of photos), declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 albums \u2192 photos (resolved by relation)\n// \u2022 files \u2192 the actual image bytes (managed object storage + signed shared links)\n// The `file`-typed fields store a files-feature object ref; the item stays a\n// bounded JSON document. Everything here is DATA the tenant owns and edits.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: { draftPublish: true },\n files: { enabled: true }, // managed object storage + shared links for the images\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n albums: {\n singular: 'album',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): a public gallery reads keyless \u2014 published\n // albums over GET /v1/cms/public/:tenantId/albums, edge-cached.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n cover: { type: 'file' }, // a files-feature object ref (the album cover)\n description: { type: 'text' },\n },\n },\n photos: {\n singular: 'photo',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): published photos read with NO API key. The\n // `image`/`cover` fields carry a files OBJECT REF (obj_\u2026), not bytes \u2014 the\n // public row exposes the ref; the reader resolves the actual image through a\n // files SHARED LINK (files.md), so the stored object stays access-controlled.\n public: true,\n fields: {\n title: { type: 'string', indexSlot: 's1' },\n image: { type: 'file', required: true }, // the photo bytes (via `files`)\n album: { type: 'relation', relationTo: 'albums', indexSlot: 's2' },\n taken_at: { type: 'datetime', indexSlot: 't1' },\n width: { type: 'int', indexSlot: 'n1' },\n height: { type: 'int', indexSlot: 'n2' },\n tags: { type: 'json' }, // [\"landscape\",\"2024\"] \u2014 $arrayContains-searchable\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'albums',\n items: [\n { title: 'Travels', slug: 'travels', description: 'Photos from the road.' },\n ],\n },\n ],\n },\n});\n",
19629
- "readme": "# Gallery template\n\nAn image / media gallery \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `albums` \u2014 a titled, slugged collection with a `cover` image (a `files` object ref) and description.\n **`public: true`** \u2014 a public gallery reads keyless.\n- `photos` \u2014 a `file`-typed `image` (the bytes live in `files`), linked to an `album`, with `taken_at`,\n `width`/`height`, and JSON `tags`. The `album` relation is slot-bound so you can list a single album's photos.\n **`public: true`** \u2014 published photos read keyless; the `image` field carries a files object ref (`obj_\u2026`),\n so the reader resolves the bytes through a files **shared link** (the stored object stays access-controlled).\n\n**Use it:**\n\n```bash\nvxil init --template gallery\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **`file` fields hold refs, not bytes** \u2014 `cover`/`image` store a `files`-feature object id (`obj_\u2026`); upload\n the bytes through `files` (managed storage + signed shared links) and the cms item stays a bounded JSON document.\n- **A slot-bound relation is the album view** \u2014 `photos.album` rides `s2`, so \"this album's photos\" is one\n indexed filter (vxil.com/docs/guide/04-data-with-cms).\n- **Numeric slots buy range queries** \u2014 `width`/`height` on `n1`/`n2` make dimension filters index-served.\n\n```ts\n// one album's photos with a server key, newest first\nconst { items } = await vx.from('photos').query({\n filter: { album: albumId, $status: 'published' }, sort: '-taken_at', limit: 24,\n});\n```\n\n**The public gallery \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `albums`/`photos` are `public: true`, so a\nstatic gallery front-end reads with **no API key** (published-only, edge-cached). The photo rows carry a\nfiles object ref, not bytes \u2014 resolve each through a files shared link (vxil.com/docs/guide/06-feature-catalog), so the stored\nobject stays access-controlled even though the metadata is public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// one album's public photos \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'photos', { filter: { album: albumId }, sort: '-taken_at' });\n```\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (files: upload + shared links) \xB7 vxil.com/docs/guide/04-data-with-cms (queries \xB7 **public\ndelivery**) \xB7 `templates/docs-site/` (a pure public-content site) \xB7 `templates/blog/` \xB7 `templates/directory/`.\n\n**Own the shape.** The config is yours after `init` \u2014 add EXIF fields, a `photographer` relation, more albums.\nNothing is locked.\n",
19724
+ "readme": "# Gallery template\n\nAn image / media gallery \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `albums` \u2014 a titled, slugged collection with a `cover` image (a `files` object ref) and description.\n **`public: true`** \u2014 a public gallery reads keyless.\n- `photos` \u2014 a `file`-typed `image` (the bytes live in `files`), linked to an `album`, with `taken_at`,\n `width`/`height`, and JSON `tags`. The `album` relation is slot-bound so you can list a single album's photos.\n **`public: true`** \u2014 published photos read keyless. The `image` field carries a private files object id\n (`obj_\u2026`), which a keyless reader cannot open: to show the picture on a public page, publish the object\n and store its public URL on the row (below).\n\n**Use it:**\n\n```bash\nmkdir my-gallery && cd my-gallery\nvxil init --template gallery\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\nvxil seed # one album, \"Travels\" (as a draft)\n```\n\nAfter you edit `vxil.config.ts`, `vxil push` applies it and regenerates the types.\n\n**Your first photo.** The seed creates an album but no photos: a `photos` row needs a real `image`, and an\nimage needs an upload first. On your server, with the project key (`vxil env pull` puts it in `.env.local`\nas `VXIL_API_KEY`). An upload made with a server key is owned by an end-user, so create one first \u2014\n`vxil api POST /v1/users --data '{\"id\":\"usr_owner\",\"email\":\"you@example.com\"}'` \u2014 or use a real user's id.\n\n```ts\nimport { readFile } from 'node:fs/promises';\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! })\nconst bytes = await readFile('beach.jpg');\nconst up = await vx.files.createUploadUrl({\n user_id: 'usr_owner', filename: 'beach.jpg', content_type: 'image/jpeg', size_bytes: bytes.byteLength,\n});\nawait fetch(up.upload_url, { method: up.upload_method, headers: up.upload_headers ?? { 'content-type': 'image/jpeg' }, body: bytes });\nawait vx.files.complete(up.object_id); // the object is now `available`\n\nconst { items: [album] } = await vx.from('albums').query({ filter: { slug: 'travels' }, limit: 1 });\nconst photo = await vx.from('photos').create({ image: up.object_id, album: album!.item_id, taken_at: new Date().toISOString() });\nawait vx.from('albums').publish(album!.item_id); // the seeded album is a draft\nawait vx.from('photos').publish(photo.item_id); // the row is now public; the picture itself needs the step below\n```\n\n`photo.item_id` and `up.object_id` are the `photoId` and `objectId` the snippets below use.\n\n**What to learn from this:**\n- **`file` fields hold refs, not bytes** \u2014 `cover`/`image` store a `files`-feature object id (`obj_\u2026`); upload\n the bytes through `files` (managed storage + signed shared links) and the cms item stays a bounded JSON document.\n- **A slot-bound relation is the album view** \u2014 `photos.album` rides `s2`, so \"this album's photos\" is one\n indexed filter (vxil.com/docs/guide/04-data-with-cms).\n- **Numeric slots buy range queries** \u2014 `width`/`height` on `n1`/`n2` make dimension filters index-served.\n\n```ts\n// one album's photos with a server key (`vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! })), newest first\nconst { items } = await vx.from('photos').query({\n filter: { album: albumId, $status: 'published' }, sort: '-taken_at', limit: 24,\n});\n```\n\n**The public gallery \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `albums`/`photos` are `public: true`, so a\nstatic gallery front-end reads the rows with **no API key** (published-only, cached for up to 60 seconds).\n\n**Serving the images keyless.** A row's `image` is a private object id; opening it needs a key, and so does\nminting a shared link, so an anonymous visitor can do neither. The keyless path is **public asset\ndelivery** (vxil.com/docs/guide/06-feature-catalog, files, \"Public asset delivery\"): your server publishes\nthe object once and stores the stable public URL on the row. Two changes to the config:\n\n```ts\n// vxil.config.ts \u2014 turn on publishing, with a thumbnail preset (Free allows 2 presets)\nfiles: { enabled: true, publicAssets: { enabled: true, variants: { thumb: { w: 320, fmt: 'webp' } } } },\n// \u2026and on `photos`, two plain string fields beside `image`:\nimage_url: { type: 'string' },\nthumb_url: { type: 'string' },\n```\n\nThen, on your server (a publish needs a server key with `files:write`):\n\n```ts\n// after the upload has completed, before you publish the photo row\nconst pub = await vx.files.publish(objectId); // idempotent; png/jpeg/webp/avif/gif\nawait vx.from('photos').patch(photoId, { image_url: pub.url, thumb_url: pub.variants.thumb });\nawait vx.from('photos').publish(photoId);\n```\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\nconst TENANT_ID = '00000000-0000-0000-0000-000000000000'; // yours: the `tenant:` line quickstart printed\n// one album's public photos \u2014 NO api key; render <img src={p.data.thumb_url}>\nconst { items } = await listCmsPublic(TENANT_ID, 'photos', { filter: { album: albumId }, sort: '-taken_at' });\n```\n\nA published file is world-readable at that URL until you `vx.files.unpublish(objectId)`. Keep files\n**shared links** (vxil.com/docs/guide/06-feature-catalog, files) for private or expiring sharing, where\nyour server hands one person one link.\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (files: upload + shared links) \xB7 vxil.com/docs/guide/04-data-with-cms (queries \xB7 **public\ndelivery**) \xB7 the **docs-site** blueprint (a pure public-content site) \xB7 the **blog** and **directory** blueprints.\n\n**Own the shape.** The config is yours after `init` \u2014 add EXIF fields, a `photographer` relation, more albums.\nNothing is locked.\n",
19630
19725
  "functions": {}
19631
19726
  },
19632
19727
  {
@@ -19645,7 +19740,7 @@ export default defineConfig({
19645
19740
  "hasFunctions": false,
19646
19741
  "byoKeys": [],
19647
19742
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Directory\" \u2014 a listings directory (categorized listings people can submit),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 listings (resolved by relation)\n// \u2022 auth \u2192 accounts, so a submitter is a verified end-user\n// `listings.submitted_by` is the end-user OWNER field: in end-user mode a\n// submitter can only edit their OWN listing (a no-op for server callers). This\n// is one declarative flag, not a per-user policy. Everything here is DATA the\n// tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // submissions land as drafts; an admin publishes them\n hooks: {\n // Every listing needs a name \u2014 a pure function of the row (Lane-A validate).\n listing_name: {\n collection: 'listings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.name) > 0',\n message: 'a listing needs a name',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // submitters sign in as end-users\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n categories: {\n singular: 'category',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the category nav reads keyless too.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n listings: {\n singular: 'listing',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified submitter may\n // only read/edit their OWN listing. Server callers are unaffected.\n ownerField: 'submitted_by',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the PUBLIC BROWSE VIEW \u2014 published listings\n // read with NO API key over GET /v1/cms/public/:tenantId/listings, and the\n // `submitted_by` owner field is STRIPPED from every served row (a visitor\n // never sees who submitted it). Owner-scoping (above) still governs the\n // authed edit lane \u2014 the two lanes are independent by design.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n submitted_by: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n description: { type: 'text' },\n website: { type: 'string' }, // stored, not indexed\n location: { type: 'string' }, // stored, not indexed\n featured: { type: 'bool' },\n rating: { type: 'float', indexSlot: 'n1' },\n submitted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'categories',\n items: [\n { name: 'Restaurants', slug: 'restaurants' },\n { name: 'Services', slug: 'services' },\n ],\n },\n ],\n },\n});\n",
19648
- "readme": "# Listings Directory template\n\nA categorized listings directory (people submit entries) \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name, unique slug. **`public: true`** \u2014 the category nav reads keyless.\n- `listings` \u2014 name, unique slug, `category` relation, `submitted_by` (the **owner field**), description,\n website, location, `featured`, `rating`, `submitted_at`. A Lane-A hook requires a name; `draftPublish` lets\n submissions land as drafts an admin publishes. **`public: true`** \u2014 the public browse view reads keyless,\n and `submitted_by` is stripped from every served row (a visitor never sees who submitted it).\n- `auth` (email/password) \u2014 so a submitter is a **verified end-user**.\n\n**Use it:**\n\n```bash\nvxil init --template directory\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **Owner-scoping is one declarative flag** \u2014 `ownerField: 'submitted_by'` (a real slot-bound string field):\n in end-user mode a submitter can only read/edit their **own** listing; server callers are unaffected\n (vxil.com/docs/guide/04-data-with-cms).\n- **Moderation is the lifecycle** \u2014 submissions land as drafts, an admin `publish` makes them public, and the\n public browse view is served over the keyless public-delivery lane (below).\n- **The name invariant is a Lane-A hook** \u2014 tenant-owned data you can edit after `init` (guide ch. 7).\n\n```ts\n// the browse view with a server key: published listings in one category, best-rated first\nconst { items } = await vx.from('listings').query({\n filter: { category: categoryId, $status: 'published' }, sort: '-rating', limit: 25,\n});\n```\n\n**The public browse view \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `listings`/`categories` are\n`public: true`, the public directory reads with **no API key** \u2014 the edge forces `status = 'published'`\n(pending submissions stay private until an admin publishes), edge-caches the page, and **strips the\n`submitted_by` owner field** from every row. Owner-scoping (guide ch. 4, ownerField) still governs the authed edit lane \u2014 the\ntwo lanes are independent.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public browse view \u2014 NO api key, submitted_by stripped\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'listings', { filter: { 'category.slug': 'restaurants' }, sort: '-rating' });\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (auth) \xB7 `examples/feedback-board/` (a voting board with a Lane-A hook + an HTTP function) \xB7\n`templates/docs-site/` (a pure public-content site) \xB7 `templates/catalog/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
19743
+ "readme": "# Listings Directory template\n\nA categorized listings directory (people submit entries) \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name, unique slug. **`public: true`** \u2014 the category nav reads keyless.\n- `listings` \u2014 name, unique slug, `category` relation, `submitted_by` (the **owner field**), description,\n website, location, `featured`, `rating`, `submitted_at`. A Lane-A hook requires a name; `draftPublish` lets\n submissions land as drafts an admin publishes. **`public: true`** \u2014 the public browse view reads keyless,\n and `submitted_by` is stripped from every served row (a visitor never sees who submitted it).\n- `auth` (email/password) \u2014 so a submitter is a **verified end-user**.\n\n**Use it:**\n\n```bash\nmkdir my-directory && cd my-directory\nvxil init --template directory\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\nvxil seed # two categories: Restaurants, Services (as drafts)\n```\n\n**See it work.** Seeded rows are drafts, and drafts never reach the public lane. Publish a category, then\nread it with no key:\n\n```bash\nID=$(vxil api GET /v1/cms/items/categories | jq -r '.items[0].item_id')\nvxil api POST /v1/cms/items/categories/$ID/publish\nvxil env pull && set -a && . ./.env.local && set +a # VXIL_TENANT_ID (and your key) into the shell\ncurl \"https://api.vxil.com/v1/cms/public/$VXIL_TENANT_ID/categories\"\n```\n\nThe public lane caches for up to 60 seconds. After you edit `vxil.config.ts`, `vxil push` applies it and\nregenerates the types.\n\n**What to learn from this:**\n- **Owner-scoping is one declarative flag** \u2014 `ownerField: 'submitted_by'` (a real slot-bound string field):\n in end-user mode a submitter can only read/edit their **own** listing; server callers are unaffected\n (vxil.com/docs/guide/04-data-with-cms).\n- **Moderation is the lifecycle** \u2014 submissions land as drafts, an admin `publish` makes them public, and the\n public browse view is served over the keyless public-delivery lane (below).\n- **The name invariant is a Lane-A hook** \u2014 tenant-owned data you can edit after `init` (guide ch. 7).\n\n```ts\n// the browse view with a server key (`vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! })):\n// published listings in one category, best-rated first\nconst { items } = await vx.from('listings').query({\n filter: { category: categoryId, $status: 'published' }, sort: '-rating', limit: 25,\n});\n```\n\n**The public browse view \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `listings`/`categories` are\n`public: true`, the public directory reads with **no API key** \u2014 the edge forces `status = 'published'`\n(pending submissions stay private until an admin publishes), edge-caches the page, and **strips the\n`submitted_by` owner field** from every row. Owner-scoping (guide ch. 4, ownerField) still governs the authed edit lane \u2014 the\ntwo lanes are independent.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\nconst TENANT_ID = '00000000-0000-0000-0000-000000000000'; // yours: the `tenant:` line quickstart printed\n// the public browse view \u2014 NO api key, submitted_by stripped\n// one category: resolve the slug to an id, then filter listings by the relation\nconst { items: [cat] } = await listCmsPublic(TENANT_ID, 'categories', { filter: { slug: 'restaurants' }, limit: 1 });\nconst { items } = await listCmsPublic(TENANT_ID, 'listings', { filter: { category: cat.item_id }, sort: '-rating' });\n```\n\nDotted join keys (`{ 'category.slug': 'restaurants' }`) work on the keyed list (`vx.from('listings').query`)\nbut not on the public lane, which answers `422` for them, so the keyless page reads in two steps.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (auth) \xB7 vxil.com/docs/guide/08-running-your-code-functions (adding an HTTP function) \xB7\nthe **docs-site** blueprint (a pure public-content site) \xB7 the **catalog** blueprint.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
19649
19744
  "functions": {}
19650
19745
  },
19651
19746
  {
@@ -19662,8 +19757,8 @@ export default defineConfig({
19662
19757
  ],
19663
19758
  "hasFunctions": false,
19664
19759
  "byoKeys": [],
19665
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \"Tasks\" \u2014 the minimal starter: one cms collection + notifications, declared\n// end-to-end in one typed file. The default `vxil init` scaffold. Everything\n// here is DATA you own and edit.\nexport default defineConfig({\n env: 'staging',\n\n // (a) per-feature config \u2014 the same TypeBox manifests, validated server-side.\n features: {\n cms: { draftPublish: true },\n notifications: { provider: 'mock', fromEmail: 'noreply@example.app' },\n },\n\n // (b) CMS schema-as-code \u2014 collections + fields (reconciled by `vxil push`).\n cms: {\n collections: {\n tasks: {\n singular: 'task',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n done: { type: 'bool' },\n due: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // (c) functions \u2014 your own code, opt-in and plan-limited (on Free: staging and\n // development projects; production projects from Developer). Uncomment to deploy.\n // functions: {\n // hello: { entry: './functions/hello.ts', trigger: { kind: 'http' } },\n // },\n\n // (d) seed \u2014 one row `vxil seed` applies so there's something to query.\n seed: {\n cms: [{ collection: 'tasks', items: [{ title: 'Try vxil push', done: false }] }],\n },\n});\n",
19666
- "readme": "# Tasks template (starter)\n\nThe minimal starter and the default `vxil init` scaffold \u2014 one `cms` collection (`tasks`) plus `notifications`.\n\n```bash\nvxil init # scaffolds this template by default\nvxil quickstart --env staging\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The three-block config anatomy** \u2014 `features` (per-feature config, validated server-side),\n `cms.collections` (schema-as-code, reconciled by `vxil push`), and the commented-out `functions` block you\n uncomment when you need tenant code.\n- **Index slots are the query budget** \u2014 `title` (`s1`) and `due` (`t1`) are slot-bound so they filter/sort by\n range; `done` is a plain `bool` (equality-only, no slot) (vxil.com/docs/guide/04-data-with-cms).\n- **`draftPublish: true`** \u2014 every item carries the draft\u2192published lifecycle, filtered with the reserved\n `$status` key.\n\n```ts\nconst { item_id } = await vx.from('tasks').create(\n { title: 'Ship the release', due: new Date(Date.now() + 86400e3).toISOString() },\n { status: 'published' },\n);\n\n// due before this time tomorrow, soonest first (slot-indexed range filter + sort)\nconst { items } = await vx.from('tasks').query({\n filter: { due: { $lt: new Date(Date.now() + 86400e3).toISOString() } }, sort: 'due', limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/05-typed-sdk-and-cli (the `vx.from` handle) \xB7 vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7\n`templates/notes/` (even smaller) \xB7 `templates/blog/` (relations + hooks).\n\nOwn the shape \u2014 add fields, add collections, uncomment the starter function. Nothing is locked.\n",
19760
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \"Tasks\" \u2014 the minimal starter: one cms collection + notifications, declared\n// end-to-end in one typed file. The default `vxil init` scaffold. Everything\n// here is DATA you own and edit.\nexport default defineConfig({\n env: 'staging',\n\n // (a) per-feature config \u2014 the same TypeBox manifests, validated server-side.\n features: {\n cms: { draftPublish: true },\n notifications: { provider: 'mock', fromEmail: 'noreply@example.com' },\n },\n\n // (b) CMS schema-as-code \u2014 collections + fields (reconciled by `vxil push`).\n cms: {\n collections: {\n tasks: {\n singular: 'task',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n done: { type: 'bool' },\n due: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // (c) functions \u2014 your own code, opt-in and plan-limited (on Free: staging and\n // development projects; production projects from Developer). Uncomment to deploy.\n // functions: {\n // hello: { entry: './functions/hello.ts', trigger: { kind: 'http' } },\n // },\n\n // (d) seed \u2014 one row `vxil seed` applies so there's something to query.\n seed: {\n cms: [{ collection: 'tasks', items: [{ title: 'Try vxil push', done: false }] }],\n },\n});\n",
19761
+ "readme": "# Tasks template (starter)\n\nThe minimal starter and the default `vxil init` scaffold \u2014 one `cms` collection (`tasks`) plus `notifications`.\n\n```bash\nmkdir my-tasks && cd my-tasks\nvxil init # scaffolds this template by default\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\nvxil seed # one task (as a draft)\nvxil api GET /v1/cms/items/tasks\n```\n\n`vxil quickstart` in an empty folder also scaffolds this template for you. After you edit\n`vxil.config.ts`, `vxil push` applies it and regenerates the types. The `notifications` feature ships on\nthe `mock` provider: sends are recorded, not delivered, until you configure a real one (guide ch. 6).\n\n**What to learn from this:**\n- **The three-block config anatomy** \u2014 `features` (per-feature config, validated server-side),\n `cms.collections` (schema-as-code, reconciled by `vxil push`), and the commented-out `functions` block you\n uncomment when you need tenant code.\n- **Index slots are the query budget** \u2014 `title` (`s1`) and `due` (`t1`) are slot-bound so they filter/sort by\n range; `done` is a plain `bool` (equality-only, no slot) (vxil.com/docs/guide/04-data-with-cms).\n- **`draftPublish: true`** \u2014 every item carries the draft\u2192published lifecycle, filtered with the reserved\n `$status` key.\n\n```ts\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }) \u2014 the key from `vxil env pull`\nconst { item_id } = await vx.from('tasks').create(\n { title: 'Ship the release', due: new Date(Date.now() + 86400e3).toISOString() },\n { status: 'published' },\n);\n\n// due before this time tomorrow, soonest first (slot-indexed range filter + sort)\nconst { items } = await vx.from('tasks').query({\n filter: { due: { $lt: new Date(Date.now() + 86400e3).toISOString() } }, sort: 'due', limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/05-typed-sdk-and-cli (the `vx.from` handle) \xB7 vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7\nthe **notes** blueprint (even smaller) \xB7 the **blog** blueprint (relations + hooks).\n\nOwn the shape \u2014 add fields, add collections, uncomment the starter function. Nothing is locked.\n",
19667
19762
  "functions": {}
19668
19763
  },
19669
19764
  {
@@ -19680,7 +19775,7 @@ export default defineConfig({
19680
19775
  "hasFunctions": false,
19681
19776
  "byoKeys": [],
19682
19777
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \"Notes\" \u2014 a single-collection notes app (no draft/publish), declared end-to-end\n// in one typed file. A minimal starter. Everything here is DATA you own and edit.\nexport default defineConfig({\n env: 'staging',\n features: { cms: { draftPublish: false } },\n cms: {\n collections: {\n notes: {\n singular: 'note',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n body: { type: 'text' },\n pinned: { type: 'bool' },\n },\n },\n },\n },\n // One row of seed data `vxil seed` applies so there's something to query.\n seed: {\n cms: [{ collection: 'notes', items: [{ title: 'Welcome', body: 'This backend was declared in one typed file.', pinned: true }] }],\n },\n});\n",
19683
- "readme": "# Notes template (starter)\n\nThe simplest possible backend \u2014 one `cms` collection (`notes`), no draft/publish.\n\n```bash\nvxil init --template notes\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **How small a backend can be** \u2014 one feature, one collection, three fields; every config leaf omitted here\n falls back to a validated server-side default.\n- **`draftPublish: false`** \u2014 no editorial lifecycle: writes are live immediately (compare `templates/blog`).\n- **Slot only what you query** \u2014 `title` (`s1`) is slot-bound for search and sort; `body` (`text`) and `pinned`\n (`bool`) take no slot (`text`/`bool`/`json` aren't slottable) \u2014 equality on an unslotted field still works,\n as a bounded scan; mark a field `indexed: true` (up to 4 per collection) to make its `=` / `$in` filters\n index-served (vxil.com/docs/guide/04-data-with-cms#indexed-fields-fast-equality-without-a-slot).\n\n```ts\nawait vx.from('notes').create({ title: 'Meeting notes', body: 'Decisions\u2026', pinned: true });\n\n// $contains is a substring match \u2014 a bounded scan of the collection (pair it with an indexed term to narrow it)\nconst { items } = await vx.from('notes').query({\n filter: { title: { $contains: 'meeting' } }, limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/guide/05-typed-sdk-and-cli \xB7\n`templates/tasks/` (the default scaffold) \xB7 `templates/research-library/` (notes grown up).\n\nOwn the shape \u2014 nothing is locked.\n",
19778
+ "readme": '# Notes template (starter)\n\nThe simplest possible backend \u2014 one `cms` collection (`notes`), no draft/publish.\n\n```bash\nmkdir my-notes && cd my-notes\nvxil init --template notes\nvxil quickstart --env staging --invite <code> # --invite only for a new e-mail; pushes the config + writes vxil.types.ts\nvxil seed # one note, "Welcome"\nvxil api GET /v1/cms/items/notes # \u2192 { "items": [ { "item_id": "\u2026", "data": { "title": "Welcome", \u2026 } } ], \u2026 }\n```\n\nThere is no draft step here (`draftPublish: false`), so the seeded note is live at once. After you edit\n`vxil.config.ts`, `vxil push` applies it and regenerates the types.\n\n**What to learn from this:**\n- **How small a backend can be** \u2014 one feature, one collection, three fields; every config leaf omitted here\n falls back to a validated server-side default.\n- **`draftPublish: false`** \u2014 no editorial lifecycle: writes are live immediately (compare the **blog** blueprint).\n- **Slot only what you query** \u2014 `title` (`s1`) is slot-bound for search and sort; `body` (`text`) and `pinned`\n (`bool`) take no slot (`text`/`bool`/`json` aren\'t slottable) \u2014 equality on an unslotted field still works,\n as a bounded scan; mark a field `indexed: true` (up to 4 per collection) to make its `=` / `$in` filters\n index-served (vxil.com/docs/guide/04-data-with-cms#indexed-fields-fast-equality-without-a-slot).\n\n```ts\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }) \u2014 the key from `vxil env pull`\nawait vx.from(\'notes\').create({ title: \'Meeting notes\', body: \'Decisions\u2026\', pinned: true });\n\n// $contains is a substring match \u2014 a bounded scan of the collection (pair it with an indexed term to narrow it)\nconst { items } = await vx.from(\'notes\').query({\n filter: { title: { $contains: \'meeting\' } }, limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/guide/05-typed-sdk-and-cli \xB7\nthe **tasks** blueprint (the default scaffold) \xB7 the **research-library** blueprint (notes grown up).\n\nOwn the shape \u2014 nothing is locked.\n',
19684
19779
  "functions": {}
19685
19780
  }
19686
19781
  ];
@@ -21455,10 +21550,19 @@ async function runApply(apply, sel = "prod", opts = {}) {
21455
21550
  report.errors++;
21456
21551
  }
21457
21552
  };
21553
+ let cmsApi = api;
21554
+ if (apply) {
21555
+ const enabledAt = await enableCmsBeforeSchema({ api, cfg });
21556
+ if (enabledAt !== null) {
21557
+ console.log(`
21558
+ cms: enabled (v${enabledAt}) before its schema \u2014 the declared cms config follows in the config step`);
21559
+ cmsApi = retryWhileCmsPropagates(api, { onWait: (line) => console.log(line) });
21560
+ }
21561
+ }
21458
21562
  await runSection("cms.schema", async () => {
21459
21563
  if (!cfg.cms?.collections || !Object.keys(cfg.cms.collections).length) return;
21460
21564
  const r = await planCmsSchema({
21461
- api,
21565
+ api: cmsApi,
21462
21566
  collections: cfg.cms.collections,
21463
21567
  apply,
21464
21568
  allowDestructive: hasFlag("allow-destructive"),
@@ -21873,15 +21977,11 @@ ${formatTriggerWiring(w).join("\n")}`);
21873
21977
  say("\napplying server-side\u2026");
21874
21978
  }
21875
21979
  });
21876
- if (done.status === "running") {
21877
- const errored = done.steps.filter((st) => st.status === "pending" && st.error);
21878
- const onlyDeferredApiState = errored.length > 0 && errored.every((st) => st.kind === "api-state" && (st.error ?? "").startsWith("deferred:"));
21879
- if (onlyDeferredApiState) {
21880
- report(done, false);
21881
- say(" \u2026 declared API state deferred (the feature config is still propagating) \u2014 waiting 30 s, then resuming the same apply");
21882
- await new Promise((r) => setTimeout(r, 3e4));
21883
- done = await serverApply({ api, resume: done.apply_id });
21884
- }
21980
+ for (let attempt = 1; done.status === "running" && onlyPropagationDeferred(done.steps) && attempt <= CMS_PROPAGATION_ATTEMPTS; attempt++) {
21981
+ if (attempt === 1) report(done, false);
21982
+ say(` \u2026 deferred: the feature config is still propagating \u2014 waiting ${CMS_PROPAGATION_DELAY_MS / 1e3} s, then resuming the same apply (${attempt}/${CMS_PROPAGATION_ATTEMPTS})`);
21983
+ await new Promise((r) => setTimeout(r, CMS_PROPAGATION_DELAY_MS));
21984
+ done = await serverApply({ api, resume: done.apply_id });
21885
21985
  }
21886
21986
  report(done, true);
21887
21987
  const problems = { hard: [], strict: [] };
@@ -22142,6 +22242,16 @@ async function runDoctor() {
22142
22242
  ));
22143
22243
  }
22144
22244
  }
22245
+ if (live.has("vector-search")) {
22246
+ const vres = await api("GET", "/v1/config/vector-search");
22247
+ if (vres.status === 200) {
22248
+ const cres = await api("GET", "/v1/search/collections");
22249
+ checks.push(...embedProviderChecks(
22250
+ vres.body.data?.manifest,
22251
+ cres.status === 200 ? cres.body.data?.collections ?? [] : null
22252
+ ));
22253
+ }
22254
+ }
22145
22255
  if (live.has("payments")) {
22146
22256
  const pres = await api("GET", "/v1/config/payments");
22147
22257
  if (pres.status === 200) {
@@ -22279,11 +22389,19 @@ function scaffoldProject(configSrc, fns) {
22279
22389
  const gi = resolve7(cwd, ".gitignore");
22280
22390
  const ignore = ["", "# vxil", ".vxil/", ".env.local", ""].join("\n");
22281
22391
  if (!existsSync8(gi) || !readFileSync8(gi, "utf8").includes(".vxil/")) appendFileSync2(gi, ignore);
22282
- const pkg = ensureScaffoldPackageJson(cwd, { sdkTypes: entries.some(([, src]) => src.includes("from '@vxil/sdk'")) });
22392
+ const freshFolder = !existsSync8(resolve7(cwd, "package.json"));
22393
+ const tsconfig = freshFolder ? ensureScaffoldTsconfig(cwd) : existsSync8(resolve7(cwd, "tsconfig.json")) ? "exists" : "suggested";
22394
+ const pkg = ensureScaffoldPackageJson(cwd, {
22395
+ sdkTypes: entries.some(([, src]) => src.includes("from '@vxil/sdk'")),
22396
+ nodeTypes: tsconfig === "created"
22397
+ });
22283
22398
  if (pkg.action === "skipped") console.log(` ! package.json not patched: ${pkg.reason}`);
22284
- return pkg;
22399
+ if (tsconfig === "suggested") {
22400
+ console.log(' (no tsconfig.json written into an existing app \u2014 to type-check vxil.config.ts / functions, use moduleResolution "bundler" and add @types/node)');
22401
+ }
22402
+ return { ...pkg, tsconfig };
22285
22403
  }
22286
- function initProject() {
22404
+ function initProject(opts = {}) {
22287
22405
  const cwd = process.cwd();
22288
22406
  const cfgPath = resolve7(cwd, "vxil.config.ts");
22289
22407
  if (existsSync8(cfgPath) && !hasFlag("force")) fail("vxil.config.ts already exists (use --force to overwrite)");
@@ -22302,8 +22420,10 @@ function initProject() {
22302
22420
  const sdk = Object.values(tpl.functions ?? {}).some((src) => src.includes("from '@vxil/sdk'"));
22303
22421
  console.log(` package.json # declares @vxil/config (the config import)${sdk ? " + @vxil/sdk (the functions' envelope types)" : ""}`);
22304
22422
  }
22423
+ if (pkgRes.tsconfig === "created") console.log(' tsconfig.json # minimal: moduleResolution bundler, types ["node"]');
22424
+ if (opts.fromQuickstart) return;
22305
22425
  console.log("\nnext: `vxil quickstart` (new tenant) or `vxil link <slug>` (existing), then `vxil push`.");
22306
- console.log(" (`npm install` is optional \u2014 it gives your editor the types; the CLI runs vxil.config.ts and bundles functions without it.)");
22426
+ console.log(" (the CLI runs vxil.config.ts and bundles functions without `npm install`; run it to type-check \u2014 tsc and your editor need the installed types.)");
22307
22427
  }
22308
22428
  function listTemplates() {
22309
22429
  console.log("vxil templates \u2014 the Blueprint Gallery. Scaffold one with `vxil init --template <id>`.\n");
@@ -22381,7 +22501,7 @@ try {
22381
22501
  saveProject(hasFlag("dev") ? { ...merged, dev: { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url } } : merged);
22382
22502
  const envNotice = quickstartEnvNotice(flag("env"), base2, d.app_slug, { dev: hasFlag("dev") });
22383
22503
  if (envNotice) console.error(`vxil: ${envNotice}`);
22384
- if (!existsSync8(resolve7(process.cwd(), "vxil.config.ts"))) initProject();
22504
+ if (!existsSync8(resolve7(process.cwd(), "vxil.config.ts"))) initProject({ fromQuickstart: true });
22385
22505
  console.log(`
22386
22506
  \u2713 tenant '${d.app_slug}' created \xB7 enabled: ${(d.enabled ?? []).join(", ")}`);
22387
22507
  if (d.expires_at) console.log(` dev tenant \u2014 expires ${d.expires_at} (the nightly reaper tears it down; extend by recreating)`);
@@ -22411,11 +22531,12 @@ try {
22411
22531
  console.log(` types: ./vxil.types.ts (generated \u2014 re-run \`vxil gen\` after a schema change)`);
22412
22532
  console.log(` try it:`);
22413
22533
  console.log(` import { Vxil } from '@vxil/sdk';`);
22414
- console.log(` import type { VxilSchema } from './vxil.types';`);
22415
- console.log(` const vx = Vxil.connect<VxilSchema>({ apiKey }); // narrows to YOUR enabled features`);
22534
+ console.log(` import type { VxilSchema } from './vxil.types.js';`);
22535
+ console.log(` const vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }); // narrows to YOUR enabled features`);
22416
22536
  console.log(` await vx.from('${firstCollection}').create({ /* field-typed */ });`);
22417
22537
  console.log(`
22418
22538
  key stored in ~/.vxil/credentials.json \xB7 project bound in .vxil/project.json`);
22539
+ console.log(" next: `vxil env pull` writes VXIL_API_KEY (+ VXIL_BASE_URL, VXIL_TENANT_ID) to .env.local for the snippet above");
22419
22540
  break;
22420
22541
  }
22421
22542
  case "try": {