@vxil/cli 0.17.0 → 0.19.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
|
@@ -2656,6 +2656,24 @@ __export(type_exports3, {
|
|
|
2656
2656
|
var Type = type_exports3;
|
|
2657
2657
|
|
|
2658
2658
|
// ../feature-configs/src/hooks.ts
|
|
2659
|
+
var HOOK_LIMITS = {
|
|
2660
|
+
maxSourceLen: 2e3,
|
|
2661
|
+
maxTokens: 600,
|
|
2662
|
+
maxNodes: 250,
|
|
2663
|
+
maxDepth: 40,
|
|
2664
|
+
maxEvalSteps: 5e3,
|
|
2665
|
+
maxStringLen: 4096,
|
|
2666
|
+
maxNumberMagnitude: 1e15,
|
|
2667
|
+
// READ-path bounds (runReadHooks). The list path evaluates hooks PER ROW over
|
|
2668
|
+
// a query page (≤500 rows), so the per-request cost is bounded by ONE shared
|
|
2669
|
+
// eval-step budget across the whole page — not per expression. Typical hooks
|
|
2670
|
+
// cost tens of steps/row, so 250k covers a max page with the full 10-hook
|
|
2671
|
+
// complement many times over; a pathological page (maximal expressions ×
|
|
2672
|
+
// maximal rows × maximal hooks) exhausts it and the request fails a clean
|
|
2673
|
+
// 422 hook_error rather than pinning the isolate's CPU.
|
|
2674
|
+
maxReadEvalStepsPerRequest: 25e4,
|
|
2675
|
+
maxReadHooksPerCollection: 10
|
|
2676
|
+
};
|
|
2659
2677
|
var HOOK_ROOT_VARS = ["item", "before", "now", "caller"];
|
|
2660
2678
|
var HOOK_FUNCTIONS = [
|
|
2661
2679
|
// math
|
|
@@ -2693,6 +2711,451 @@ var HOOK_FUNCTIONS = [
|
|
|
2693
2711
|
];
|
|
2694
2712
|
var FN_SET = new Set(HOOK_FUNCTIONS);
|
|
2695
2713
|
var ROOT_SET = new Set(HOOK_ROOT_VARS);
|
|
2714
|
+
var FORBIDDEN_PROPS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
2715
|
+
var PROP_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
2716
|
+
var HookParseError = class extends Error {
|
|
2717
|
+
};
|
|
2718
|
+
var PUNCT = ["||", "&&", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%", "!", "(", ")", ",", ".", "?", ":"];
|
|
2719
|
+
function tokenize(src) {
|
|
2720
|
+
if (src.length > HOOK_LIMITS.maxSourceLen) {
|
|
2721
|
+
throw new HookParseError(`expression exceeds ${HOOK_LIMITS.maxSourceLen} chars`);
|
|
2722
|
+
}
|
|
2723
|
+
const toks = [];
|
|
2724
|
+
let i = 0;
|
|
2725
|
+
const n = src.length;
|
|
2726
|
+
while (i < n) {
|
|
2727
|
+
const c = src[i];
|
|
2728
|
+
if (c === " " || c === " " || c === "\n" || c === "\r") {
|
|
2729
|
+
i++;
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
2732
|
+
if (c === '"' || c === "'") {
|
|
2733
|
+
const quote = c;
|
|
2734
|
+
let s = "";
|
|
2735
|
+
i++;
|
|
2736
|
+
while (i < n && src[i] !== quote) {
|
|
2737
|
+
let ch = src[i];
|
|
2738
|
+
if (ch === "\\") {
|
|
2739
|
+
const e = src[i + 1];
|
|
2740
|
+
if (e === "n") ch = "\n";
|
|
2741
|
+
else if (e === "t") ch = " ";
|
|
2742
|
+
else if (e === "\\" || e === '"' || e === "'") ch = e;
|
|
2743
|
+
else throw new HookParseError(`bad string escape \\${e ?? ""}`);
|
|
2744
|
+
i += 2;
|
|
2745
|
+
} else {
|
|
2746
|
+
i++;
|
|
2747
|
+
}
|
|
2748
|
+
s += ch;
|
|
2749
|
+
if (s.length > HOOK_LIMITS.maxStringLen) throw new HookParseError("string literal too long");
|
|
2750
|
+
}
|
|
2751
|
+
if (i >= n) throw new HookParseError("unterminated string");
|
|
2752
|
+
i++;
|
|
2753
|
+
toks.push({ k: "str", v: s });
|
|
2754
|
+
} else if (c >= "0" && c <= "9") {
|
|
2755
|
+
let j = i;
|
|
2756
|
+
while (j < n && /[0-9]/.test(src[j])) j++;
|
|
2757
|
+
if (src[j] === ".") {
|
|
2758
|
+
j++;
|
|
2759
|
+
while (j < n && /[0-9]/.test(src[j])) j++;
|
|
2760
|
+
}
|
|
2761
|
+
const num2 = Number(src.slice(i, j));
|
|
2762
|
+
if (!Number.isFinite(num2)) throw new HookParseError(`bad number near ${src.slice(i, j)}`);
|
|
2763
|
+
toks.push({ k: "num", v: num2 });
|
|
2764
|
+
i = j;
|
|
2765
|
+
} else if (/[A-Za-z_]/.test(c)) {
|
|
2766
|
+
let j = i;
|
|
2767
|
+
while (j < n && /[A-Za-z0-9_]/.test(src[j])) j++;
|
|
2768
|
+
toks.push({ k: "name", v: src.slice(i, j) });
|
|
2769
|
+
i = j;
|
|
2770
|
+
} else {
|
|
2771
|
+
const two = src.slice(i, i + 2);
|
|
2772
|
+
const one = c;
|
|
2773
|
+
const m = PUNCT.includes(two) ? two : PUNCT.includes(one) ? one : null;
|
|
2774
|
+
if (!m) throw new HookParseError(`unexpected character '${c}'`);
|
|
2775
|
+
toks.push({ k: "op", v: m });
|
|
2776
|
+
i += m.length;
|
|
2777
|
+
}
|
|
2778
|
+
if (toks.length > HOOK_LIMITS.maxTokens) throw new HookParseError("expression too long (token cap)");
|
|
2779
|
+
}
|
|
2780
|
+
toks.push({ k: "eof" });
|
|
2781
|
+
return toks;
|
|
2782
|
+
}
|
|
2783
|
+
var BIN_PREC = {
|
|
2784
|
+
"||": 1,
|
|
2785
|
+
"&&": 2,
|
|
2786
|
+
"==": 3,
|
|
2787
|
+
"!=": 3,
|
|
2788
|
+
"<": 4,
|
|
2789
|
+
"<=": 4,
|
|
2790
|
+
">": 4,
|
|
2791
|
+
">=": 4,
|
|
2792
|
+
"+": 5,
|
|
2793
|
+
"-": 5,
|
|
2794
|
+
"*": 6,
|
|
2795
|
+
"/": 6,
|
|
2796
|
+
"%": 6
|
|
2797
|
+
};
|
|
2798
|
+
var Parser = class {
|
|
2799
|
+
constructor(toks) {
|
|
2800
|
+
this.toks = toks;
|
|
2801
|
+
}
|
|
2802
|
+
toks;
|
|
2803
|
+
p = 0;
|
|
2804
|
+
nodes = 0;
|
|
2805
|
+
peek() {
|
|
2806
|
+
return this.toks[this.p];
|
|
2807
|
+
}
|
|
2808
|
+
next() {
|
|
2809
|
+
return this.toks[this.p++];
|
|
2810
|
+
}
|
|
2811
|
+
isOp(v) {
|
|
2812
|
+
const t = this.peek();
|
|
2813
|
+
return t.k === "op" && t.v === v;
|
|
2814
|
+
}
|
|
2815
|
+
eatOp(v) {
|
|
2816
|
+
if (!this.isOp(v)) throw new HookParseError(`expected '${v}'`);
|
|
2817
|
+
this.p++;
|
|
2818
|
+
}
|
|
2819
|
+
node(nd) {
|
|
2820
|
+
if (++this.nodes > HOOK_LIMITS.maxNodes) throw new HookParseError("expression too complex (node cap)");
|
|
2821
|
+
return nd;
|
|
2822
|
+
}
|
|
2823
|
+
parse() {
|
|
2824
|
+
const e = this.ternary();
|
|
2825
|
+
if (this.peek().k !== "eof") throw new HookParseError("trailing tokens after expression");
|
|
2826
|
+
return e;
|
|
2827
|
+
}
|
|
2828
|
+
ternary() {
|
|
2829
|
+
const c = this.binary(0);
|
|
2830
|
+
if (this.isOp("?")) {
|
|
2831
|
+
this.next();
|
|
2832
|
+
const a = this.ternary();
|
|
2833
|
+
this.eatOp(":");
|
|
2834
|
+
const b2 = this.ternary();
|
|
2835
|
+
return this.node({ t: "tern", c, a, b: b2 });
|
|
2836
|
+
}
|
|
2837
|
+
return c;
|
|
2838
|
+
}
|
|
2839
|
+
binary(minPrec) {
|
|
2840
|
+
let left = this.unary();
|
|
2841
|
+
for (; ; ) {
|
|
2842
|
+
const t = this.peek();
|
|
2843
|
+
if (t.k !== "op") break;
|
|
2844
|
+
const prec = BIN_PREC[t.v];
|
|
2845
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
2846
|
+
this.next();
|
|
2847
|
+
const right = this.binary(prec + 1);
|
|
2848
|
+
left = this.node({ t: "bin", op: t.v, l: left, r: right });
|
|
2849
|
+
}
|
|
2850
|
+
return left;
|
|
2851
|
+
}
|
|
2852
|
+
unary() {
|
|
2853
|
+
if (this.isOp("!")) {
|
|
2854
|
+
this.next();
|
|
2855
|
+
return this.node({ t: "unary", op: "!", arg: this.unary() });
|
|
2856
|
+
}
|
|
2857
|
+
if (this.isOp("-")) {
|
|
2858
|
+
this.next();
|
|
2859
|
+
return this.node({ t: "unary", op: "-", arg: this.unary() });
|
|
2860
|
+
}
|
|
2861
|
+
return this.postfix();
|
|
2862
|
+
}
|
|
2863
|
+
postfix() {
|
|
2864
|
+
let e = this.primary();
|
|
2865
|
+
for (; ; ) {
|
|
2866
|
+
if (this.isOp(".")) {
|
|
2867
|
+
this.next();
|
|
2868
|
+
const t = this.next();
|
|
2869
|
+
if (t.k !== "name") throw new HookParseError("expected property name after .");
|
|
2870
|
+
if (!PROP_RE.test(t.v) || FORBIDDEN_PROPS.has(t.v)) {
|
|
2871
|
+
throw new HookParseError(`forbidden property '${t.v}'`);
|
|
2872
|
+
}
|
|
2873
|
+
e = this.node({ t: "member", obj: e, prop: t.v });
|
|
2874
|
+
} else break;
|
|
2875
|
+
}
|
|
2876
|
+
return e;
|
|
2877
|
+
}
|
|
2878
|
+
primary() {
|
|
2879
|
+
const t = this.next();
|
|
2880
|
+
if (t.k === "num") return this.node({ t: "num", v: t.v });
|
|
2881
|
+
if (t.k === "str") return this.node({ t: "str", v: t.v });
|
|
2882
|
+
if (t.k === "op" && t.v === "(") {
|
|
2883
|
+
const e = this.ternary();
|
|
2884
|
+
this.eatOp(")");
|
|
2885
|
+
return e;
|
|
2886
|
+
}
|
|
2887
|
+
if (t.k === "name") {
|
|
2888
|
+
if (t.v === "true") return this.node({ t: "bool", v: true });
|
|
2889
|
+
if (t.v === "false") return this.node({ t: "bool", v: false });
|
|
2890
|
+
if (t.v === "null") return this.node({ t: "null" });
|
|
2891
|
+
if (this.isOp("(")) {
|
|
2892
|
+
this.next();
|
|
2893
|
+
const args = [];
|
|
2894
|
+
if (!this.isOp(")")) {
|
|
2895
|
+
args.push(this.ternary());
|
|
2896
|
+
while (this.isOp(",")) {
|
|
2897
|
+
this.next();
|
|
2898
|
+
args.push(this.ternary());
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
this.eatOp(")");
|
|
2902
|
+
return this.node({ t: "call", fn: t.v, args });
|
|
2903
|
+
}
|
|
2904
|
+
return this.node({ t: "var", name: t.v });
|
|
2905
|
+
}
|
|
2906
|
+
throw new HookParseError("unexpected token");
|
|
2907
|
+
}
|
|
2908
|
+
};
|
|
2909
|
+
function parseExpr(src) {
|
|
2910
|
+
return new Parser(tokenize(src)).parse();
|
|
2911
|
+
}
|
|
2912
|
+
function validateAst(root) {
|
|
2913
|
+
const errors = [];
|
|
2914
|
+
const walk = (nd, depth) => {
|
|
2915
|
+
if (depth > HOOK_LIMITS.maxDepth) {
|
|
2916
|
+
errors.push("expression nests too deeply");
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
switch (nd.t) {
|
|
2920
|
+
case "num":
|
|
2921
|
+
if (!Number.isFinite(nd.v) || Math.abs(nd.v) > HOOK_LIMITS.maxNumberMagnitude) errors.push("numeric literal out of range");
|
|
2922
|
+
return;
|
|
2923
|
+
case "str":
|
|
2924
|
+
if (nd.v.length > HOOK_LIMITS.maxStringLen) errors.push("string literal too long");
|
|
2925
|
+
return;
|
|
2926
|
+
case "bool":
|
|
2927
|
+
case "null":
|
|
2928
|
+
return;
|
|
2929
|
+
case "var":
|
|
2930
|
+
if (!ROOT_SET.has(nd.name)) errors.push(`unknown variable '${nd.name}' (allowed: ${HOOK_ROOT_VARS.join(", ")})`);
|
|
2931
|
+
return;
|
|
2932
|
+
case "member":
|
|
2933
|
+
if (FORBIDDEN_PROPS.has(nd.prop) || !PROP_RE.test(nd.prop)) errors.push(`forbidden property '${nd.prop}'`);
|
|
2934
|
+
walk(nd.obj, depth + 1);
|
|
2935
|
+
return;
|
|
2936
|
+
case "unary":
|
|
2937
|
+
walk(nd.arg, depth + 1);
|
|
2938
|
+
return;
|
|
2939
|
+
case "bin":
|
|
2940
|
+
walk(nd.l, depth + 1);
|
|
2941
|
+
walk(nd.r, depth + 1);
|
|
2942
|
+
return;
|
|
2943
|
+
case "tern":
|
|
2944
|
+
walk(nd.c, depth + 1);
|
|
2945
|
+
walk(nd.a, depth + 1);
|
|
2946
|
+
walk(nd.b, depth + 1);
|
|
2947
|
+
return;
|
|
2948
|
+
case "call":
|
|
2949
|
+
if (!FN_SET.has(nd.fn)) errors.push(`unknown function '${nd.fn}'`);
|
|
2950
|
+
if (nd.args.length > 16) errors.push(`function '${nd.fn}' has too many arguments`);
|
|
2951
|
+
for (const a of nd.args) walk(a, depth + 1);
|
|
2952
|
+
return;
|
|
2953
|
+
default: {
|
|
2954
|
+
errors.push("unsupported expression node");
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
};
|
|
2958
|
+
walk(root, 0);
|
|
2959
|
+
return errors;
|
|
2960
|
+
}
|
|
2961
|
+
var NUM_FNS = /* @__PURE__ */ new Set(["min", "max", "abs", "round", "floor", "ceil", "sqrt", "pow", "sign", "len", "number", "daysBetween", "yearsBetween"]);
|
|
2962
|
+
var STR_FNS = /* @__PURE__ */ new Set(["lower", "upper", "trim", "substr", "concat", "string"]);
|
|
2963
|
+
var BOOL_FNS = /* @__PURE__ */ new Set(["contains", "startsWith", "endsWith", "not", "isNull", "bool"]);
|
|
2964
|
+
function inferType(nd) {
|
|
2965
|
+
switch (nd.t) {
|
|
2966
|
+
case "num":
|
|
2967
|
+
return "number";
|
|
2968
|
+
case "str":
|
|
2969
|
+
return "string";
|
|
2970
|
+
case "bool":
|
|
2971
|
+
return "boolean";
|
|
2972
|
+
case "null":
|
|
2973
|
+
return "null";
|
|
2974
|
+
case "var":
|
|
2975
|
+
return nd.name === "now" ? "string" : "unknown";
|
|
2976
|
+
case "member":
|
|
2977
|
+
return "unknown";
|
|
2978
|
+
case "unary":
|
|
2979
|
+
return nd.op === "!" ? "boolean" : "number";
|
|
2980
|
+
case "bin":
|
|
2981
|
+
return ["==", "!=", "<", "<=", ">", ">=", "&&", "||"].includes(nd.op) ? "boolean" : "number";
|
|
2982
|
+
case "tern": {
|
|
2983
|
+
const a = inferType(nd.a);
|
|
2984
|
+
const b2 = inferType(nd.b);
|
|
2985
|
+
return a === b2 ? a : "unknown";
|
|
2986
|
+
}
|
|
2987
|
+
case "call":
|
|
2988
|
+
if (NUM_FNS.has(nd.fn)) return "number";
|
|
2989
|
+
if (STR_FNS.has(nd.fn)) return "string";
|
|
2990
|
+
if (BOOL_FNS.has(nd.fn)) return "boolean";
|
|
2991
|
+
return "unknown";
|
|
2992
|
+
// coalesce / ifNull
|
|
2993
|
+
default:
|
|
2994
|
+
return "unknown";
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
var FIELD_STATIC = {
|
|
2998
|
+
string: "string",
|
|
2999
|
+
text: "string",
|
|
3000
|
+
datetime: "string",
|
|
3001
|
+
relation: "string",
|
|
3002
|
+
file: "string",
|
|
3003
|
+
int: "number",
|
|
3004
|
+
float: "number",
|
|
3005
|
+
bool: "boolean",
|
|
3006
|
+
json: "json"
|
|
3007
|
+
};
|
|
3008
|
+
var WRITE_EVENTS = /* @__PURE__ */ new Set(["beforeCreate", "beforeUpdate", "beforeWrite"]);
|
|
3009
|
+
function fieldRef(nd) {
|
|
3010
|
+
if (nd.t !== "member" || nd.obj.t !== "var") return null;
|
|
3011
|
+
if (nd.obj.name !== "item" && nd.obj.name !== "before") return null;
|
|
3012
|
+
return { root: nd.obj.name, field: nd.prop };
|
|
3013
|
+
}
|
|
3014
|
+
function staticTypeOf(nd, fields) {
|
|
3015
|
+
if (nd.t === "var" && (nd.name === "item" || nd.name === "before" || nd.name === "caller")) return "object";
|
|
3016
|
+
const ref = fieldRef(nd);
|
|
3017
|
+
if (ref) {
|
|
3018
|
+
const f = fields && Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : void 0;
|
|
3019
|
+
return f ? FIELD_STATIC[f.type] ?? "unknown" : "unknown";
|
|
3020
|
+
}
|
|
3021
|
+
return inferType(nd);
|
|
3022
|
+
}
|
|
3023
|
+
function describeOperand(nd) {
|
|
3024
|
+
const ref = fieldRef(nd);
|
|
3025
|
+
if (ref) return `${ref.root}.${ref.field}`;
|
|
3026
|
+
if (nd.t === "var") return nd.name;
|
|
3027
|
+
if (nd.t === "str") return JSON.stringify(nd.v);
|
|
3028
|
+
return "a text value";
|
|
3029
|
+
}
|
|
3030
|
+
function nanOperand(nd, fields) {
|
|
3031
|
+
if (nd.t === "var" && nd.name === "now") return nd;
|
|
3032
|
+
if (nd.t === "str") return Number.isFinite(Number(nd.v)) ? null : nd;
|
|
3033
|
+
const ref = fieldRef(nd);
|
|
3034
|
+
if (ref && fields && Object.prototype.hasOwnProperty.call(fields, ref.field) && fields[ref.field].type === "datetime") return nd;
|
|
3035
|
+
return null;
|
|
3036
|
+
}
|
|
3037
|
+
function checkHookExprTypes(root, fields, opts) {
|
|
3038
|
+
const errors = [];
|
|
3039
|
+
const warnings = [];
|
|
3040
|
+
const warned = /* @__PURE__ */ new Set();
|
|
3041
|
+
const writePath = WRITE_EVENTS.has(opts.event);
|
|
3042
|
+
const optionalOperand = (nd) => {
|
|
3043
|
+
if (!writePath || !fields) return;
|
|
3044
|
+
const ref = fieldRef(nd);
|
|
3045
|
+
if (!ref) return;
|
|
3046
|
+
const f = Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : void 0;
|
|
3047
|
+
if (!f || f.required === true || ref.root === "item" && opts.present?.has(ref.field)) return;
|
|
3048
|
+
const name = `${ref.root}.${ref.field}`;
|
|
3049
|
+
if (warned.has(name)) return;
|
|
3050
|
+
warned.add(name);
|
|
3051
|
+
warnings.push(`arithmetic on '${name}', which is optional, faults the hook whenever it is missing \u2014 write coalesce(${name}, 0)`);
|
|
3052
|
+
};
|
|
3053
|
+
const walk = (nd) => {
|
|
3054
|
+
switch (nd.t) {
|
|
3055
|
+
case "bin": {
|
|
3056
|
+
let refused = false;
|
|
3057
|
+
if (nd.op === "==" || nd.op === "!=") {
|
|
3058
|
+
const lt = staticTypeOf(nd.l, fields);
|
|
3059
|
+
const rt = staticTypeOf(nd.r, fields);
|
|
3060
|
+
if (nd.l.t !== "null" && nd.r.t !== "null") {
|
|
3061
|
+
const always = nd.op === "==" ? "always false" : "always true";
|
|
3062
|
+
const objSide = lt === "object" ? nd.l : rt === "object" ? nd.r : lt === "json" && rt === "json" ? nd.l : null;
|
|
3063
|
+
const jsonSide = lt === "json" ? nd.l : rt === "json" ? nd.r : null;
|
|
3064
|
+
if (objSide) {
|
|
3065
|
+
errors.push(`'${nd.op}' cannot compare '${describeOperand(objSide)}' (an object or json value: the comparison is ${always}) \u2014 compare a scalar inside it (e.g. ${describeOperand(objSide)}.status) or test isNull()`);
|
|
3066
|
+
} else if (jsonSide) {
|
|
3067
|
+
warnings.push(`'${nd.op}' on json field '${describeOperand(jsonSide)}' compares only when it holds a scalar \u2014 when it holds an object or array the comparison is ${always}; compare a scalar inside it (e.g. ${describeOperand(jsonSide)}.status)`);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
} else if (nd.op === "+") {
|
|
3071
|
+
const nan = nanOperand(nd.l, fields) ?? nanOperand(nd.r, fields);
|
|
3072
|
+
if (nan) {
|
|
3073
|
+
refused = true;
|
|
3074
|
+
errors.push(`'+' adds numbers only, and '${describeOperand(nan)}' is never a number \u2014 use concat() to join text`);
|
|
3075
|
+
} else {
|
|
3076
|
+
const strSide = staticTypeOf(nd.l, fields) === "string" ? nd.l : staticTypeOf(nd.r, fields) === "string" ? nd.r : null;
|
|
3077
|
+
if (strSide) {
|
|
3078
|
+
warnings.push(`'+' adds numbers only: '${describeOperand(strSide)}' is text, so the hook faults unless it holds a numeric string \u2014 use concat() to join text, or number() to add`);
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
if (!refused && (nd.op === "+" || nd.op === "-" || nd.op === "*" || nd.op === "/" || nd.op === "%")) {
|
|
3083
|
+
optionalOperand(nd.l);
|
|
3084
|
+
optionalOperand(nd.r);
|
|
3085
|
+
}
|
|
3086
|
+
walk(nd.l);
|
|
3087
|
+
walk(nd.r);
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
case "unary":
|
|
3091
|
+
if (nd.op === "-") optionalOperand(nd.arg);
|
|
3092
|
+
walk(nd.arg);
|
|
3093
|
+
return;
|
|
3094
|
+
case "tern":
|
|
3095
|
+
walk(nd.c);
|
|
3096
|
+
walk(nd.a);
|
|
3097
|
+
walk(nd.b);
|
|
3098
|
+
return;
|
|
3099
|
+
case "call":
|
|
3100
|
+
for (const a of nd.args) walk(a);
|
|
3101
|
+
return;
|
|
3102
|
+
case "member":
|
|
3103
|
+
walk(nd.obj);
|
|
3104
|
+
return;
|
|
3105
|
+
default:
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
};
|
|
3109
|
+
walk(root);
|
|
3110
|
+
return { errors, warnings };
|
|
3111
|
+
}
|
|
3112
|
+
function hookFieldTypesOf(collections) {
|
|
3113
|
+
const out = {};
|
|
3114
|
+
if (!collections || typeof collections !== "object") return out;
|
|
3115
|
+
for (const [name, def] of Object.entries(collections)) {
|
|
3116
|
+
const fields = def?.fields;
|
|
3117
|
+
if (!fields || typeof fields !== "object") continue;
|
|
3118
|
+
const m = {};
|
|
3119
|
+
for (const [f, fd] of Object.entries(fields)) {
|
|
3120
|
+
const t = fd?.type;
|
|
3121
|
+
if (typeof t !== "string") continue;
|
|
3122
|
+
m[f] = { type: t, ...fd.required === true ? { required: true } : {} };
|
|
3123
|
+
}
|
|
3124
|
+
out[name] = m;
|
|
3125
|
+
}
|
|
3126
|
+
return out;
|
|
3127
|
+
}
|
|
3128
|
+
function checkHookTypes(hooks, fieldTypes) {
|
|
3129
|
+
const errors = [];
|
|
3130
|
+
const warnings = [];
|
|
3131
|
+
if (!hooks) return { errors, warnings };
|
|
3132
|
+
const derived = /* @__PURE__ */ new Map();
|
|
3133
|
+
for (const h of Object.values(hooks)) {
|
|
3134
|
+
if (h.kind === "derive" && h.field && WRITE_EVENTS.has(h.event) && h.enabled !== false) {
|
|
3135
|
+
if (!derived.has(h.collection)) derived.set(h.collection, /* @__PURE__ */ new Set());
|
|
3136
|
+
derived.get(h.collection).add(h.field);
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
for (const [id, h] of Object.entries(hooks)) {
|
|
3140
|
+
if (!Object.prototype.hasOwnProperty.call(fieldTypes, h.collection)) continue;
|
|
3141
|
+
let ast;
|
|
3142
|
+
try {
|
|
3143
|
+
ast = parseExpr(h.expr ?? "");
|
|
3144
|
+
} catch {
|
|
3145
|
+
continue;
|
|
3146
|
+
}
|
|
3147
|
+
if (validateAst(ast).length) continue;
|
|
3148
|
+
const r = checkHookExprTypes(ast, fieldTypes[h.collection], {
|
|
3149
|
+
kind: h.kind,
|
|
3150
|
+
event: h.event,
|
|
3151
|
+
present: h.kind === "validate" ? derived.get(h.collection) : void 0
|
|
3152
|
+
});
|
|
3153
|
+
if (h.enabled === false) for (const e of r.errors) warnings.push(`hooks.${id}.expr (disabled): ${e}`);
|
|
3154
|
+
else for (const e of r.errors) errors.push(`hooks.${id}.expr: ${e}`);
|
|
3155
|
+
for (const w of r.warnings) warnings.push(`hooks.${id}.expr: ${w}`);
|
|
3156
|
+
}
|
|
3157
|
+
return { errors, warnings };
|
|
3158
|
+
}
|
|
2696
3159
|
|
|
2697
3160
|
// ../feature-configs/src/apiState.ts
|
|
2698
3161
|
var FN_TRIGGER_TARGET_MARKERS = [
|
|
@@ -3003,10 +3466,32 @@ var NotificationsConfigSchema = Type.Object({
|
|
|
3003
3466
|
},
|
|
3004
3467
|
{ default: {} }
|
|
3005
3468
|
)),
|
|
3006
|
-
suppression
|
|
3007
|
-
|
|
3469
|
+
// `suppression` became an OPTIONAL bag (1 leaf either way — it held the one
|
|
3470
|
+
// leaf softBounceThreshold) on 2026-10-04 so the
|
|
3471
|
+
// `testRecipients` list rides inside it at zero leaf cost (the schema is AT
|
|
3472
|
+
// the cap). It KEEPS `default: {}`, so Value.Default still materializes
|
|
3473
|
+
// suppression.softBounceThreshold into every persisted manifest exactly as
|
|
3474
|
+
// before (byte-identical); only the TS type is optional (workers read via
|
|
3475
|
+
// `config.suppression?.…` with the shipped fallback).
|
|
3476
|
+
suppression: Type.Optional(Type.Object(
|
|
3477
|
+
{
|
|
3478
|
+
softBounceThreshold: Type.Integer({ default: 3 }),
|
|
3479
|
+
// TEST-RECIPIENT SINK (2026-10-04) — the auth `otp.testRecipients` grammar and
|
|
3480
|
+
// matcher (matchesTestRecipient): an exact email or a `*@domain` glob,
|
|
3481
|
+
// ≤20 (validateFeatureConfig refuses anything else — a +E.164 entry
|
|
3482
|
+
// could never match a mail recipient). A send whose recipient matches is
|
|
3483
|
+
// rendered in full and recorded as a delivery with status `test_sink`
|
|
3484
|
+
// (the render readable at GET /v1/notifications/deliveries/{id}); NO
|
|
3485
|
+
// provider is called and the send is not billed. Optional, no default:
|
|
3486
|
+
// absent = no sink. CI / store-review / QA addresses only — never a real
|
|
3487
|
+
// user's address.
|
|
3488
|
+
testRecipients: Type.Optional(Type.Array(
|
|
3489
|
+
Type.String({ minLength: 3, maxLength: 320 }),
|
|
3490
|
+
{ maxItems: 20 }
|
|
3491
|
+
))
|
|
3492
|
+
},
|
|
3008
3493
|
{ default: {} }
|
|
3009
|
-
),
|
|
3494
|
+
)),
|
|
3010
3495
|
// `rateLimit` became an OPTIONAL bag (2 leaves → 1, the `retry` trick)
|
|
3011
3496
|
// on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
|
|
3012
3497
|
// `default: {}`, so Value.Default still materializes
|
|
@@ -3220,7 +3705,16 @@ var OidcProviderSchema = Type.Object({
|
|
|
3220
3705
|
claims: Type.Optional(Type.Object({
|
|
3221
3706
|
email: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
3222
3707
|
name: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
3223
|
-
roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 }))
|
|
3708
|
+
roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
3709
|
+
// G-38b: an optional namespace prepended to every mapped IdP role (e.g.
|
|
3710
|
+
// `idp-`), so an IdP group named like an app role (`finance`, `owner`)
|
|
3711
|
+
// arrives as `idp-finance` and cannot pass a `readRoles: ['finance']`
|
|
3712
|
+
// gate. Absent = the raw group names (the shipped behaviour). The prefixed
|
|
3713
|
+
// role still obeys the 32-char role bound (longer ones are dropped).
|
|
3714
|
+
// The alphabet is the cms readRoles / orgs role slug alphabet
|
|
3715
|
+
// (`^[a-z0-9][a-z0-9_-]*$`, cms-v1 READ_ROLE_RE), so a prefixed role can
|
|
3716
|
+
// itself be named in a gate — a `:`/`.`/uppercase prefix never could.
|
|
3717
|
+
rolePrefix: Type.Optional(Type.String({ minLength: 1, maxLength: 16, pattern: "^[a-z0-9][a-z0-9_-]{0,15}$" }))
|
|
3224
3718
|
})),
|
|
3225
3719
|
// when non-empty, the asserted email's domain MUST be listed (fail-closed:
|
|
3226
3720
|
// no email ⇒ refused) → 403 oidc_domain_not_allowed; a listed domain also
|
|
@@ -3410,6 +3904,26 @@ var AuthConfigSchema = Type.Object({
|
|
|
3410
3904
|
allowedRedirectOrigins: Type.Optional(Type.Array(
|
|
3411
3905
|
Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }),
|
|
3412
3906
|
{ maxItems: 32 }
|
|
3907
|
+
)),
|
|
3908
|
+
// G-3 sign-up gating (2026-10-05). Both Optional with NO default, so an
|
|
3909
|
+
// existing manifest folds byte-identically and the gate is opt-in. When
|
|
3910
|
+
// EITHER is set, every self-service account-create path (password sign-up,
|
|
3911
|
+
// magic-link / OTP pre-register, the guest → email claim, social sign-in
|
|
3912
|
+
// with google/github/apple/facebook) is checked, fail-closed:
|
|
3913
|
+
// • inviteOnly — a new account only for an address the tenant already
|
|
3914
|
+
// put in its user registry server-side (POST /v1/users, an import);
|
|
3915
|
+
// • allowedEmailDomains — otherwise, only for an address at one of these
|
|
3916
|
+
// domains (exact, lowercase; a provider must assert the address
|
|
3917
|
+
// VERIFIED).
|
|
3918
|
+
// With either set, password sign-up is refused (it proves nothing about
|
|
3919
|
+
// the address — a stranger could squat an invitee's address), guest
|
|
3920
|
+
// sign-in is refused (a guest is nobody's invitee), and an existing
|
|
3921
|
+
// account is never affected. The generic OIDC issuer keeps its own gate
|
|
3922
|
+
// (providers.oidc.allowedDomains + IdP assignment). auth-v1 signupGate.ts.
|
|
3923
|
+
inviteOnly: Type.Optional(Type.Boolean()),
|
|
3924
|
+
allowedEmailDomains: Type.Optional(Type.Array(
|
|
3925
|
+
Type.String({ minLength: 3, maxLength: 253, pattern: "^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$" }),
|
|
3926
|
+
{ maxItems: 32 }
|
|
3413
3927
|
))
|
|
3414
3928
|
}))
|
|
3415
3929
|
});
|
|
@@ -3575,7 +4089,9 @@ var CmsConfigSchema = Type.Object({
|
|
|
3575
4089
|
maxCollections: Type.Integer({ default: 25, minimum: 1, maximum: 200 }),
|
|
3576
4090
|
maxFieldsPerCollection: Type.Integer({ default: 50, minimum: 1, maximum: 200 }),
|
|
3577
4091
|
maxItemBytes: Type.Integer({ default: 256 * 1024, minimum: 1024, maximum: 1024 * 1024 }),
|
|
3578
|
-
|
|
4092
|
+
// Capped at 10 M: every create counts the collection's live rows, so
|
|
4093
|
+
// the cap also bounds what each create pays.
|
|
4094
|
+
maxItemsPerCollection: Type.Integer({ default: 1e5, minimum: 1, maximum: 1e7 })
|
|
3579
4095
|
},
|
|
3580
4096
|
{ default: {} }
|
|
3581
4097
|
),
|
|
@@ -4007,6 +4523,12 @@ var RagConfigSchema = Type.Object({
|
|
|
4007
4523
|
),
|
|
4008
4524
|
// drop retrieved chunks below this score BEFORE budgeting (0 = keep all).
|
|
4009
4525
|
minScore: Type.Number({ default: 0 }),
|
|
4526
|
+
// drop retrieved chunks whose cosine `similarity` to the query is
|
|
4527
|
+
// below this floor (-1..1). Unlike minScore (a floor on the RRF rank
|
|
4528
|
+
// value, ~0.016-0.033 at the top) this is the relevance threshold a RAG
|
|
4529
|
+
// app means. Absent = no floor; a hit with no measured similarity (no
|
|
4530
|
+
// query vector: keyword mode) is never dropped by it.
|
|
4531
|
+
minSimilarity: Type.Optional(Type.Number({ minimum: -1, maximum: 1 })),
|
|
4010
4532
|
// forward rerank:true to vector-search's post-RRF BYO rerank pass (it owns
|
|
4011
4533
|
// the provider + key); inert until that pass exists/is configured there.
|
|
4012
4534
|
rerank: Type.Boolean({ default: false })
|
|
@@ -4543,9 +5065,9 @@ function lowerAllTriggerBindings(def, name) {
|
|
|
4543
5065
|
return bindings;
|
|
4544
5066
|
}
|
|
4545
5067
|
var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
|
|
4546
|
-
var VXIL_CONFIG_PKG_VERSION = "0.
|
|
4547
|
-
var VXIL_SDK_PKG_VERSION = "0.
|
|
4548
|
-
var VXIL_CLI_PKG_VERSION = "0.
|
|
5068
|
+
var VXIL_CONFIG_PKG_VERSION = "0.13.0";
|
|
5069
|
+
var VXIL_SDK_PKG_VERSION = "0.19.0";
|
|
5070
|
+
var VXIL_CLI_PKG_VERSION = "0.19.0";
|
|
4549
5071
|
function ensureScaffoldPackageJson(cwd, opts = {}) {
|
|
4550
5072
|
const file = resolve(cwd, "package.json");
|
|
4551
5073
|
const wanted = {
|
|
@@ -5197,7 +5719,7 @@ function handleValue(x, parameters, types2, options) {
|
|
|
5197
5719
|
throw Errors.generic("UNDEFINED_VALUE", "Undefined values are not allowed");
|
|
5198
5720
|
}
|
|
5199
5721
|
return "$" + types2.push(
|
|
5200
|
-
x instanceof Parameter ? (parameters.push(x.value), x.array ? x.array[x.type ||
|
|
5722
|
+
x instanceof Parameter ? (parameters.push(x.value), x.array ? x.array[x.type || inferType2(x.value)] || x.type || firstIsString(x.value) : x.type) : (parameters.push(x), inferType2(x))
|
|
5201
5723
|
);
|
|
5202
5724
|
}
|
|
5203
5725
|
var defaultHandlers = typeHandlers(types);
|
|
@@ -5291,8 +5813,8 @@ function escapeIdentifiers(xs, { transform: { column } }) {
|
|
|
5291
5813
|
var escapeIdentifier = function escape(str2) {
|
|
5292
5814
|
return '"' + str2.replace(/"/g, '""').replace(/\./g, '"."') + '"';
|
|
5293
5815
|
};
|
|
5294
|
-
var
|
|
5295
|
-
return x instanceof Parameter ? x.type : x instanceof Date ? 1184 : x instanceof Uint8Array ? 17 : x === true || x === false ? 16 : typeof x === "bigint" ? 20 : Array.isArray(x) ?
|
|
5816
|
+
var inferType2 = function inferType3(x) {
|
|
5817
|
+
return x instanceof Parameter ? x.type : x instanceof Date ? 1184 : x instanceof Uint8Array ? 17 : x === true || x === false ? 16 : typeof x === "bigint" ? 20 : Array.isArray(x) ? inferType3(x[0]) : 0;
|
|
5296
5818
|
};
|
|
5297
5819
|
var escapeBackslash = /\\/g;
|
|
5298
5820
|
var escapeQuote = /"/g;
|
|
@@ -6861,7 +7383,7 @@ function Postgres(a, b2) {
|
|
|
6861
7383
|
function array(x, type) {
|
|
6862
7384
|
if (!Array.isArray(x))
|
|
6863
7385
|
return array(Array.from(arguments));
|
|
6864
|
-
return new Parameter(x, type || (x.length ?
|
|
7386
|
+
return new Parameter(x, type || (x.length ? inferType2(x) || 25 : 0), options.shared.typeArrayMap);
|
|
6865
7387
|
}
|
|
6866
7388
|
function handler(query) {
|
|
6867
7389
|
if (ending)
|
|
@@ -7130,6 +7652,15 @@ var KNOWN_SCOPES = [
|
|
|
7130
7652
|
// so pre-existing keys keep working; mint this when a key should see usage
|
|
7131
7653
|
// and nothing else.
|
|
7132
7654
|
"usage:read",
|
|
7655
|
+
// Narrow READ-ONLY scope for the audit stream (G-15, 2026-10-05): GET
|
|
7656
|
+
// /v1/audit and GET /v1/audit/export accept it alongside 'features:read'
|
|
7657
|
+
// (any-of), so an ops/admin function or a dedicated key reads the audit log
|
|
7658
|
+
// WITHOUT holding features:read — which is also the door of
|
|
7659
|
+
// GET /v1/export/:feature (a whole-project export credential). Both routes
|
|
7660
|
+
// stay server-only; a public / thin-client key can never carry it
|
|
7661
|
+
// (PUBLIC_KEY_DENY_SCOPES), and a function receives it on its confined
|
|
7662
|
+
// control-plane callback token (CONTROL_PLANE_FUNCTION_SCOPES).
|
|
7663
|
+
"audit:read",
|
|
7133
7664
|
"admin"
|
|
7134
7665
|
];
|
|
7135
7666
|
var FUNCTION_SCOPE_AUDIENCE = {
|
|
@@ -7158,7 +7689,14 @@ var FUNCTION_SCOPE_AUDIENCE = {
|
|
|
7158
7689
|
// CONTROL_PLANE_FUNCTION_SCOPES (scopesForFunctionAudience) so the same token
|
|
7159
7690
|
// can never satisfy any other scope-gated control-plane route.
|
|
7160
7691
|
users: "control-plane",
|
|
7161
|
-
usage: "control-plane"
|
|
7692
|
+
usage: "control-plane",
|
|
7693
|
+
// G-15: the audit stream (/v1/audit, /v1/audit/export) is a control-plane
|
|
7694
|
+
// route too; `audit:read` joins the confined claim below
|
|
7695
|
+
audit: "control-plane",
|
|
7696
|
+
// webhooks-in (the inbound receiver's READ routes); the token is confined to
|
|
7697
|
+
// WEBHOOKS_FUNCTION_SCOPES, and webhooks:write stays in SCOPES_NOT_FOR_FUNCTIONS
|
|
7698
|
+
// (which wins over the prefix — see functionScopeAudience).
|
|
7699
|
+
webhooks: "webhooks"
|
|
7162
7700
|
};
|
|
7163
7701
|
var FUNCTION_SCOPE_PREFIX_ALIASES = {
|
|
7164
7702
|
"rate-limits": "ratelimits",
|
|
@@ -7166,7 +7704,20 @@ var FUNCTION_SCOPE_PREFIX_ALIASES = {
|
|
|
7166
7704
|
vector: "vector-search",
|
|
7167
7705
|
feeds: "activity-feed"
|
|
7168
7706
|
};
|
|
7169
|
-
var
|
|
7707
|
+
var SCOPES_NOT_FOR_FUNCTIONS = {
|
|
7708
|
+
admin: "never grantable to a function (DENY_FUNCTION_SCOPES strips it at deploy)",
|
|
7709
|
+
"secrets:write": "never grantable to a function (self-integrity: DENY_FUNCTION_SCOPES strips it at deploy)",
|
|
7710
|
+
"features:write": "never grantable to a function (self-integrity: DENY_FUNCTION_SCOPES strips it at deploy)",
|
|
7711
|
+
"functions:write": "never grantable to a function (self-integrity: DENY_FUNCTION_SCOPES strips it at deploy)",
|
|
7712
|
+
"features:read": "GET /v1/features is privilege-independent (any authenticated principal) and no other route needs it from a function \u2014 the control-plane callback token is confined to users:*/usage:read",
|
|
7713
|
+
"functions:invoke": "there is no function\u2192function lane by design (no aud=functions token is ever minted) \u2014 chain work through a cms/webhook/queue trigger instead",
|
|
7714
|
+
"functions:read": "GET /v1/functions is privilege-independent and the manifest is deploy-time data \u2014 read your own config from vxil.config.ts, not at invoke",
|
|
7715
|
+
"webhooks:write": "webhook subscriptions are deploy-managed trigger wiring (auto-wired from bindings) \u2014 a function must not rewrite its own triggers (self-integrity, same class as functions:write)",
|
|
7716
|
+
"copilot:read": "copilot is an agent surface that acts on your features through its own typed tool routes; a function-driven copilot turn is not a supported callback lane \u2014 invoke copilot from your app, not from a function",
|
|
7717
|
+
"copilot:write": "copilot is an agent surface that acts on your features through its own typed tool routes; a function-driven copilot turn is not a supported callback lane \u2014 invoke copilot from your app, not from a function"
|
|
7718
|
+
};
|
|
7719
|
+
var CONTROL_PLANE_FUNCTION_SCOPES = ["users:read", "users:write", "usage:read", "audit:read"];
|
|
7720
|
+
var WEBHOOKS_FUNCTION_SCOPES = ["webhooks:read"];
|
|
7170
7721
|
function canonicalFunctionScope(scope) {
|
|
7171
7722
|
const i = scope.indexOf(":");
|
|
7172
7723
|
if (i <= 0) return scope;
|
|
@@ -7176,6 +7727,7 @@ function canonicalFunctionScope(scope) {
|
|
|
7176
7727
|
}
|
|
7177
7728
|
function functionScopeAudience(scope) {
|
|
7178
7729
|
const c = canonicalFunctionScope(scope);
|
|
7730
|
+
if (c in SCOPES_NOT_FOR_FUNCTIONS) return null;
|
|
7179
7731
|
const i = c.indexOf(":");
|
|
7180
7732
|
if (i <= 0) return null;
|
|
7181
7733
|
return FUNCTION_SCOPE_AUDIENCE[c.slice(0, i)] ?? null;
|
|
@@ -7191,8 +7743,30 @@ function functionCallbackAudiences(scopes) {
|
|
|
7191
7743
|
function scopesForFunctionAudience(aud, scopes) {
|
|
7192
7744
|
const canon = [...new Set(scopes.map(canonicalFunctionScope))];
|
|
7193
7745
|
if (aud === "control-plane") return canon.filter((s) => CONTROL_PLANE_FUNCTION_SCOPES.includes(s));
|
|
7746
|
+
if (aud === "webhooks") return canon.filter((s) => WEBHOOKS_FUNCTION_SCOPES.includes(s));
|
|
7194
7747
|
return canon;
|
|
7195
7748
|
}
|
|
7749
|
+
var PUBLIC_KEY_DENY_SCOPES = /* @__PURE__ */ new Set([
|
|
7750
|
+
"admin",
|
|
7751
|
+
"*",
|
|
7752
|
+
"users:read",
|
|
7753
|
+
"users:write",
|
|
7754
|
+
"features:read",
|
|
7755
|
+
"features:write",
|
|
7756
|
+
"secrets:write",
|
|
7757
|
+
// G-15: the tenant-wide audit stream has no end-user owner dimension
|
|
7758
|
+
"audit:read",
|
|
7759
|
+
"functions:read",
|
|
7760
|
+
"functions:write",
|
|
7761
|
+
"jobs:read",
|
|
7762
|
+
"jobs:write",
|
|
7763
|
+
"webhooks:read",
|
|
7764
|
+
"webhooks:write",
|
|
7765
|
+
"ratelimits:write",
|
|
7766
|
+
"payments:write",
|
|
7767
|
+
"notifications:send",
|
|
7768
|
+
"auth:write"
|
|
7769
|
+
]);
|
|
7196
7770
|
var TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
7197
7771
|
var FUNCTION_SOURCE_MAX_BYTES = 512e3;
|
|
7198
7772
|
var FN_PLATFORM_DEFAULT_CPU_MS = 3e4;
|
|
@@ -8240,6 +8814,15 @@ function indexArmNote(rows) {
|
|
|
8240
8814
|
if (rows === null) return "arms inline or through the re-index push runs (live row count unavailable)";
|
|
8241
8815
|
return rows <= EQ_INDEX_INLINE_MAX_ROWS ? `arms inline (${rows} rows)` : `needs reindex (${rows} rows; push runs it)`;
|
|
8242
8816
|
}
|
|
8817
|
+
function writeRolesOf(v) {
|
|
8818
|
+
if (!Array.isArray(v)) return [];
|
|
8819
|
+
return [...new Set(v.filter((r) => typeof r === "string"))].sort();
|
|
8820
|
+
}
|
|
8821
|
+
function writeRolesWidens(declared, remote) {
|
|
8822
|
+
if (remote.length === 0) return false;
|
|
8823
|
+
if (declared.length === 0) return true;
|
|
8824
|
+
return declared.some((r) => !remote.includes(r));
|
|
8825
|
+
}
|
|
8243
8826
|
function endUserAccessOf(v) {
|
|
8244
8827
|
return v === "read" || v === "none" ? v : "readwrite";
|
|
8245
8828
|
}
|
|
@@ -8341,6 +8924,7 @@ async function readRemote2(api) {
|
|
|
8341
8924
|
ownerField: c.owner_field ?? null,
|
|
8342
8925
|
public: c.public === true,
|
|
8343
8926
|
endUserAccess: endUserAccessOf(c.end_user_access),
|
|
8927
|
+
writeRoles: writeRolesOf(c.write_roles),
|
|
8344
8928
|
actions: normalizeActions(c.actions),
|
|
8345
8929
|
fields: Array.isArray(c.fields) ? c.fields : []
|
|
8346
8930
|
});
|
|
@@ -8394,6 +8978,10 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
8394
8978
|
if (v !== void 0 && v !== "readwrite" && v !== "read" && v !== "none") {
|
|
8395
8979
|
throw new Error(`cms.collections.${name}.endUserAccess must be 'readwrite' | 'read' | 'none' (got ${JSON.stringify(v)})`);
|
|
8396
8980
|
}
|
|
8981
|
+
const wr = def.writeRoles;
|
|
8982
|
+
if (wr !== void 0 && (!Array.isArray(wr) || wr.some((r) => typeof r !== "string"))) {
|
|
8983
|
+
throw new Error(`cms.collections.${name}.writeRoles must be an array of role slugs (got ${JSON.stringify(wr)})`);
|
|
8984
|
+
}
|
|
8397
8985
|
}
|
|
8398
8986
|
const read = await readRemote2(api);
|
|
8399
8987
|
if (read.unavailable) {
|
|
@@ -8556,6 +9144,24 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
8556
9144
|
});
|
|
8557
9145
|
}
|
|
8558
9146
|
}
|
|
9147
|
+
const declaredRoles = writeRolesOf(def.writeRoles);
|
|
9148
|
+
if (stableStringify(declaredRoles) !== stableStringify(rc.writeRoles)) {
|
|
9149
|
+
const widens = writeRolesWidens(declaredRoles, rc.writeRoles);
|
|
9150
|
+
const fmt = (r) => r.length ? `[${r.join(", ")}]` : "none (ungated)";
|
|
9151
|
+
if (!widens || allowDestructive) {
|
|
9152
|
+
collChanges.push({
|
|
9153
|
+
collection: name,
|
|
9154
|
+
kind: "set-write-roles",
|
|
9155
|
+
detail: `${name} writeRoles \u2192 ${fmt(declaredRoles)} (was ${fmt(rc.writeRoles)})` + (widens ? " \u2014 WIDENS who may write (full-config reconciliation)" : " (narrows end-user writes)")
|
|
9156
|
+
});
|
|
9157
|
+
} else {
|
|
9158
|
+
collChanges.push({
|
|
9159
|
+
collection: name,
|
|
9160
|
+
kind: "warn",
|
|
9161
|
+
detail: `${name}: writeRoles are ${fmt(rc.writeRoles)} remotely but ${fmt(declaredRoles)} in config \u2014 that WIDENS which end users may write, so it is NOT applied. Declare the remote roles to keep them, or re-run with --allow-destructive to widen.`
|
|
9162
|
+
});
|
|
9163
|
+
}
|
|
9164
|
+
}
|
|
8559
9165
|
const declaredActions = normalizeActions(def.actions);
|
|
8560
9166
|
if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
|
|
8561
9167
|
collChanges.push({
|
|
@@ -8629,6 +9235,8 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
8629
9235
|
if (def.public === true) body.public = true;
|
|
8630
9236
|
const access = endUserAccessOf(def.endUserAccess);
|
|
8631
9237
|
if (access !== "readwrite") body.end_user_access = access;
|
|
9238
|
+
const roles = writeRolesOf(def.writeRoles);
|
|
9239
|
+
if (roles.length > 0) body.write_roles = roles;
|
|
8632
9240
|
if (def.actions && def.actions.length > 0) body.actions = normalizeActions(def.actions);
|
|
8633
9241
|
const res = await api("POST", "/v1/cms/collections", body);
|
|
8634
9242
|
if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
|
|
@@ -8676,6 +9284,14 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
8676
9284
|
throw new Error(`cms: set endUserAccess on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
8677
9285
|
}
|
|
8678
9286
|
applied++;
|
|
9287
|
+
} else if (c.kind === "set-write-roles") {
|
|
9288
|
+
const target = writeRolesOf(collections[c.collection].writeRoles);
|
|
9289
|
+
const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { write_roles: target });
|
|
9290
|
+
if (res.status !== 200 && res.status !== 201) {
|
|
9291
|
+
const e = res.body.error ?? {};
|
|
9292
|
+
throw new Error(`cms: set writeRoles on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
9293
|
+
}
|
|
9294
|
+
applied++;
|
|
8679
9295
|
} else if (c.kind === "set-actions") {
|
|
8680
9296
|
const target = normalizeActions(collections[c.collection].actions);
|
|
8681
9297
|
const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { actions: target });
|
|
@@ -8741,7 +9357,7 @@ function formatCmsChanges(changes) {
|
|
|
8741
9357
|
if (c.kind === "destructive") return ` ! ${c.detail}`;
|
|
8742
9358
|
if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
|
|
8743
9359
|
if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
|
|
8744
|
-
if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access") return ` ~ ${c.detail}`;
|
|
9360
|
+
if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles") return ` ~ ${c.detail}`;
|
|
8745
9361
|
return ` + ${c.detail}`;
|
|
8746
9362
|
}).join("\n");
|
|
8747
9363
|
}
|
|
@@ -8773,6 +9389,11 @@ function indexCertifyWarning(collection, certify) {
|
|
|
8773
9389
|
if (certify !== "timeout" && certify !== "pending") return null;
|
|
8774
9390
|
return ` \u26A0 the index on ${collection} is NOT armed yet (` + (certify === "timeout" ? "its certification ran out of time" : "rows changed during the pass") + ") \u2014 filters stay correct but unaccelerated; re-run `vxil cms reindex " + collection + "`";
|
|
8775
9391
|
}
|
|
9392
|
+
function hookTypePreflight(cfg) {
|
|
9393
|
+
const hooks = cfg.features?.cms?.hooks;
|
|
9394
|
+
if (!hooks) return { errors: [], warnings: [] };
|
|
9395
|
+
return checkHookTypes(hooks, hookFieldTypesOf(cfg.cms?.collections));
|
|
9396
|
+
}
|
|
8776
9397
|
|
|
8777
9398
|
// src/apiCmd.ts
|
|
8778
9399
|
var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
@@ -8915,7 +9536,7 @@ var API_CHEATSHEET = [
|
|
|
8915
9536
|
// realtime
|
|
8916
9537
|
{ feature: "realtime", what: "publish an event to a channel", cmd: `vxil api POST /v1/realtime/channels/<channel>/publish '{"event":"ping","data":{}}'` },
|
|
8917
9538
|
// webhooks
|
|
8918
|
-
{ feature: "webhooks", what: "inbound sources add / list / rm / test", cmd: "vxil webhooks sources add --provider stripe --name prod [--forward-url https://\u2026] \xB7 sources list \xB7 sources rm <id> [--yes] \xB7 sources test <id>" },
|
|
9539
|
+
{ feature: "webhooks", what: "inbound sources add / list / rm / test", cmd: "vxil webhooks sources add --provider stripe --name prod [--forward-url https://\u2026 | --target-function <fn>] [--provider hmac --verify <json>] \xB7 sources list \xB7 sources rm <id> [--yes] \xB7 sources test <id>" },
|
|
8919
9540
|
{ feature: "webhooks", what: "replay an inbound event / see its delivery runs", cmd: "vxil webhooks events replay <event_id> \xB7 vxil webhooks events runs <event_id>" },
|
|
8920
9541
|
{ feature: "webhooks", what: "declare outbound subscriptions in vxil.config (converged by push)", cmd: "features.webhooks.subscriptions[] in vxil.config.ts \u2192 vxil push" },
|
|
8921
9542
|
{ feature: "webhooks", what: "add an outbound subscription by hand (undeclared \u2014 `vxil diff --strict-api-state` reports it)", cmd: `vxil api POST /v1/webhooks/subscriptions '{"target_url":"https://\u2026","event_prefixes":["cms."]}'` },
|
|
@@ -9194,7 +9815,8 @@ function resolve_target(opts) {
|
|
|
9194
9815
|
}
|
|
9195
9816
|
if (selector2.kind === "named") {
|
|
9196
9817
|
const name = selector2.name;
|
|
9197
|
-
const
|
|
9818
|
+
const slotKind = proj?.targets?.[name] ? "target" : proj?.branches?.[name] ? "branch" : name === "dev" && proj?.dev ? "dev" : void 0;
|
|
9819
|
+
const slot = slotKind === "target" ? proj.targets[name] : slotKind === "branch" ? proj.branches[name] : slotKind === "dev" ? proj.dev : void 0;
|
|
9198
9820
|
if (!slot) {
|
|
9199
9821
|
throw new TargetUnboundError(
|
|
9200
9822
|
selector2,
|
|
@@ -9210,6 +9832,7 @@ function resolve_target(opts) {
|
|
|
9210
9832
|
tenantId: slot.tenant_id,
|
|
9211
9833
|
slot: "named",
|
|
9212
9834
|
slotName: name,
|
|
9835
|
+
...slotKind ? { slotKind } : {},
|
|
9213
9836
|
envLabel: slot.env ?? envLabelFor(baseUrl2),
|
|
9214
9837
|
keySource: stored ? "credentials" : "none",
|
|
9215
9838
|
selector: selector2
|
|
@@ -9302,7 +9925,10 @@ function isNonProductionLabel(label) {
|
|
|
9302
9925
|
return NON_PRODUCTION_ENV_LABELS.has(normalizeLabel(label));
|
|
9303
9926
|
}
|
|
9304
9927
|
function isDevSlot(t) {
|
|
9305
|
-
|
|
9928
|
+
if (t.slot === "dev") return true;
|
|
9929
|
+
if (t.slot !== "named") return false;
|
|
9930
|
+
if (t.slotKind !== void 0) return t.slotKind === "branch" || t.slotKind === "dev";
|
|
9931
|
+
return t.selector.kind === "named" && t.selector.name === "dev";
|
|
9306
9932
|
}
|
|
9307
9933
|
function isProductionTarget(t) {
|
|
9308
9934
|
if (isDevSlot(t)) return false;
|
|
@@ -10292,7 +10918,8 @@ async function filesRm(api, objectId) {
|
|
|
10292
10918
|
if (!ok2xx(r)) throw new Error(`could not delete '${objectId}' (${apiErrText(r)})`);
|
|
10293
10919
|
return { object_id: objectId, deleted: true };
|
|
10294
10920
|
}
|
|
10295
|
-
var WEBHOOK_PROVIDERS = ["stripe", "paddle", "github", "slack", "revenuecat", "generic"];
|
|
10921
|
+
var WEBHOOK_PROVIDERS = ["stripe", "paddle", "github", "slack", "revenuecat", "generic", "hmac"];
|
|
10922
|
+
var TARGET_FUNCTION_RE = /^[a-z][a-z0-9-]{0,47}$/;
|
|
10296
10923
|
function parseSourceAdd(f) {
|
|
10297
10924
|
const provider = (f.provider ?? "").trim();
|
|
10298
10925
|
if (!WEBHOOK_PROVIDERS.includes(provider)) {
|
|
@@ -10303,14 +10930,48 @@ function parseSourceAdd(f) {
|
|
|
10303
10930
|
if (name.length > 200) return { error: `--name is at most 200 characters (got ${name.length})` };
|
|
10304
10931
|
const fwd = f.forwardUrl?.trim();
|
|
10305
10932
|
if (fwd !== void 0 && fwd !== "" && !/^https:\/\//i.test(fwd)) return { error: "--forward-url must be an https:// URL" };
|
|
10306
|
-
|
|
10933
|
+
const fn = f.targetFunction?.trim();
|
|
10934
|
+
if (fn !== void 0 && fn !== "") {
|
|
10935
|
+
if (!TARGET_FUNCTION_RE.test(fn)) return { error: `--target-function must be a function name matching ${TARGET_FUNCTION_RE.source}` };
|
|
10936
|
+
if (fwd) return { error: "set --forward-url OR --target-function, not both (one source, one destination)" };
|
|
10937
|
+
}
|
|
10938
|
+
let verify;
|
|
10939
|
+
const rawVerify = f.verify?.trim();
|
|
10940
|
+
if (provider === "hmac") {
|
|
10941
|
+
if (!rawVerify) {
|
|
10942
|
+
return { error: `--provider hmac needs --verify '<json>', e.g. --verify '{"header":"X-Shopify-Hmac-Sha256","encoding":"base64"}'` };
|
|
10943
|
+
}
|
|
10944
|
+
try {
|
|
10945
|
+
const v = JSON.parse(rawVerify);
|
|
10946
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return { error: "--verify must be a JSON object" };
|
|
10947
|
+
verify = v;
|
|
10948
|
+
} catch {
|
|
10949
|
+
return { error: "--verify must be valid JSON (a single-quoted object)" };
|
|
10950
|
+
}
|
|
10951
|
+
} else if (rawVerify) {
|
|
10952
|
+
return { error: "--verify is accepted only with --provider hmac" };
|
|
10953
|
+
}
|
|
10954
|
+
return {
|
|
10955
|
+
body: {
|
|
10956
|
+
provider,
|
|
10957
|
+
name,
|
|
10958
|
+
...fwd ? { forward_url: fwd } : {},
|
|
10959
|
+
...fn ? { target_function: fn } : {},
|
|
10960
|
+
...verify ? { verify } : {}
|
|
10961
|
+
}
|
|
10962
|
+
};
|
|
10307
10963
|
}
|
|
10308
10964
|
async function sourcesAdd(api, body) {
|
|
10309
10965
|
const r = await api("POST", "/v1/webhooks/sources", body);
|
|
10310
10966
|
if (!ok2xx(r)) throw new Error(`could not register the source (${apiErrText(r)})`);
|
|
10311
10967
|
const d = dataOf(r);
|
|
10312
10968
|
if (!d.source_id || !d.receiver_url_path) throw new Error("the source route returned no source_id/receiver_url_path");
|
|
10313
|
-
return {
|
|
10969
|
+
return {
|
|
10970
|
+
source_id: d.source_id,
|
|
10971
|
+
receiver_url_path: d.receiver_url_path,
|
|
10972
|
+
provider: d.provider ?? body.provider,
|
|
10973
|
+
...body.target_function ? { target_function: d.target_function ?? body.target_function } : {}
|
|
10974
|
+
};
|
|
10314
10975
|
}
|
|
10315
10976
|
function sourceAddStdout(s, edgeBase, json2) {
|
|
10316
10977
|
if (json2) return JSON.stringify({ source_id: s.source_id, provider: s.provider, receiver_url_path: s.receiver_url_path, receiver_url: `${edgeBase.replace(/\/$/, "")}${s.receiver_url_path}` });
|
|
@@ -10319,9 +10980,14 @@ function sourceAddStdout(s, edgeBase, json2) {
|
|
|
10319
10980
|
function sourceAddNotices(s) {
|
|
10320
10981
|
return [
|
|
10321
10982
|
`\u2713 registered ${s.provider} source ${s.source_id} \u2014 the receiver URL above is shown ONCE; paste it into the provider's webhook settings`,
|
|
10322
|
-
`a signing secret goes in \`vxil secrets set webhooks/source_${s.source_id}\` (a 'generic' source needs none); \`vxil listen --source ${s.source_id} --forward-to <url>\` forwards its events locally
|
|
10983
|
+
`a signing secret goes in \`vxil secrets set webhooks/source_${s.source_id.toLowerCase()}\` (a 'generic' source needs none); \`vxil listen --source ${s.source_id} --forward-to <url>\` forwards its events locally`,
|
|
10984
|
+
...s.target_function ? [`verified events are delivered to function ${s.target_function}: it needs a bare webhook binding (triggers: [{ kind: 'webhook' }]); a cron-only function is woken as a cron tick WITHOUT the event, and a function with neither binding skips it`] : []
|
|
10323
10985
|
];
|
|
10324
10986
|
}
|
|
10987
|
+
function sourceDestination(s) {
|
|
10988
|
+
if (s.target_function) return `fn:${s.target_function}`;
|
|
10989
|
+
return s.forward_url || null;
|
|
10990
|
+
}
|
|
10325
10991
|
async function sourcesList(api) {
|
|
10326
10992
|
const r = await api("GET", "/v1/webhooks/sources");
|
|
10327
10993
|
if (!ok2xx(r)) throw new Error(`could not list the sources (${apiErrText(r)})`);
|
|
@@ -10610,10 +11276,20 @@ function boundedData(data2, maxBytes, onTruncate) {
|
|
|
10610
11276
|
const bytes = new TextEncoder().encode(text).length;
|
|
10611
11277
|
if (bytes > maxBytes) {
|
|
10612
11278
|
onTruncate?.(bytes);
|
|
10613
|
-
return
|
|
11279
|
+
return truncatedStub(data2);
|
|
10614
11280
|
}
|
|
10615
11281
|
return data2;
|
|
10616
11282
|
}
|
|
11283
|
+
function truncatedStub(data2) {
|
|
11284
|
+
const stub = { truncated: true };
|
|
11285
|
+
if (data2 && typeof data2 === "object" && !Array.isArray(data2)) {
|
|
11286
|
+
for (const k of ["vxil_event_id", "source_id"]) {
|
|
11287
|
+
const v = data2[k];
|
|
11288
|
+
if (typeof v === "string" && /^[A-Za-z0-9_]{1,64}$/.test(v)) stub[k] = v;
|
|
11289
|
+
}
|
|
11290
|
+
}
|
|
11291
|
+
return stub;
|
|
11292
|
+
}
|
|
10617
11293
|
function webhookPayload(inner, maxBytes = WEBHOOK_PAYLOAD_MAX_BYTES) {
|
|
10618
11294
|
return {
|
|
10619
11295
|
event: inner.webhook_event,
|
|
@@ -10789,11 +11465,20 @@ function deployedVerdict(lane, binding, timeoutMs) {
|
|
|
10789
11465
|
}
|
|
10790
11466
|
return out;
|
|
10791
11467
|
}
|
|
10792
|
-
|
|
10793
|
-
|
|
10794
|
-
if (o.
|
|
11468
|
+
var NON_DEV_GUARDED_LANES = LOCAL_LANES.filter((l) => l !== "http");
|
|
11469
|
+
function liveLaneGuard(o) {
|
|
11470
|
+
if (o.devSlot || o.explicitSelector) return null;
|
|
10795
11471
|
const what = o.oneShot ? `this --event delivery (${o.lane})` : `every ${o.lane} delivery of the local server`;
|
|
10796
|
-
|
|
11472
|
+
if (!o.oneShot && !NON_DEV_GUARDED_LANES.includes(o.lane)) return null;
|
|
11473
|
+
if (o.production) {
|
|
11474
|
+
return `no dev tenant is bound, so ${what} would run '${o.fnName}' against the PRODUCTION binding '${o.tenant}' \u2014 its feature calls land there for real, and each local delivery carries a fresh idempotency_key, so a money-lane function grants, refunds or mails again even for an event production already handled. Bind a dev tenant (\`vxil dev up\`), or aim on purpose with --target <name>`;
|
|
11475
|
+
}
|
|
11476
|
+
return `no dev tenant is bound, so ${what} would run '${o.fnName}' against the non-dev binding '${o.tenant}' \u2014 its feature calls land there for REAL (its data, its provider accounts), and each local delivery carries a fresh idempotency_key, so a function that grants, bills, refunds or mails does it again. Run functions dev only against a dev slot (\`vxil dev up\`), or aim on purpose with --target <name>`;
|
|
11477
|
+
}
|
|
11478
|
+
function liveWritesBanner(o) {
|
|
11479
|
+
if (o.devSlot) return null;
|
|
11480
|
+
const line = ` !! writes for REAL on '${o.tenant}' (${o.envLabel}) \u2014 not a dev slot: every feature call, grant, refund and mail is real`;
|
|
11481
|
+
return o.color ? `\x1B[31m${line}\x1B[0m` : line;
|
|
10797
11482
|
}
|
|
10798
11483
|
|
|
10799
11484
|
// src/fnDev.ts
|
|
@@ -10855,6 +11540,7 @@ function controlPlaneFunctionScope(method, pathname) {
|
|
|
10855
11540
|
const read = m === "GET" || m === "HEAD";
|
|
10856
11541
|
if (pathname === "/v1/users" || pathname.startsWith("/v1/users/")) return read ? "users:read" : "users:write";
|
|
10857
11542
|
if (pathname === "/v1/usage") return read ? "usage:read" : null;
|
|
11543
|
+
if (pathname === "/v1/audit" || pathname === "/v1/audit/export") return read ? "audit:read" : null;
|
|
10858
11544
|
return null;
|
|
10859
11545
|
}
|
|
10860
11546
|
function json(body, status, headers = {}) {
|
|
@@ -11268,6 +11954,16 @@ export type VxilFilterJson = string | number | boolean | { $eq?: string | number
|
|
|
11268
11954
|
}
|
|
11269
11955
|
|
|
11270
11956
|
// ../../workers/mcp-v1/src/tools.ts
|
|
11957
|
+
function auditExportQuery(a) {
|
|
11958
|
+
const qs = new URLSearchParams();
|
|
11959
|
+
for (const k of ["subject", "since", "until", "after_id"]) {
|
|
11960
|
+
const v = a[k];
|
|
11961
|
+
if (typeof v === "string" && v) qs.set(k, v);
|
|
11962
|
+
}
|
|
11963
|
+
const n = Number(a.limit);
|
|
11964
|
+
qs.set("limit", String(Number.isFinite(n) ? Math.min(Math.max(Math.trunc(n), 1), 1e3) : 500));
|
|
11965
|
+
return qs.toString();
|
|
11966
|
+
}
|
|
11271
11967
|
var TOOLS = [
|
|
11272
11968
|
{
|
|
11273
11969
|
name: "users_upsert",
|
|
@@ -11379,12 +12075,12 @@ var TOOLS = [
|
|
|
11379
12075
|
{
|
|
11380
12076
|
name: "notifications_list_deliveries",
|
|
11381
12077
|
feature: "notifications",
|
|
11382
|
-
description: "List recent notification deliveries with status (queued|sent|failed|suppressed). Filter by user_id, status, or engagement (delivered|opened|clicked, stamped from the signed provider webhook).",
|
|
12078
|
+
description: "List recent notification deliveries with status (queued|sent|failed|suppressed|test_sink \u2014 test_sink = the recipient is in the project's suppression.testRecipients: rendered and recorded, never sent, never billed). Filter by user_id, status, or engagement (delivered|opened|clicked, stamped from the signed provider webhook).",
|
|
11383
12079
|
inputSchema: {
|
|
11384
12080
|
type: "object",
|
|
11385
12081
|
properties: {
|
|
11386
12082
|
user_id: { type: "string" },
|
|
11387
|
-
status: { type: "string", enum: ["queued", "sent", "failed", "suppressed"] },
|
|
12083
|
+
status: { type: "string", enum: ["queued", "sent", "failed", "suppressed", "test_sink"] },
|
|
11388
12084
|
engagement: { type: "string", enum: ["delivered", "opened", "clicked"] },
|
|
11389
12085
|
limit: { type: "number", default: 20 }
|
|
11390
12086
|
}
|
|
@@ -11616,7 +12312,7 @@ var TOOLS = [
|
|
|
11616
12312
|
{
|
|
11617
12313
|
name: "jobs_get_run",
|
|
11618
12314
|
feature: "jobs",
|
|
11619
|
-
description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true }), any generation_* fields, result (what the run completed with: its signed callback's result, or a generation's settled value; null when nothing reported one), progress (the latest { progress 0..100, stage, message, at } report, or null), concurrency_key / concurrency_limit (null when unkeyed) and, on a generation run, mirror_error (the last status_mirror write the target refused \u2014 e.g. a callback key the collection does not declare: the status word was retried alone and fields_dropped lists what was not written \u2014 or null). Pass wait (1..25 seconds) to WAIT FOR THIS RUN: the read holds until the run is terminal (succeeded | failed | dead | cancelled) or the deadline passes, then returns the current row \u2014 on a timeout (or when the per-tenant wait budget of 60 held reads a minute declined to hold) state is still non-terminal, so check it and call again. Use it right after jobs_enqueue or an async function invoke instead of polling jobs_list_runs.",
|
|
12315
|
+
description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true }), any generation_* fields, result (what the run completed with: its signed callback's result, or a generation's settled value; null when nothing reported one), progress (the latest { progress 0..100, stage, message, at } report, or null), concurrency_key / concurrency_limit (null when unkeyed) and, on a generation run, mirror_error (the last status_mirror write the target refused \u2014 e.g. a callback key the collection does not declare: the status word was retried alone and fields_dropped lists what was not written; status_written says whether the record holds the status word \u2014 or null; a later clean completed/failed write clears it into last_mirror_error, with mirror_ok_at saying when). Pass wait (1..25 seconds) to WAIT FOR THIS RUN: the read holds until the run is terminal (succeeded | failed | dead | cancelled) or the deadline passes, then returns the current row \u2014 on a timeout (or when the per-tenant wait budget of 60 held reads a minute declined to hold) state is still non-terminal, so check it and call again. Use it right after jobs_enqueue or an async function invoke instead of polling jobs_list_runs.",
|
|
11620
12316
|
inputSchema: {
|
|
11621
12317
|
type: "object",
|
|
11622
12318
|
properties: {
|
|
@@ -11760,7 +12456,7 @@ var TOOLS = [
|
|
|
11760
12456
|
{
|
|
11761
12457
|
name: "jobs_cancel_run",
|
|
11762
12458
|
feature: "jobs",
|
|
11763
|
-
description: "Cancel a run that has not started its current attempt \u2014 queued, delayed or retrying \u2014 or a plain run handed off to its signed callback (waiting after its handler answered 202): it ends in state cancelled and is never delivered again (a handed-off run's callback URL is consumed). A generation run's credit hold is released and its status mirror is set
|
|
12459
|
+
description: "Cancel a run that has not started its current attempt \u2014 queued, delayed or retrying \u2014 or a plain run handed off to its signed callback (waiting after its handler answered 202): it ends in state cancelled and is never delivered again (a handed-off run's callback URL is consumed). A generation run's credit hold is released and its status mirror is set \u2014 a STARTED generation too (its provider accepted it, the run waits on its callback): it ends failed (error_class Cancelled) and a late provider callback is a no-op, but the provider is NOT told, so its work and its charge may still complete. 409 not_cancellable for a plain run that is running (a delivery in flight), a run waiting on an event (wake it with jobs_emit_event), or one already terminal.",
|
|
11764
12460
|
inputSchema: {
|
|
11765
12461
|
type: "object",
|
|
11766
12462
|
properties: { run_id: { type: "string" } },
|
|
@@ -11772,7 +12468,7 @@ var TOOLS = [
|
|
|
11772
12468
|
{
|
|
11773
12469
|
name: "jobs_replay_run",
|
|
11774
12470
|
feature: "jobs",
|
|
11775
|
-
description: "Replay a finished plain run (dead / failed / cancelled / succeeded): a NEW run with the same job, target and payload is queued (replayed_from names the original). The target receives it like any delivery \u2014 dedupe on run_id if it must not act twice. A generation run is not replayable (409 not_replayable): enqueue the generation again.",
|
|
12471
|
+
description: "Replay a finished plain run (dead / failed / cancelled / succeeded): a NEW run with the same job, target and payload is queued (replayed_from names the original). The target receives it like any delivery \u2014 dedupe on run_id if it must not act twice. A generation run is not replayable (409 not_replayable): enqueue the generation again. A redacted or erased run is not replayable either (409 run_redacted): its payload is gone.",
|
|
11776
12472
|
inputSchema: {
|
|
11777
12473
|
type: "object",
|
|
11778
12474
|
properties: { run_id: { type: "string" } },
|
|
@@ -11781,6 +12477,20 @@ var TOOLS = [
|
|
|
11781
12477
|
method: "POST",
|
|
11782
12478
|
path: (a) => `/v1/jobs/runs/${encodeURIComponent(String(a.run_id))}/replay`
|
|
11783
12479
|
},
|
|
12480
|
+
{
|
|
12481
|
+
name: "jobs_redact_runs",
|
|
12482
|
+
feature: "jobs",
|
|
12483
|
+
description: "Redact FINISHED runs that hold personal data (a privacy request for runs nothing links to a user, e.g. a plain queue run or an async function call): payload becomes { erased: true }; result, progress, callback body and error text are cleared; a generation's provider request body/headers are dropped and its credit hold's user id is pseudonymized (unless its settle is still owed). The run row stays (state, timings, job_name). Send EXACTLY ONE of run_ids (\u2264500; open runs are left alone and counted in skipped_open) or user_id (the generation runs whose credit hold names that user \u2014 one page of \u2264500 per call; done:false \u2192 call again). Idempotent; irreversible; a redacted run no longer replays. concurrency_key, debounce_key, idempotency_key and target_url are NOT cleared \u2014 keep user ids out of them. Answers { redacted, skipped_open, done }.",
|
|
12484
|
+
inputSchema: {
|
|
12485
|
+
type: "object",
|
|
12486
|
+
properties: {
|
|
12487
|
+
run_ids: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 500, description: "Finished runs to redact." },
|
|
12488
|
+
user_id: { type: "string", description: "Redact the finished generation runs whose credit hold names this end-user." }
|
|
12489
|
+
}
|
|
12490
|
+
},
|
|
12491
|
+
method: "POST",
|
|
12492
|
+
path: "/v1/jobs/runs/redact"
|
|
12493
|
+
},
|
|
11784
12494
|
{
|
|
11785
12495
|
name: "jobs_emit_event",
|
|
11786
12496
|
feature: "jobs",
|
|
@@ -11799,7 +12509,7 @@ var TOOLS = [
|
|
|
11799
12509
|
{
|
|
11800
12510
|
name: "jobs_enqueue_generation",
|
|
11801
12511
|
feature: "jobs",
|
|
11802
|
-
description: "Start a long-running EXTERNAL generation (a render, a model job): vxil calls provider.url (https; your provider key in provider.headers), then learns completion by POLLING completion.poll.url every interval_ms or by the provider's WEBHOOK (completion.mode 'webhook' \u2014 a signed single-use callback URL is appended as completion.callback.query_param). completion.status_path names the status field; status_map maps provider words to completed / failed / processing. status_mirror writes the status onto your record (e.g. cms collection + record_id); timeout.after_ms ends it as failed; reserve_credits holds a user's credits, committed on completion and released on failure; when payments cannot be reached the call answers 503 payments_unavailable (nothing started or held \u2014 retry the same call after Retry-After) unless reserve_credits.on_unavailable is 'proceed'. max_attempts = provider START calls (a 408/429/5xx retries; a provider Retry-After on 429/503 is honoured). Answers 202 { run_id, generation_status: 'pending' } (deduplicated:true for a repeated idempotency_key); 429 with Retry-After when the project's open-generation cap is reached. Read it with jobs_get_run (generation_status, result, progress, mirror_error).",
|
|
12512
|
+
description: "Start a long-running EXTERNAL generation (a render, a model job): vxil calls provider.url (https; your provider key in provider.headers), then learns completion by POLLING completion.poll.url every interval_ms or by the provider's WEBHOOK (completion.mode 'webhook' \u2014 a signed single-use callback URL is appended as completion.callback.query_param). completion.status_path names the status field; status_map maps provider words to completed / failed / processing. status_mirror writes the status onto your record (e.g. cms collection + record_id); timeout.after_ms ends it as failed; reserve_credits holds a user's credits, committed on completion and released on failure; when payments cannot be reached the call answers 503 payments_unavailable (nothing started or held \u2014 retry the same call after Retry-After) unless reserve_credits.on_unavailable is 'proceed'. max_attempts = provider START calls (a 408/429/5xx retries; a provider Retry-After on 429/503 is honoured). Answers 202 { run_id, generation_status: 'pending' } (deduplicated:true for a repeated idempotency_key with the SAME body; a different body under that key will answer 422 idempotency_key_reused once enforced \u2014 today a mismatch is only logged and still deduplicated, so keep the body deterministic; 409 generation_in_progress while that run is still placing its hold \u2014 retry after Retry-After); 429 with Retry-After when the project's open-generation cap is reached. Read it with jobs_get_run (generation_status, result, progress, mirror_error).",
|
|
11803
12513
|
inputSchema: {
|
|
11804
12514
|
type: "object",
|
|
11805
12515
|
properties: {
|
|
@@ -11842,7 +12552,12 @@ var TOOLS = [
|
|
|
11842
12552
|
collection: { type: "string" },
|
|
11843
12553
|
record_id: { type: "string" },
|
|
11844
12554
|
column: { type: "string" },
|
|
11845
|
-
progress_fields: { type: "array", items: { type: "string", enum: ["progress", "stage", "message"] } }
|
|
12555
|
+
progress_fields: { type: "array", items: { type: "string", enum: ["progress", "stage", "message"] } },
|
|
12556
|
+
fence_field: {
|
|
12557
|
+
type: "string",
|
|
12558
|
+
maxLength: 80,
|
|
12559
|
+
description: "cms only: a declared string field stamped with this run's run_id on its first write; every later write requires it, so an older run never overwrites a row a newer run claimed."
|
|
12560
|
+
}
|
|
11846
12561
|
},
|
|
11847
12562
|
required: ["feature", "collection", "record_id"]
|
|
11848
12563
|
},
|
|
@@ -11960,7 +12675,7 @@ var TOOLS = [
|
|
|
11960
12675
|
{
|
|
11961
12676
|
name: "files_create_upload_url",
|
|
11962
12677
|
feature: "files",
|
|
11963
|
-
description: "Mint a presigned PUT URL for a new file (bytes go straight to storage, never through Vxil). After uploading, call files_complete to make the object available. Optional expiresInSeconds (60 s to 100 years) makes the object expire and be deleted that long after the mint; the answer's expires_at is when (null = never), expires_in is the upload URL's life in seconds.",
|
|
12678
|
+
description: "Mint a presigned PUT URL for a new file (bytes go straight to storage, never through Vxil). After uploading, call files_complete to make the object available. Optional expiresInSeconds (60 s to 100 years) makes the object expire and be deleted that long after the mint; the answer's expires_at is when (null = never), expires_in is the upload URL's life in seconds. Optional checksum_sha256 is signed into the URL (the PUT must send the answer's upload_headers) and kept with the upload: files_complete answers 409 checksum_mismatch when the stored bytes hash differently, or 409 checksum_unverified when the store reports no checksum.",
|
|
11964
12679
|
inputSchema: {
|
|
11965
12680
|
type: "object",
|
|
11966
12681
|
properties: {
|
|
@@ -11968,7 +12683,8 @@ var TOOLS = [
|
|
|
11968
12683
|
filename: { type: "string" },
|
|
11969
12684
|
content_type: { type: "string" },
|
|
11970
12685
|
size_bytes: { type: "number" },
|
|
11971
|
-
expiresInSeconds: { type: ["integer", "null"], minimum: 60, maximum: 31536e5, description: "Delete the object this many seconds after the mint, 60..3153600000 (100 years) (null = never; omitted = the files.ttl default when enabled, else never)." }
|
|
12686
|
+
expiresInSeconds: { type: ["integer", "null"], minimum: 60, maximum: 31536e5, description: "Delete the object this many seconds after the mint, 60..3153600000 (100 years) (null = never; omitted = the files.ttl default when enabled, else never)." },
|
|
12687
|
+
checksum_sha256: { type: "string", minLength: 44, maxLength: 64, description: "SHA-256 of the bytes: 64 hex or 44 base64 characters. Signed into upload_url as x-amz-checksum-sha256 (returned in upload_headers)." }
|
|
11972
12688
|
},
|
|
11973
12689
|
required: ["user_id", "filename", "content_type", "size_bytes"]
|
|
11974
12690
|
},
|
|
@@ -12133,7 +12849,7 @@ var TOOLS = [
|
|
|
12133
12849
|
{
|
|
12134
12850
|
name: "cms_list_collections",
|
|
12135
12851
|
feature: "cms",
|
|
12136
|
-
description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only); server keys are never affected.",
|
|
12852
|
+
description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago.",
|
|
12137
12853
|
inputSchema: { type: "object", properties: {} },
|
|
12138
12854
|
method: "GET",
|
|
12139
12855
|
path: "/v1/cms/collections"
|
|
@@ -12141,13 +12857,23 @@ var TOOLS = [
|
|
|
12141
12857
|
{
|
|
12142
12858
|
name: "cms_create_item",
|
|
12143
12859
|
feature: "cms",
|
|
12144
|
-
description: "Create a content item in a collection. data must match the collection field defs (call cms_list_collections first). status draft|published.",
|
|
12860
|
+
description: "Create a content item in a collection. data must match the collection field defs (call cms_list_collections first). status draft|published. Upsert: pass onConflict {field, patch} where field is a unique field present in data \u2014 if a live item already holds that value, patch is merged into it instead (200, audited and hooked as an update; null clears a field), else the item is created (201).",
|
|
12145
12861
|
inputSchema: {
|
|
12146
12862
|
type: "object",
|
|
12147
12863
|
properties: {
|
|
12148
12864
|
collection: { type: "string" },
|
|
12149
12865
|
data: { type: "object", additionalProperties: true },
|
|
12150
|
-
status: { type: "string", enum: ["draft", "published"], default: "draft" }
|
|
12866
|
+
status: { type: "string", enum: ["draft", "published"], default: "draft" },
|
|
12867
|
+
onConflict: {
|
|
12868
|
+
type: "object",
|
|
12869
|
+
description: "Upsert by a declared unique field: { field: <unique field in data>, patch: { \u2026merge-patch applied to the existing item } }.",
|
|
12870
|
+
properties: {
|
|
12871
|
+
field: { type: "string" },
|
|
12872
|
+
patch: { type: "object", additionalProperties: true }
|
|
12873
|
+
},
|
|
12874
|
+
required: ["field", "patch"],
|
|
12875
|
+
additionalProperties: false
|
|
12876
|
+
}
|
|
12151
12877
|
},
|
|
12152
12878
|
required: ["collection", "data"]
|
|
12153
12879
|
},
|
|
@@ -12157,7 +12883,7 @@ var TOOLS = [
|
|
|
12157
12883
|
{
|
|
12158
12884
|
name: "cms_query_items",
|
|
12159
12885
|
feature: "cms",
|
|
12160
|
-
description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). Equality ($eq / bare value / $in) is index-served on slot-indexed and `indexed` fields; on other fields it is a bounded scan (same results). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). Paging: pass the returned next_cursor back as cursor WITH THE SAME filter and sort (works for the default order and for any slot/timestamp sort; not for a dotted join sort). $expand inlines relation/file fields. count=true returns {count} instead of a page (filter only \u2014 no sort/limit/cursor/$expand).',
|
|
12886
|
+
description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in ($ne counts an absent/null field as not equal) $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). Equality ($eq / bare value / $in) is index-served on slot-indexed and `indexed` fields; on other fields it is a bounded scan (same results). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). Paging: pass the returned next_cursor back as cursor WITH THE SAME filter and sort (works for the default order and for any slot/timestamp sort; not for a dotted join sort). $expand inlines relation/file fields. count=true returns {count} instead of a page (filter only \u2014 no sort/limit/cursor/$expand).',
|
|
12161
12887
|
inputSchema: {
|
|
12162
12888
|
type: "object",
|
|
12163
12889
|
properties: {
|
|
@@ -12628,6 +13354,46 @@ var TOOLS = [
|
|
|
12628
13354
|
method: "DELETE",
|
|
12629
13355
|
path: (a) => `/v1/functions/${encodeURIComponent(String(a.name))}`
|
|
12630
13356
|
},
|
|
13357
|
+
{
|
|
13358
|
+
name: "functions_list",
|
|
13359
|
+
feature: "functions",
|
|
13360
|
+
description: "The project's deployed functions: name, endpoint (/v1/fn/<name>), trigger bindings, scopes, limits and the runtime each runs under, plus `enabled` (false while the project's functions are paused as a whole \u2014 invokes then answer 404) and `cpuMsCeiling`. Read-only; any authenticated key may read it. Use it before functions_invoke or functions_logs to get the exact names.",
|
|
13361
|
+
inputSchema: { type: "object", properties: {} },
|
|
13362
|
+
method: "GET",
|
|
13363
|
+
path: "/v1/functions"
|
|
13364
|
+
},
|
|
13365
|
+
{
|
|
13366
|
+
name: "functions_invoke",
|
|
13367
|
+
feature: "functions",
|
|
13368
|
+
description: "Invoke ONE deployed function synchronously (POST /v1/fn/<name>) and return its own response verbatim (not a {data, meta} envelope). Every argument other than `name` is sent as the JSON request body, which the function reads as its payload. At-least-once semantics are the function's own: send an `idempotency_key` argument only if the function reads one from its payload. 404 when the name is not deployed; 403 without functions:invoke. Runs tenant code \u2014 it is metered like any invoke.",
|
|
13369
|
+
inputSchema: {
|
|
13370
|
+
type: "object",
|
|
13371
|
+
properties: {
|
|
13372
|
+
name: { type: "string", description: "The deployed function name (lowercase, e.g. on-payment)." }
|
|
13373
|
+
},
|
|
13374
|
+
required: ["name"],
|
|
13375
|
+
additionalProperties: true
|
|
13376
|
+
},
|
|
13377
|
+
method: "POST",
|
|
13378
|
+
path: (a) => `/v1/fn/${encodeURIComponent(String(a.name))}`,
|
|
13379
|
+
pathArgs: ["name"]
|
|
13380
|
+
},
|
|
13381
|
+
{
|
|
13382
|
+
name: "functions_logs",
|
|
13383
|
+
feature: "functions",
|
|
13384
|
+
description: "Log records of ONE deployed function, one per invocation (ts, outcome, wall_ms, cpu_ms, console lines, exceptions), oldest first, with `next_since` \u2014 pass it back as `since` to read only newer records (a tail). Without `since`: the most recent records of the last 24 hours. Kept 14 days. Needs functions:read.",
|
|
13385
|
+
inputSchema: {
|
|
13386
|
+
type: "object",
|
|
13387
|
+
properties: {
|
|
13388
|
+
name: { type: "string", description: "The deployed function name." },
|
|
13389
|
+
since: { type: "string", description: "ISO timestamp: only lines after it (the previous call's next_since)." }
|
|
13390
|
+
},
|
|
13391
|
+
required: ["name"]
|
|
13392
|
+
},
|
|
13393
|
+
method: "GET",
|
|
13394
|
+
path: (a) => `/v1/functions/${encodeURIComponent(String(a.name))}/logs`,
|
|
13395
|
+
queryArgs: ["since"]
|
|
13396
|
+
},
|
|
12631
13397
|
{
|
|
12632
13398
|
name: "webhooks_test_subscription",
|
|
12633
13399
|
feature: "webhooks",
|
|
@@ -12643,7 +13409,7 @@ var TOOLS = [
|
|
|
12643
13409
|
{
|
|
12644
13410
|
name: "webhooks_test_source",
|
|
12645
13411
|
feature: "webhooks",
|
|
12646
|
-
description: "Send a sample inbound envelope to a source's forward_url through the real delivery path (jobs signing + SSRF guard). 200 \u2192 inline outcome; 202 \u2192 still in flight; 409 no_forward_url when the source has
|
|
13412
|
+
description: "Send a sample inbound envelope to a source's destination (its forward_url, or its target_function) through the real delivery path (jobs signing + SSRF guard). 200 \u2192 inline outcome; 202 \u2192 still in flight; 409 no_forward_url when the source has neither.",
|
|
12647
13413
|
inputSchema: {
|
|
12648
13414
|
type: "object",
|
|
12649
13415
|
properties: { source_id: { type: "string" } },
|
|
@@ -12672,6 +13438,25 @@ var TOOLS = [
|
|
|
12672
13438
|
method: "GET",
|
|
12673
13439
|
path: "/v1/webhooks/events/catalog"
|
|
12674
13440
|
},
|
|
13441
|
+
{
|
|
13442
|
+
name: "webhooks_list_events",
|
|
13443
|
+
feature: "webhooks",
|
|
13444
|
+
description: "Received INBOUND webhook events (what providers POSTed to your sources): event id, source, provider, provider event id, type, payload, whether the signature verified, and the forward status. Newest first, paged with `cursor` (next_cursor); or pass `after` (an event id \u2014 your watermark) to read OLDEST first from just after it, storing `next_after` (has_more says another page waits; the after drain answers only events at least 10 s old, so a stored watermark never skips a late-committing event). `event_id` reads exactly one event. Filter by source_id and status. Needs webhooks:read.",
|
|
13445
|
+
inputSchema: {
|
|
13446
|
+
type: "object",
|
|
13447
|
+
properties: {
|
|
13448
|
+
source_id: { type: "string" },
|
|
13449
|
+
status: { type: "string", enum: ["received", "forwarding", "forwarded"] },
|
|
13450
|
+
cursor: { type: "string", description: "next_cursor of the previous (newest-first) page." },
|
|
13451
|
+
after: { type: "string", description: "An event id: answer the events after it, oldest first. Exclusive with cursor." },
|
|
13452
|
+
event_id: { type: "string", description: "Exactly this event." },
|
|
13453
|
+
limit: { type: "number", default: 50, description: "1..200" }
|
|
13454
|
+
}
|
|
13455
|
+
},
|
|
13456
|
+
method: "GET",
|
|
13457
|
+
path: "/v1/webhooks/events",
|
|
13458
|
+
queryArgs: ["source_id", "status", "cursor", "after", "event_id", "limit"]
|
|
13459
|
+
},
|
|
12675
13460
|
{
|
|
12676
13461
|
name: "feeds_add_activity",
|
|
12677
13462
|
feature: "activity-feed",
|
|
@@ -12940,7 +13725,8 @@ var TOOLS = [
|
|
|
12940
13725
|
provider: { type: "string" },
|
|
12941
13726
|
model: { type: "string" },
|
|
12942
13727
|
input: { type: "object", additionalProperties: true, description: "Extra template vars beyond {{query}}/{{context}}." },
|
|
12943
|
-
user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." }
|
|
13728
|
+
user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." },
|
|
13729
|
+
min_similarity: { type: "number", minimum: -1, maximum: 1, description: "Ground only on chunks whose cosine similarity to the query is at least this (-1..1). Overrides rag config retrieval.minSimilarity." }
|
|
12944
13730
|
},
|
|
12945
13731
|
required: ["query"]
|
|
12946
13732
|
},
|
|
@@ -12950,7 +13736,7 @@ var TOOLS = [
|
|
|
12950
13736
|
{
|
|
12951
13737
|
name: "rag_search",
|
|
12952
13738
|
feature: "rag",
|
|
12953
|
-
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,
|
|
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.",
|
|
12954
13740
|
inputSchema: {
|
|
12955
13741
|
type: "object",
|
|
12956
13742
|
properties: {
|
|
@@ -12961,7 +13747,8 @@ var TOOLS = [
|
|
|
12961
13747
|
filter: { type: "object", additionalProperties: true, description: "Metadata equality filter applied to retrieval." },
|
|
12962
13748
|
rerank: { type: "boolean", description: "Forward rerank:true to vector-search's BYO rerank pass (inert until configured there)." },
|
|
12963
13749
|
boosts: { type: "object", additionalProperties: true, description: "Per-metadata-field boost specs: { field: { kind:'recency', halfLifeDays?, weight? } | { kind:'value', weights:{\u2026} } }." },
|
|
12964
|
-
min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score. score is a rank value (about 0.016-0.033 at the top), so a floor above ~0.033 drops every hit;
|
|
13750
|
+
min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score. score is a rank value (about 0.016-0.033 at the top), so a floor above ~0.033 drops every hit; use min_similarity for a relevance threshold." },
|
|
13751
|
+
min_similarity: { type: "number", minimum: -1, maximum: 1, description: "Drop hits whose cosine similarity to the query is below this (-1..1), e.g. 0.5. Overrides rag config retrieval.minSimilarity; a hit with no measured similarity (keyword mode) is kept." }
|
|
12965
13752
|
},
|
|
12966
13753
|
required: ["query"]
|
|
12967
13754
|
},
|
|
@@ -13082,6 +13869,41 @@ var TOOLS = [
|
|
|
13082
13869
|
method: "GET",
|
|
13083
13870
|
path: "/v1/usage"
|
|
13084
13871
|
},
|
|
13872
|
+
{
|
|
13873
|
+
name: "audit_query",
|
|
13874
|
+
description: "The project's audit trail, newest first: every write and lifecycle event (id, event, actor, surface, request_id, payload, created_at), paged with `cursor` (next_cursor). `subject` narrows to the events about ONE thing: a user id (payload.user_id / payload.id), a cms record (payload.item_id \u2014 the record's history), a verified end user (payload.end_user_id \u2014 what that person changed through a thin-client key or a function acting for them, and their payments events), or a principal (actor). Read-only; server keys only (refused in end-user mode). Needs audit:read or features:read.",
|
|
13875
|
+
inputSchema: {
|
|
13876
|
+
type: "object",
|
|
13877
|
+
properties: {
|
|
13878
|
+
subject: { type: "string", description: "A user id, cms item id, end-user id or actor to filter by." },
|
|
13879
|
+
since: { type: "string", description: "ISO timestamp lower bound." },
|
|
13880
|
+
cursor: { type: "string", description: "next_cursor of the previous page." },
|
|
13881
|
+
limit: { type: "number", default: 50, description: "1..200" }
|
|
13882
|
+
}
|
|
13883
|
+
},
|
|
13884
|
+
method: "GET",
|
|
13885
|
+
path: "/v1/audit",
|
|
13886
|
+
queryArgs: ["subject", "since", "cursor", "limit"]
|
|
13887
|
+
},
|
|
13888
|
+
{
|
|
13889
|
+
name: "audit_export",
|
|
13890
|
+
description: "Export the audit trail OLDEST first as NDJSON (one event per line) for a window \u2014 the evidence/compliance read. Resume with `after_id` = the id of the last line. Same `subject` filter as audit_query. At most 1000 rows per call from this tool (default 500) so the answer fits an agent's context; page with after_id. Read-only; server keys only. Needs audit:read or features:read.",
|
|
13891
|
+
inputSchema: {
|
|
13892
|
+
type: "object",
|
|
13893
|
+
properties: {
|
|
13894
|
+
subject: { type: "string", description: "A user id, cms item id, end-user id or actor to filter by." },
|
|
13895
|
+
since: { type: "string", description: "ISO timestamp lower bound (inclusive)." },
|
|
13896
|
+
until: { type: "string", description: "ISO timestamp upper bound (exclusive)." },
|
|
13897
|
+
after_id: { type: "string", description: "Continue after this audit id (the last line of the previous call)." },
|
|
13898
|
+
limit: { type: "number", default: 500, description: "1..1000" }
|
|
13899
|
+
}
|
|
13900
|
+
},
|
|
13901
|
+
method: "GET",
|
|
13902
|
+
// The REST route allows 10 000 rows; an MCP answer is model context, so
|
|
13903
|
+
// the tool clamps the page (auditExportQuery builds the whole query; the
|
|
13904
|
+
// path stays one literal so the trinity-drift scanner reads the route).
|
|
13905
|
+
path: (a) => `/v1/audit/export?${auditExportQuery(a)}`
|
|
13906
|
+
},
|
|
13085
13907
|
{
|
|
13086
13908
|
name: "copilot_send_message",
|
|
13087
13909
|
feature: "copilot",
|
|
@@ -13777,6 +14599,22 @@ function parseKeyEnv(raw) {
|
|
|
13777
14599
|
if (raw === "live" || raw === "test") return { env: raw };
|
|
13778
14600
|
return { error: `--env must be 'live' or 'test' (got '${raw}') \u2014 this is the key's env prefix, not the project's environment label` };
|
|
13779
14601
|
}
|
|
14602
|
+
function parseKeyClass(rawClass, endUserRequired) {
|
|
14603
|
+
if (rawClass !== void 0 && rawClass !== "server" && rawClass !== "public") {
|
|
14604
|
+
return { error: `--class must be 'server' or 'public' (got '${rawClass}') \u2014 public = a thin-client key for a browser / mobile app (end_user_required)` };
|
|
14605
|
+
}
|
|
14606
|
+
if (rawClass === "server" && endUserRequired) {
|
|
14607
|
+
return { error: "--end-user-required makes a public (thin-client) key \u2014 drop it, or use --class public" };
|
|
14608
|
+
}
|
|
14609
|
+
return { keyClass: endUserRequired ? "public" : rawClass ?? "server" };
|
|
14610
|
+
}
|
|
14611
|
+
function checkPublicKeyScopes(scopes) {
|
|
14612
|
+
const denied = scopes.filter((s) => PUBLIC_KEY_DENY_SCOPES.has(s));
|
|
14613
|
+
if (!denied.length) return null;
|
|
14614
|
+
return {
|
|
14615
|
+
error: `a public (thin-client) key cannot carry server-only scope${denied.length > 1 ? "s" : ""}: ${denied.join(", ")} \u2014 keep the public key to end-user data scopes (e.g. cms:read,cms:write,files:write,realtime:write,auth:signin) and mint a separate server key for your backend`
|
|
14616
|
+
};
|
|
14617
|
+
}
|
|
13780
14618
|
function parseKeyName(raw) {
|
|
13781
14619
|
const name = (raw ?? "").trim();
|
|
13782
14620
|
if (!name) return { error: "--name is required \u2014 a label for this key, e.g. --name web-worker" };
|
|
@@ -13787,7 +14625,7 @@ async function mintKeyViaSession(dash, cookie, tenantId, req) {
|
|
|
13787
14625
|
const r = await dash(
|
|
13788
14626
|
"POST",
|
|
13789
14627
|
`/dashboard/tenants/${tenantId}/api-keys`,
|
|
13790
|
-
{ name: req.name, scopes: req.scopes, env: req.env },
|
|
14628
|
+
{ name: req.name, scopes: req.scopes, env: req.env, ...req.keyClass === "public" ? { end_user_required: true } : {} },
|
|
13791
14629
|
cookie
|
|
13792
14630
|
);
|
|
13793
14631
|
if (r.status === 401) return { unauthorized: true };
|
|
@@ -13803,7 +14641,10 @@ async function mintKeyViaSession(dash, cookie, tenantId, req) {
|
|
|
13803
14641
|
scopes: d.scopes ?? req.scopes,
|
|
13804
14642
|
env: d.env ?? req.env,
|
|
13805
14643
|
name: req.name,
|
|
13806
|
-
...d.prefix ? { prefix: d.prefix } : {}
|
|
14644
|
+
...d.prefix ? { prefix: d.prefix } : {},
|
|
14645
|
+
// report ONLY what the server says it minted: a stale server that
|
|
14646
|
+
// ignored the flag (and so omits the field) must not read as public
|
|
14647
|
+
...d.end_user_required === true ? { end_user_required: true } : {}
|
|
13807
14648
|
}
|
|
13808
14649
|
};
|
|
13809
14650
|
}
|
|
@@ -13874,7 +14715,7 @@ function revokeGuard(args) {
|
|
|
13874
14715
|
return { ok: true, row: row2, isCurrent };
|
|
13875
14716
|
}
|
|
13876
14717
|
function mintStdout(r, json2) {
|
|
13877
|
-
return json2 ? JSON.stringify({ api_key: r.api_key, key_id: r.key_id, scopes: r.scopes }) : r.api_key;
|
|
14718
|
+
return json2 ? JSON.stringify({ api_key: r.api_key, key_id: r.key_id, scopes: r.scopes, ...r.end_user_required ? { end_user_required: true } : {} }) : r.api_key;
|
|
13878
14719
|
}
|
|
13879
14720
|
function mintNotices(r, json2, opts = {}) {
|
|
13880
14721
|
const storage = opts.stored ? "It was stored as this project's CLI key (~/.vxil/credentials.json), replacing the previous one." : "It was NOT stored on this machine (pass --store to make it this project's CLI key).";
|
|
@@ -13886,7 +14727,8 @@ function mintNotices(r, json2, opts = {}) {
|
|
|
13886
14727
|
];
|
|
13887
14728
|
}
|
|
13888
14729
|
return [
|
|
13889
|
-
`\u2713 minted '${r.name}' (${r.key_id}, env ${r.env}) with scopes [${r.scopes.join(", ")}]`,
|
|
14730
|
+
`\u2713 minted '${r.name}' (${r.key_id}, env ${r.env}${r.end_user_required ? ", public / end_user_required" : ""}) with scopes [${r.scopes.join(", ")}]`,
|
|
14731
|
+
...r.end_user_required ? ["A public key belongs in your browser / mobile app: every request must carry the signed-in user's session (X-Vxil-End-User), and it acts only as that user."] : [],
|
|
13890
14732
|
`This key is shown ONCE and is not retrievable again \u2014 copy it now. ${storage} Revoke with \`vxil keys revoke ${r.key_id}\`.`,
|
|
13891
14733
|
...storeWarn
|
|
13892
14734
|
];
|
|
@@ -14392,7 +15234,7 @@ var VERB_USAGE = {
|
|
|
14392
15234
|
projects: "usage: vxil projects [list] [--json] \xB7 projects create <slug> [--name <display>] [--workload production|staging|development] [--json] \xB7 projects workload <slug> production|staging|development [--json] \xB7 projects rm <slug> [--yes] [--json]",
|
|
14393
15235
|
billing: "usage: vxil billing [status] [--json] \xB7 vxil billing upgrade --tier <free|developer|team|business> [--yes] [--json] (upgrade also downgrades: --tier free)",
|
|
14394
15236
|
files: "usage: vxil files put <path> --user <user_id> [--content-type <type>] [--json] \xB7 vxil files rm <object_id> [--yes] [--json]",
|
|
14395
|
-
webhooks: "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic> --name <n> [--forward-url <https>] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>",
|
|
15237
|
+
webhooks: "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic|hmac> --name <n> [--forward-url <https> | --target-function <fn>] [--verify <json> (hmac)] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>",
|
|
14396
15238
|
search: "usage: vxil search sync run <sourceKey> [--sweep] [--json] \xB7 vxil search sync reset <sourceKey> [--purge] [--yes] [--json]",
|
|
14397
15239
|
listen: "usage: vxil listen --forward-to <url> [--dev] [--source <id>] [--events <prefix,\u2026>] [--replay-last <n>] [--interval <s>] [--show-runs] [--raw] \u2014 forward inbound webhook events to a local server",
|
|
14398
15240
|
mcp: "usage: vxil mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <api_key>] [--scopes a,b] [--name <server>]",
|
|
@@ -17542,12 +18384,13 @@ async function runListen(deps, opts) {
|
|
|
17542
18384
|
if (sres.body.error || sres.status >= 400) throw envelopeError(sres, "list webhook sources");
|
|
17543
18385
|
let tenantId = sres.body.meta?.tenant_id;
|
|
17544
18386
|
const sources = sres.body.data?.sources ?? [];
|
|
18387
|
+
const destOf = new Map(sources.map((s) => [s.source_id, s.target_function ? `fn:${s.target_function}` : s.forward_url || null]));
|
|
17545
18388
|
if (sources.length === 0) {
|
|
17546
18389
|
deps.err("no webhook sources yet \u2014 create one (dashboard \u2192 Webhooks, or POST /v1/webhooks/sources) and point the provider at its receiver URL; waiting for events anyway\u2026");
|
|
17547
18390
|
} else {
|
|
17548
18391
|
deps.err(`sources (${sources.length}):`);
|
|
17549
18392
|
for (const s of sources) {
|
|
17550
|
-
deps.err(` ${s.source_id} ${s.provider} ${s.name}${s.
|
|
18393
|
+
deps.err(` ${s.source_id} ${s.provider} ${s.name}${destOf.get(s.source_id)?.startsWith("fn:") ? ` \u2192 ${destOf.get(s.source_id)}` : ""}${destOf.get(s.source_id) ? "" : " (no forward_url \u2014 this listener is the only delivery)"}`);
|
|
17551
18394
|
}
|
|
17552
18395
|
}
|
|
17553
18396
|
let signingSecret;
|
|
@@ -17593,7 +18436,8 @@ async function runListen(deps, opts) {
|
|
|
17593
18436
|
} else {
|
|
17594
18437
|
const runs = rr.body.data?.runs ?? [];
|
|
17595
18438
|
if (runs.length === 0) {
|
|
17596
|
-
|
|
18439
|
+
const dest = ev.source_id ? destOf.get(ev.source_id) : void 0;
|
|
18440
|
+
deps.err(dest ? ` runs: none yet (the source delivers to ${dest})` : " runs: none (the source has no forward_url \u2014 this listener is the only delivery)");
|
|
17597
18441
|
} else {
|
|
17598
18442
|
for (const r of runs) {
|
|
17599
18443
|
deps.err(` run ${r.run_id}: ${r.state} (attempt ${r.attempt_number ?? 0}/${r.max_attempts ?? "?"}${r.dead_lettered ? ", DLQ" : ""})`);
|
|
@@ -18601,12 +19445,12 @@ export default defineConfig({
|
|
|
18601
19445
|
"render_token",
|
|
18602
19446
|
"vxil_jobs_key"
|
|
18603
19447
|
],
|
|
18604
|
-
"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 // \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",
|
|
18605
|
-
"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. The\n credits it holds are the 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## 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). 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 when vxil stops waiting \u2014 to the second: any callback that arrives at or after it 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. 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`). A `402`\n fails the row with `insufficient_credits`. A `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",
|
|
19448
|
+
"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 will answer 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",
|
|
19449
|
+
"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 will answer `422\n idempotency_key_reused`; that check is being rolled out, and today a mismatch is only logged, on\n every project, and still handed the first run (a changelog entry will announce enforcement). So nothing in the 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. Today that is only logged and the\nexisting run is handed back; once the check is enforced, 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",
|
|
18606
19450
|
"functions": {
|
|
18607
19451
|
"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",
|
|
18608
|
-
"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// 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// 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 };\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 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 ?? {},\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 code = enq.status === 429 || (enq.status >= 400 && enq.status !== 402)\n ? ((await enq.json().catch(() => ({}))) as { error?: { code?: string } }).error?.code ?? null\n : null;\n if (enq.status === 429 || (enq.status >= 400 && enq.status < 500) || code === 'payments_unavailable') {\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}\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: {\n generation_id: a.itemId, correlation_id: a.requestKey,\n deadline_at: new Date(Date.now() + RENDER_DEADLINE_MS).toISOString(),\n },\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",
|
|
18609
|
-
"request-render.ts": "// request-render.ts \u2014 start ONE long render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/request-render (with the user's session)\n// { \"composition\": \"promo-30s\", \"request_key\": \"<your idempotency key>\", \"props\": { \u2026 } }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, status }\n// this user's request_key already started one\n// (or its run has already ended: status 'failed')\n// \u2192 402 { error: 'insufficient_credits', item_id } nothing held; use a new request_key\n// \u2192 429 / 503 / 502 { error, queued: true, item_id, request_key, retry_after }\n// no run YET (429 at the cap, 503 credits\n// could not be held right now \u2014 nothing\n// held): the row is queued and\n// redrive-pending starts it (credits\n// held then). Never retry with a NEW\n// request_key \u2014 that is a second render.\n//\n// What happens after the 202 is vxil's and your runtime's, not this function's:\n// \u2022 the generation lane POSTs your render endpoint (secret render_url) with\n// `Authorization: Bearer <render_token>` and the JSON body\n// { render_id, user_id, composition, props,\n// payload: { generation_id, correlation_id, deadline_at }, callback_url }\n// (plus an X-Vxil-Jobs-Signature header). The endpoint must answer 2xx\n// within 20 s \u2014 queue the work, do not render inline. A 408 / 429 / 5xx or\n// a timeout is retried; any other 4xx ends the run and refunds the credits.\n// \u2022 your runtime POSTs JSON to callback_url: { \"status\": \"processing\", \u2026 }\n// while it works, then { \"status\": \"completed\", \"output_url\": \"\u2026\",\n// \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\" } \u2014 or\n// { \"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"\u2026\" }.\n// \u2022 vxil settles: completed COMMITS the held credits and writes every key of\n// the body onto this render's row; failed, no answer by the deadline, or a\n// cancel REFUNDS them and the row says failed. `deadline_at` (ISO time) is\n// when vxil stops waiting: a runtime that cannot finish by then should post\n// `failed` itself and stop \u2014 a `completed` after it changes nothing (the\n// credits are already back and the row says failed).\n// Every run ends with job.generation.completed | failed (generation_id = the\n// row's item_id, correlation_id = its request_key) \u2014 notify-ready listens.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's render_key\n// (owner + ':' + request_key \u2014 per user) is unique, so a second start with the\n// same key is a 409. On a 409 we read THIS user's row: a row that already has\n// its run is a duplicate; a row with no run (the first start died between the\n// row and the enqueue, or lost the enqueue's answer) is RE-DRIVEN \u2014 the run\n// carries the same render_key as its idempotency_key, so jobs hands back the\n// existing run instead of starting a second render.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { composition?: string; request_key?: string; props?: Record<string, unknown> };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = {\n item_id: string;\n data: { composition?: string; props?: Record<string, unknown>; run_id?: string; status?: string };\n};\n\n/** What one render costs, in `render_credits`. Price by composition if yours\n * differ \u2014 the per-run hold is clamped to `generation.maxReserveCredits`. */\nconst RENDER_CREDITS = 5;\n/** The deadline: a render your runtime never reports on fails and refunds\n * after this long. At most one hour (`generation.maxTimeoutMs`). */\nconst RENDER_DEADLINE_MS = 1_800_000;\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 if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n const renderUrl = env.secrets?.render_url;\n const renderToken = env.secrets?.render_token;\n if (!renderUrl || !renderToken) {\n return Response.json(\n { error: 'store your render endpoint: vxil secrets set functions/render_url and functions/render_token' },\n { status: 500 },\n );\n }\n // the held credits are FORCED onto the verified end-user\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: \"invoke request-render with the user's session (end-user mode)\" }, { status: 401 });\n\n const composition = typeof env.payload?.composition === 'string' ? env.payload.composition.trim().slice(0, 120) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n const rawProps = env.payload?.props;\n const props = rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps) ? rawProps : {};\n if (!composition || !requestKey) {\n return Response.json({ error: 'need { composition, request_key, props? }' }, { status: 422 });\n }\n if (JSON.stringify(props).length > 16_384) {\n return Response.json({ error: 'props too large (16 KB max) \u2014 pass a reference to your own storage instead' }, { status: 413 });\n }\n const renderKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // 1. create-or-find the row the app watches (owned by the user \u2014 cms forces\n // `owner` in end-user mode; the render_key_shape hook checks the key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n render_key: renderKey, request_key: requestKey, owner: user, composition, props,\n credits: RENDER_CREDITS, 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({ render_key: renderKey }));\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.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, status: row.data.status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on render_key)\n return startRun({\n base, H, jobs, renderUrl, renderToken, user, renderKey, requestKey,\n itemId: row.item_id, composition: row.data.composition ?? composition, props: row.data.props ?? props,\n });\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, renderUrl, renderToken, user, renderKey, requestKey, itemId, composition, props });\n },\n};\n\ninterface StartArgs {\n base: string; H: Record<string, string>; jobs: string; renderUrl: string; renderToken: string;\n user: string; renderKey: string; requestKey: string; itemId: string;\n composition: string; props: Record<string, unknown>;\n}\n\n/** 2. the webhook-mode generation run: your endpoint, the held credits, the\n * deadline and the status mirror. Idempotent on render_key: a re-drive gets\n * the run that already exists. */\nasync function startRun(a: StartArgs): Promise<Response> {\n const enq = await fetch(`${a.base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${a.jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: 'render',\n provider: {\n url: a.renderUrl,\n method: 'POST',\n // stored with the run for your project only, shown as [redacted] on\n // every run read, sent to your endpoint on the start call\n headers: { authorization: `Bearer ${a.renderToken}` },\n // your endpoint receives this, plus `payload` and `callback_url`\n // (user_id: the owner a coordinator uploads the output file for)\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 // the status word onto `status`, and a `processing` ping's progress /\n // stage / message onto the same-named fields of the row\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 // rides job.generation.* as generation_id / correlation_id, and reaches\n // your endpoint beside callback_url. deadline_at = when vxil stops\n // waiting (the deadline counts from this enqueue; a re-drive gets the\n // first run back, with ITS payload).\n payload: {\n generation_id: a.itemId, correlation_id: a.requestKey,\n deadline_at: new Date(Date.now() + RENDER_DEADLINE_MS).toISOString(),\n },\n idempotency_key: a.renderKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held. This key is spent; a new\n // attempt (after a top-up) uses a new request_key.\n const marked = await patchRow(a, { status: 'failed', error: 'insufficient_credits' });\n // if that write failed the row still says pending with no run; a retry with\n // the same key re-drives, gets the ended run back and marks it failed then\n return Response.json(\n { error: 'insufficient_credits', item_id: a.itemId, ...(marked ? {} : { row_updated: false }) },\n { status: 402 },\n );\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 503 payments_unavailable (the credits could\n // not be held right now \u2014 nothing was started or held; the same key starts\n // a fresh run later) / 5xx: no run YET \u2014 the row stays pending with\n // no run, and it is QUEUED: redrive-pending (cron) starts it once a slot\n // frees up (within the hour, or the row is failed and the owner told) and\n // holds the credits then. `queued: true` says so. The app must NOT retry\n // with a NEW request_key (that is a second render, charged twice): show\n // \"queued\", watch the row, and retry only with the SAME request_key.\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, queued: true, item_id: a.itemId, request_key: a.requestKey, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 || enq.status === 503 ? enq.status : 502 },\n );\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 // a re-drive whose run already ENDED (refused for credits, failed or timed\n // out, and the row write that said so was lost): record it and say so \u2014\n // never report a fresh render with credits held\n const marked = await patchRow(a, { run_id: run.run_id, status: 'failed' });\n return Response.json({\n duplicate: true, request_key: a.requestKey, item_id: a.itemId, run_id: run.run_id, status: 'failed',\n ...(marked ? {} : { row_updated: false }),\n });\n }\n const linked = await patchRow(a, { run_id: run.run_id });\n // the run exists either way (and settles the row through the status mirror);\n // an unlinked row is linked by the next same-key call\n return Response.json(\n { item_id: a.itemId, run_id: run.run_id, credits: RENDER_CREDITS, ...(linked ? {} : { row_updated: false }) },\n { status: 202 },\n );\n}\n\n/** PATCH this render's row; true when the write landed. */\nasync function patchRow(a: StartArgs, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${a.base}/v1/cms/items/renders/${encodeURIComponent(a.itemId)}`, {\n method: 'PATCH',\n headers: a.H,\n body: JSON.stringify({ data }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n"
|
|
19452
|
+
"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 will answer 422 idempotency_key_reused once that check is\n// enforced (today a mismatch is only logged, on every project). 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",
|
|
19453
|
+
"request-render.ts": "// request-render.ts \u2014 start ONE long render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/request-render (with the user's session)\n// { \"composition\": \"promo-30s\", \"request_key\": \"<your idempotency key>\", \"props\": { \u2026 } }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, status }\n// this user's request_key already started one\n// (or its run has already ended: status 'failed')\n// \u2192 402 { error: 'insufficient_credits', item_id } nothing held; use a new request_key\n// \u2192 429 / 503 / 502 { error, queued: true, item_id, request_key, retry_after }\n// no run YET (429 at the cap, 503 credits\n// could not be held right now \u2014 nothing\n// held): the row is queued and\n// redrive-pending starts it (credits\n// held then). Never retry with a NEW\n// request_key \u2014 that is a second render.\n//\n// What happens after the 202 is vxil's and your runtime's, not this function's:\n// \u2022 the generation lane POSTs your render endpoint (secret render_url) with\n// `Authorization: Bearer <render_token>` and the JSON body\n// { render_id, user_id, composition, props,\n// payload: { generation_id, correlation_id, deadline_at }, callback_url }\n// (plus an X-Vxil-Jobs-Signature header). The endpoint must answer 2xx\n// within 20 s \u2014 queue the work, do not render inline. A 408 / 429 / 5xx or\n// a timeout is retried; any other 4xx ends the run and refunds the credits.\n// \u2022 your runtime POSTs JSON to callback_url: { \"status\": \"processing\", \u2026 }\n// while it works, then { \"status\": \"completed\", \"output_url\": \"\u2026\",\n// \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\" } \u2014 or\n// { \"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"\u2026\" }.\n// \u2022 vxil settles: completed COMMITS the held credits and writes every key of\n// the body onto this render's row; failed, no answer by the deadline, or a\n// cancel REFUNDS them and the row says failed. `deadline_at` (ISO time) is\n// the row's stored deadline \u2014 never later than vxil's own stop time (the\n// run's timeout counted from that run's enqueue), and on a re-driven row\n// possibly earlier, even already past: a runtime that cannot finish by then should post\n// `failed` itself and stop \u2014 a `completed` after it changes nothing (the\n// credits are already back and the row says failed).\n// Every run ends with job.generation.completed | failed (generation_id = the\n// row's item_id, correlation_id = its request_key) \u2014 notify-ready listens.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's render_key\n// (owner + ':' + request_key \u2014 per user) is unique, so a second start with the\n// same key is a 409. On a 409 we read THIS user's row: a row that already has\n// its run is a duplicate; a row with no run (the first start died between the\n// row and the enqueue, or lost the enqueue's answer) is RE-DRIVEN \u2014 the run\n// carries the same render_key as its idempotency_key, so jobs hands back the\n// existing run instead of starting a second render.\n//\n// THE BODY IS PART OF THE KEY: jobs hands the existing run back only to a\n// re-send of the SAME request \u2014 compared as a canonical-JSON fingerprint\n// (defaults applied, keys sorted, provider/poll header VALUES left out), not\n// byte-for-byte. A different body under a used key will answer 422\n// idempotency_key_reused (being rolled out: today a mismatch is only logged,\n// on every project, and still handed the first run). So nothing in the descriptor may be rebuilt per send:\n// the run's `deadline_at` is computed ONCE and kept on the row (`deadline_at`),\n// and every start of this row \u2014 this function's re-drive and redrive-pending's\n// \u2014 sends the stored value. It is cleared (null) only when jobs says no run\n// holds the key (429, or 503 payments_unavailable), so the next start computes\n// a fresh one.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { composition?: string; request_key?: string; props?: Record<string, unknown> };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = {\n item_id: string;\n data: {\n composition?: string; props?: Record<string, unknown>; run_id?: string; status?: string;\n deadline_at?: string | null;\n };\n};\n\n/** What one render costs, in `render_credits`. Price by composition if yours\n * differ \u2014 the per-run hold is clamped to `generation.maxReserveCredits`. */\nconst RENDER_CREDITS = 5;\n/** The deadline: a render your runtime never reports on fails and refunds\n * after this long. At most one hour (`generation.maxTimeoutMs`). */\nconst RENDER_DEADLINE_MS = 1_800_000;\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 if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n const renderUrl = env.secrets?.render_url;\n const renderToken = env.secrets?.render_token;\n if (!renderUrl || !renderToken) {\n return Response.json(\n { error: 'store your render endpoint: vxil secrets set functions/render_url and functions/render_token' },\n { status: 500 },\n );\n }\n // the held credits are FORCED onto the verified end-user\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: \"invoke request-render with the user's session (end-user mode)\" }, { status: 401 });\n\n const composition = typeof env.payload?.composition === 'string' ? env.payload.composition.trim().slice(0, 120) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n const rawProps = env.payload?.props;\n const props = rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps) ? rawProps : {};\n if (!composition || !requestKey) {\n return Response.json({ error: 'need { composition, request_key, props? }' }, { status: 422 });\n }\n if (JSON.stringify(props).length > 16_384) {\n return Response.json({ error: 'props too large (16 KB max) \u2014 pass a reference to your own storage instead' }, { status: 413 });\n }\n const renderKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = Date.now();\n // computed ONCE, stored on the row, re-sent unchanged by every start of it\n const deadlineAt = new Date(now + RENDER_DEADLINE_MS).toISOString();\n\n // 1. create-or-find the row the app watches (owned by the user \u2014 cms forces\n // `owner` in end-user mode; the render_key_shape hook checks the key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n render_key: renderKey, request_key: requestKey, owner: user, composition, props,\n credits: RENDER_CREDITS, status: 'pending', created_at: new Date(now).toISOString(),\n deadline_at: deadlineAt,\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({ render_key: renderKey }));\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.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, status: row.data.status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on render_key)\n // with the row's STORED deadline \u2014 the same body the first start sent.\n // None stored (no run holds the key, or a row from before this field):\n // store a fresh one BEFORE sending, so a lost answer is re-sent the same.\n const a: StartArgs = {\n base, H, jobs, renderUrl, renderToken, user, renderKey, requestKey,\n itemId: row.item_id, composition: row.data.composition ?? composition, props: row.data.props ?? props,\n deadlineAt: row.data.deadline_at ?? deadlineAt,\n };\n // (only while none is stored \u2014 a racing redrive-pending may have stored its own)\n if (!row.data.deadline_at && !(await patchRow(a, { deadline_at: a.deadlineAt }, { deadline_at: null }))) {\n return Response.json(\n { error: 'render row: could not store the deadline', queued: true, item_id: row.item_id, request_key: requestKey, retry_after: null },\n { status: 502 },\n );\n }\n return startRun(a);\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, renderUrl, renderToken, user, renderKey, requestKey, itemId, composition, props, deadlineAt });\n },\n};\n\ninterface StartArgs {\n base: string; H: Record<string, string>; jobs: string; renderUrl: string; renderToken: string;\n user: string; renderKey: string; requestKey: string; itemId: 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/** 2. the webhook-mode generation run: your endpoint, the held credits, the\n * deadline and the status mirror. Idempotent on render_key: a re-drive gets\n * the run that already exists. */\nasync function startRun(a: StartArgs): Promise<Response> {\n const enq = await fetch(`${a.base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${a.jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: 'render',\n provider: {\n url: a.renderUrl,\n method: 'POST',\n // stored with the run for your project only, shown as [redacted] on\n // every run read, sent to your endpoint on the start call\n headers: { authorization: `Bearer ${a.renderToken}` },\n // your endpoint receives this, plus `payload` and `callback_url`\n // (user_id: the owner a coordinator uploads the output file for)\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 // the status word onto `status`, and a `processing` ping's progress /\n // stage / message onto the same-named fields of the row\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 // rides job.generation.* as generation_id / correlation_id, and reaches\n // your endpoint beside callback_url. deadline_at = the row's STORED\n // value (computed once \u2014 a value rebuilt per send would make every\n // re-drive a different request, which will answer 422\n // idempotency_key_reused instead of the run that exists). It can be\n // earlier than vxil's real stop time on a re-driven row, never later.\n payload: { generation_id: a.itemId, correlation_id: a.requestKey, deadline_at: a.deadlineAt },\n idempotency_key: a.renderKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held. This key is spent; a new\n // attempt (after a top-up) uses a new request_key.\n const marked = await patchRow(a, { status: 'failed', error: 'insufficient_credits' });\n // if that write failed the row still says pending with no run; a retry with\n // the same key re-drives, gets the ended run back and marks it failed then\n return Response.json(\n { error: 'insufficient_credits', item_id: a.itemId, ...(marked ? {} : { row_updated: false }) },\n { status: 402 },\n );\n }\n if (enq.status === 422) {\n // idempotency_key_reused naming THIS user's run: a run of this row's key\n // exists under another body (a start that raced a deadline reset, or a row\n // from before the stored deadline) \u2014 it IS this render: link it\n const e = ((await enq.clone().json().catch(() => ({}))) as { error?: { code?: string; run_id?: string } }).error;\n if (e?.code === 'idempotency_key_reused' && e.run_id) {\n const linked = await patchRow(a, { run_id: e.run_id });\n return Response.json({\n duplicate: true, request_key: a.requestKey, item_id: a.itemId, run_id: e.run_id, status: 'pending',\n ...(linked ? {} : { row_updated: false }),\n });\n }\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 503 payments_unavailable (the credits could\n // not be held right now \u2014 nothing was started or held; the same key starts\n // a fresh run later) / 5xx: no run YET \u2014 the row stays pending with\n // no run, and it is QUEUED: redrive-pending (cron) starts it once a slot\n // frees up (within the hour, or the row is failed and the owner told) and\n // holds the credits then. `queued: true` says so. The app must NOT retry\n // with a NEW request_key (that is a second render, charged twice): show\n // \"queued\", watch the row, and retry only with the SAME request_key.\n // 429 / 503 payments_unavailable: jobs holds NO run under this key \u2014 clear\n // the stored deadline so the next start computes a fresh one (any other\n // answer may have left a run behind: keep it, so a re-send matches).\n const code = enq.status === 503\n ? ((await enq.clone().json().catch(() => ({}))) as { error?: { code?: string } }).error?.code\n : undefined;\n if (enq.status === 429 || code === 'payments_unavailable') await patchRow(a, { deadline_at: null });\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, queued: true, item_id: a.itemId, request_key: a.requestKey, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 || enq.status === 503 ? enq.status : 502 },\n );\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 // a re-drive whose run already ENDED (refused for credits, failed or timed\n // out, and the row write that said so was lost): record it and say so \u2014\n // never report a fresh render with credits held\n const marked = await patchRow(a, { run_id: run.run_id, status: 'failed' });\n return Response.json({\n duplicate: true, request_key: a.requestKey, item_id: a.itemId, run_id: run.run_id, status: 'failed',\n ...(marked ? {} : { row_updated: false }),\n });\n }\n const linked = await patchRow(a, { run_id: run.run_id });\n // the run exists either way (and settles the row through the status mirror);\n // an unlinked row is linked by the next same-key call\n return Response.json(\n { item_id: a.itemId, run_id: run.run_id, credits: RENDER_CREDITS, ...(linked ? {} : { row_updated: false }) },\n { status: 202 },\n );\n}\n\n/** PATCH this render's row (optionally only while `when` still holds); true when the write landed. */\nasync function patchRow(a: StartArgs, data: Record<string, unknown>, when?: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${a.base}/v1/cms/items/renders/${encodeURIComponent(a.itemId)}`, {\n method: 'PATCH',\n headers: a.H,\n body: JSON.stringify(when ? { data, if: when } : { data }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n"
|
|
18610
19454
|
}
|
|
18611
19455
|
},
|
|
18612
19456
|
{
|
|
@@ -20565,6 +21409,18 @@ async function runApply(apply, sel = "prod", opts = {}) {
|
|
|
20565
21409
|
console.error(`vxil: warning \u2014 ${msg}`);
|
|
20566
21410
|
}
|
|
20567
21411
|
}
|
|
21412
|
+
{
|
|
21413
|
+
const typed = hookTypePreflight(cfg);
|
|
21414
|
+
for (const w of typed.warnings) console.error(`vxil: warning \u2014 cms ${w}`);
|
|
21415
|
+
if (typed.errors.length) {
|
|
21416
|
+
const msg = `cms hooks that can never work on the declared fields:
|
|
21417
|
+
${typed.errors.join("\n ")}`;
|
|
21418
|
+
if (apply) fail(`${msg}
|
|
21419
|
+
(nothing was pushed)`);
|
|
21420
|
+
console.error(`vxil: ${msg}`);
|
|
21421
|
+
if (gate) report.errors++;
|
|
21422
|
+
}
|
|
21423
|
+
}
|
|
20568
21424
|
const notCompared = (u, hint) => {
|
|
20569
21425
|
console.log(formatNotCompared(u, hint));
|
|
20570
21426
|
if (gate) report.errors++;
|
|
@@ -20961,6 +21817,15 @@ ${formatTriggerWiring(w).join("\n")}`);
|
|
|
20961
21817
|
if (hasFlag("skip-functions") && declaredFns && Object.keys(declaredFns).length) {
|
|
20962
21818
|
say(`functions: skipped (--skip-functions) \u2014 ${Object.keys(declaredFns).length} declared function(s) not deployed`);
|
|
20963
21819
|
}
|
|
21820
|
+
{
|
|
21821
|
+
const typed = hookTypePreflight(cfg);
|
|
21822
|
+
for (const w of typed.warnings) say(`vxil: warning \u2014 cms ${w}`);
|
|
21823
|
+
if (typed.errors.length) {
|
|
21824
|
+
fail(`cms hooks that can never work on the declared fields:
|
|
21825
|
+
${typed.errors.join("\n ")}
|
|
21826
|
+
(nothing was applied)`);
|
|
21827
|
+
}
|
|
21828
|
+
}
|
|
20964
21829
|
if (fnNames.length) {
|
|
20965
21830
|
const ent = await probeFunctionsEntitlement(api, fnNames[0]);
|
|
20966
21831
|
if (!ent.entitled) {
|
|
@@ -21658,12 +22523,15 @@ try {
|
|
|
21658
22523
|
}
|
|
21659
22524
|
case "keys": {
|
|
21660
22525
|
const [sub, arg] = positional(0);
|
|
21661
|
-
const KEYS_USAGE = "usage: vxil keys mint --name <name> --scopes <a,b,c> [--env live|test] [--store] [--json] \xB7 vxil keys list [--json] \xB7 vxil keys revoke <key_id> [--yes] \xB7 vxil keys perms <key_id> [--allow <a,b> | --clear-allow] [--deny <c> | --clear-deny] [--json]";
|
|
22526
|
+
const KEYS_USAGE = "usage: vxil keys mint --name <name> --scopes <a,b,c> [--env live|test] [--class server|public] [--end-user-required] [--store] [--json] \xB7 vxil keys list [--json] \xB7 vxil keys revoke <key_id> [--yes] \xB7 vxil keys perms <key_id> [--allow <a,b> | --clear-allow] [--deny <c> | --clear-deny] [--json]";
|
|
21662
22527
|
if (sub === "help" || hasFlag("help")) {
|
|
21663
22528
|
console.log(`${KEYS_USAGE}
|
|
21664
22529
|
mint a NARROW scoped key for a server on the linked project \u2014 scopes are validated
|
|
21665
22530
|
locally (admin refused); the key prints ONCE on stdout (--json \u2192 { api_key, key_id,
|
|
21666
|
-
scopes }) and is stored as this machine's CLI key only under --store
|
|
22531
|
+
scopes }) and is stored as this machine's CLI key only under --store.
|
|
22532
|
+
--class public (or --end-user-required) mints the thin-client key for a browser /
|
|
22533
|
+
mobile app \u2014 the dashboard's "Public" class: it only works with a signed-in
|
|
22534
|
+
user's session, server-only scopes are refused, and it cannot be --store'd
|
|
21667
22535
|
list names, ids, scopes, env, created, last-used \u2014 never a secret; * marks the CLI's key
|
|
21668
22536
|
revoke type the key id to confirm (--yes for scripts); refuses the CLI's own key unless --yes
|
|
21669
22537
|
perms the key's MCP tool permissions: with no flag, shows them; --allow / --deny set a
|
|
@@ -21693,7 +22561,14 @@ try {
|
|
|
21693
22561
|
if ("error" in scopes) fail(scopes.error);
|
|
21694
22562
|
const env = parseKeyEnv(flag("env"));
|
|
21695
22563
|
if ("error" in env) fail(env.error);
|
|
21696
|
-
|
|
22564
|
+
const cls = parseKeyClass(flag("class"), hasFlag("end-user-required"));
|
|
22565
|
+
if ("error" in cls) fail(cls.error);
|
|
22566
|
+
if (cls.keyClass === "public") {
|
|
22567
|
+
const bad = checkPublicKeyScopes(scopes.scopes);
|
|
22568
|
+
if (bad) fail(bad.error);
|
|
22569
|
+
if (hasFlag("store")) fail("--store refused for a public key \u2014 it cannot drive the CLI (every request needs an end-user session); copy it into your app instead");
|
|
22570
|
+
}
|
|
22571
|
+
return { name: name.name, scopes: scopes.scopes, env: env.env, keyClass: cls.keyClass };
|
|
21697
22572
|
})() : void 0;
|
|
21698
22573
|
const t = resolveTargetOrFail(selector());
|
|
21699
22574
|
if (!t.tenantSlug) fail("no linked project \u2014 run `vxil link <slug>` (or `vxil quickstart`) first; keys are minted on the bound project");
|
|
@@ -22107,10 +22982,16 @@ try {
|
|
|
22107
22982
|
}
|
|
22108
22983
|
case "webhooks": {
|
|
22109
22984
|
const [sub, verb, arg] = positional(0);
|
|
22110
|
-
const WEBHOOKS_USAGE = "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic> --name <n> [--forward-url <https>] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>";
|
|
22985
|
+
const WEBHOOKS_USAGE = "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic|hmac> --name <n> [--forward-url <https> | --target-function <fn>] [--verify <json> (hmac)] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>";
|
|
22111
22986
|
if (sub === "sources") {
|
|
22112
22987
|
if (verb === "add") {
|
|
22113
|
-
const p = parseSourceAdd({
|
|
22988
|
+
const p = parseSourceAdd({
|
|
22989
|
+
provider: flag("provider"),
|
|
22990
|
+
name: flag("name"),
|
|
22991
|
+
forwardUrl: flag("forward-url"),
|
|
22992
|
+
targetFunction: flag("target-function"),
|
|
22993
|
+
verify: flag("verify")
|
|
22994
|
+
});
|
|
22114
22995
|
if ("error" in p) fail(p.error);
|
|
22115
22996
|
const { api, baseUrl } = requireApi(selector(), { write: `webhooks sources add ${p.body.name}` });
|
|
22116
22997
|
const s = await sourcesAdd(api, p.body);
|
|
@@ -22123,7 +23004,10 @@ try {
|
|
|
22123
23004
|
const rows = await sourcesList(api);
|
|
22124
23005
|
if (jsonOut) console.log(JSON.stringify({ sources: rows }, null, 2));
|
|
22125
23006
|
else if (!rows.length) console.log("(no inbound sources \u2014 `vxil webhooks sources add --provider \u2026 --name \u2026`)");
|
|
22126
|
-
else for (const r of rows)
|
|
23007
|
+
else for (const r of rows) {
|
|
23008
|
+
const dest = sourceDestination(r);
|
|
23009
|
+
console.log(`${r.source_id} ${String(r.provider).padEnd(10)} ${r.name}${dest ? ` \u2192 ${dest}` : ""}`);
|
|
23010
|
+
}
|
|
22127
23011
|
break;
|
|
22128
23012
|
}
|
|
22129
23013
|
if (verb === "rm") {
|
|
@@ -22865,7 +23749,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
22865
23749
|
default:
|
|
22866
23750
|
console.log(`vxil \u2014 a Supabase-grade code-first CLI for the whole platform
|
|
22867
23751
|
--version \xB7 init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--features a,b] [--invite <code>] [--env <label>] [--dev [--ttl <h>]] [--no-push] \xB7 login \xB7 link <slug> [--key <k> [--tenant <id>]] [--as dev|<name>] [--env <label>] [--mint]
|
|
22868
|
-
keys mint --name <n> --scopes <a,b,c> [--env live|test] [--store] \xB7 keys list \xB7 keys revoke <key_id> [--yes] \u2014 a NARROW scoped key for a server (shown once; needs a vxil login session)
|
|
23752
|
+
keys mint --name <n> --scopes <a,b,c> [--env live|test] [--class server|public] [--store] \xB7 keys list \xB7 keys revoke <key_id> [--yes] \u2014 a NARROW scoped key for a server (shown once; needs a vxil login session)
|
|
22869
23753
|
keys perms <key_id> [--allow <a,b> | --clear-allow] [--deny <c> | --clear-deny] \u2014 a key's MCP tool permissions (admin+)
|
|
22870
23754
|
account [show] \xB7 account set [--locale <code>] [--display-name <n>] \xB7 account password \xB7 logout \xB7 invites [list] \xB7 invites generate [--count <n>] \xB7 invites accept <token|url>
|
|
22871
23755
|
members [list] \xB7 members invite <email> --role <r> \xB7 members add <email> --role <r> \xB7 members rm <user_id|email> [--yes] \xB7 members uninvite <email|id> [--yes] \u2014 the bound project's team (session)
|
|
@@ -23468,18 +24352,26 @@ async function runFunctionsDev(name) {
|
|
|
23468
24352
|
console.error(`vxil: no dev tenant bound \u2014 the local function's feature calls will hit the PRIMARY binding '${t.tenantSlug ?? "(env key)"}' (${t.envLabel}); bind one with \`vxil dev up\` or pass --target <name>`);
|
|
23469
24353
|
}
|
|
23470
24354
|
announceTarget(t, `functions dev ${name}`);
|
|
23471
|
-
const
|
|
24355
|
+
const liveBanner = liveWritesBanner({
|
|
24356
|
+
devSlot: isDevSlot(t),
|
|
24357
|
+
tenant: t.tenantSlug ?? "(env key)",
|
|
24358
|
+
envLabel: t.envLabel,
|
|
24359
|
+
color: Boolean(process.stderr.isTTY) && !process.env.NO_COLOR
|
|
24360
|
+
});
|
|
24361
|
+
if (liveBanner && (lane !== "http" || oneShot !== void 0)) console.error(liveBanner);
|
|
24362
|
+
const liveGuard = liveLaneGuard({
|
|
23472
24363
|
lane,
|
|
23473
24364
|
oneShot: oneShot !== void 0,
|
|
23474
24365
|
explicitSelector: explicit,
|
|
23475
24366
|
production: isProductionTarget(t),
|
|
24367
|
+
devSlot: isDevSlot(t),
|
|
23476
24368
|
tenant: t.tenantSlug ?? "(env key)",
|
|
23477
24369
|
fnName: name
|
|
23478
24370
|
});
|
|
23479
|
-
if (
|
|
24371
|
+
if (liveGuard) {
|
|
23480
24372
|
await confirmTyped(
|
|
23481
|
-
|
|
23482
|
-
`vxil: ${
|
|
24373
|
+
liveGuard,
|
|
24374
|
+
`vxil: ${liveGuard}.
|
|
23483
24375
|
Type ${t.tenantSlug ? `the tenant slug '${t.tenantSlug}'` : "'yes'"} to run it anyway: `,
|
|
23484
24376
|
t.tenantSlug ?? "yes"
|
|
23485
24377
|
);
|