@vxil/cli 0.22.0 → 0.23.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.
@@ -15,6 +15,11 @@ export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
15
15
  * Read by the schema, the deploy clamp and the CLI. */
16
16
  export declare const FN_RETRY_MAX_ATTEMPTS = 5;
17
17
  export declare const FN_RETRY_BINDING_KINDS: ReadonlySet<string>;
18
+ /** An `http` binding's caps (2026-10-11; rules at fnForwardHeadersError /
19
+ * fnServerScopesError). Up here because the schema below reads them. */
20
+ export declare const FN_FORWARD_HEADERS_MAX = 8;
21
+ export declare const FN_FORWARD_HEADER_VALUE_MAX = 256;
22
+ export declare const FN_SERVER_SCOPES_MAX = 32;
18
23
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
19
24
  * of which is restricted to internal platform machinery). */
20
25
  export declare function isReservedCreditType(creditType: string): boolean;
@@ -710,6 +715,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
710
715
  amount: import("@sinclair/typebox").TInteger;
711
716
  period: import("@sinclair/typebox").TString;
712
717
  mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"add">, import("@sinclair/typebox").TLiteral<"reset">]>>;
718
+ expireOnEnd: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
713
719
  }>>>;
714
720
  }>>;
715
721
  priceMap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
@@ -744,6 +750,8 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
744
750
  maxAttempts: import("@sinclair/typebox").TInteger;
745
751
  }>>;
746
752
  overlap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"allow">, import("@sinclair/typebox").TLiteral<"skip">]>>;
753
+ forwardHeaders: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
754
+ serverScopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
747
755
  }>>>;
748
756
  scriptRef: import("@sinclair/typebox").TString;
749
757
  scopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
@@ -869,6 +877,13 @@ export declare const MCP_MAX_PROMPTS = 32;
869
877
  * stays ALLOWED (fn→fn composition). Source of truth for both the config-write
870
878
  * validation below and the control-plane deploy clamp. */
871
879
  export declare const DENY_FUNCTION_SCOPES: Set<string>;
880
+ export declare function fnForwardHeadersError(raw: unknown): string | null;
881
+ /** FN-SERVER-SCOPES: an `http` binding's `serverScopes` — the scopes of the
882
+ * tenant-only tokens (`server_jwts`) minted next to the caller-bound ones.
883
+ * Each must be one of the function's own `scopes` (pass `fnScopes` once they
884
+ * are known; undefined checks the shape only), so a caller can never reach
885
+ * more than the author granted the function. */
886
+ export declare function fnServerScopesError(raw: unknown, fnScopes?: readonly string[]): string | null;
872
887
  /** `{var}` names of a rate-limit key template (rate-limits-v1 core.ts
873
888
  * templateVars parity). */
874
889
  export declare function rlTemplateVars(template: string): string[];
package/dist/config.d.ts CHANGED
@@ -178,10 +178,18 @@ export interface FunctionTriggerRetry {
178
178
  /** A function trigger (guide ch. 8). cmsHook fires on a CMS write;
179
179
  * authHook fires on ONE auth lifecycle event (default `user.created` — the
180
180
  * post-signup hook; at-least-once, ~1min fanout latency). */
181
- export type FunctionTrigger = {
181
+ export type FunctionTrigger =
182
+ /** `http` (2026-10-11): `forwardHeaders` — up to 8 `x-…` request header
183
+ * names copied into the envelope's `client.headers` (caller-asserted: never
184
+ * for authorization); `serverScopes` — scopes (each one of the function's
185
+ * own `scopes`) of extra tenant-only tokens, `server_jwts`, for writes the
186
+ * end user must not make. */
187
+ {
182
188
  kind: 'http';
183
189
  path?: string;
184
190
  retry?: FunctionTriggerRetry;
191
+ forwardHeaders?: string[];
192
+ serverScopes?: string[];
185
193
  }
186
194
  /** `overlap: 'skip'` (2026-10-01): a due tick fires nothing while the
187
195
  * previous tick's run is still open (queued / running / retrying / waiting /
package/dist/vxil.js CHANGED
@@ -3402,6 +3402,8 @@ if (!format_exports.Has("email")) {
3402
3402
  format_exports.Set("email", (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
3403
3403
  }
3404
3404
  var FN_RETRY_MAX_ATTEMPTS = 5;
3405
+ var FN_FORWARD_HEADERS_MAX = 8;
3406
+ var FN_SERVER_SCOPES_MAX = 32;
3405
3407
  var NOTIF_OVERRIDE_SUBJECT_MAX = 500;
3406
3408
  var NOTIF_OVERRIDE_BODY_MAX = 2e4;
3407
3409
  var NOTIF_OVERRIDE_MAX_BYTES = 64 * 1024;
@@ -4713,7 +4715,13 @@ var PaymentsConfigSchema = Type.Object({
4713
4715
  // granted, so the user starts every period with exactly `amount`
4714
4716
  // (credits held by an in-flight job stay held). No schema default (it
4715
4717
  // would materialize into every manifest carrying grants).
4716
- mode: Type.Optional(Type.Union([Type.Literal("add"), Type.Literal("reset")]))
4718
+ mode: Type.Optional(Type.Union([Type.Literal("add"), Type.Literal("reset")])),
4719
+ // (2026-10-11) a 'reset' grant only: when the plan ENDS (its
4720
+ // subscription is cancelled, expires, lapses or is refunded and the
4721
+ // user's tier no longer grants `creditType`) the available balance of
4722
+ // the type is written off once as an `expire` ledger row. Absent =
4723
+ // the credits stay after the plan ends. No schema default.
4724
+ expireOnEnd: Type.Optional(Type.Boolean())
4717
4725
  })))
4718
4726
  })),
4719
4727
  // provider price/plan id → tier. A real subscription webhook carries the
@@ -4848,7 +4856,13 @@ var FunctionsConfigSchema = Type.Object({
4848
4856
  // W7 (2026-10-01): cron only — 'skip' = a due tick fires nothing
4849
4857
  // while the previous tick's run is still open (queued / running /
4850
4858
  // retrying / waiting / delayed). Absent = 'allow' (every tick runs).
4851
- overlap: Type.Optional(Type.Union([Type.Literal("allow"), Type.Literal("skip")]))
4859
+ overlap: Type.Optional(Type.Union([Type.Literal("allow"), Type.Literal("skip")])),
4860
+ // (2026-10-11) http only — request headers copied into the
4861
+ // envelope's client.headers (fnForwardHeadersError holds the rules)
4862
+ forwardHeaders: Type.Optional(Type.Array(Type.String(), { maxItems: FN_FORWARD_HEADERS_MAX })),
4863
+ // (2026-10-11) http only — scopes of the tenant-only server_jwts,
4864
+ // each one of the function's own scopes (fnServerScopesError)
4865
+ serverScopes: Type.Optional(Type.Array(Type.String(), { maxItems: FN_SERVER_SCOPES_MAX }))
4852
4866
  }),
4853
4867
  { maxItems: 8 }
4854
4868
  )
@@ -5048,22 +5062,31 @@ function lowerTriggerBindings(trigger) {
5048
5062
  const o = trigger?.overlap;
5049
5063
  return o === void 0 || o === "allow" ? {} : { overlap: o };
5050
5064
  };
5051
- if (t === "cron") return [{ kind: "cron", ...str2("schedule") ? { schedule: str2("schedule") } : {}, ...retryOf(), ...overlapOf() }];
5052
- if (t === "queue") return [{ kind: "queue", ...str2("source") ? { source: str2("source") } : {}, ...retryOf(), ...overlapOf() }];
5053
- if (t === "webhook") return [{ kind: "webhook", ...str2("source") ? { source: str2("source") } : {}, ...retryOf(), ...overlapOf() }];
5065
+ const httpExtrasOf = () => {
5066
+ const fh = trigger?.forwardHeaders;
5067
+ const ss = trigger?.serverScopes;
5068
+ return {
5069
+ ...fh !== void 0 && !(Array.isArray(fh) && fh.length === 0) ? { forwardHeaders: Array.isArray(fh) ? fh.map((h) => typeof h === "string" ? h.toLowerCase() : h) : fh } : {},
5070
+ ...ss !== void 0 && !(Array.isArray(ss) && ss.length === 0) ? { serverScopes: ss } : {}
5071
+ };
5072
+ };
5073
+ if (t === "cron") return [{ kind: "cron", ...str2("schedule") ? { schedule: str2("schedule") } : {}, ...retryOf(), ...overlapOf(), ...httpExtrasOf() }];
5074
+ if (t === "queue") return [{ kind: "queue", ...str2("source") ? { source: str2("source") } : {}, ...retryOf(), ...overlapOf(), ...httpExtrasOf() }];
5075
+ if (t === "webhook") return [{ kind: "webhook", ...str2("source") ? { source: str2("source") } : {}, ...retryOf(), ...overlapOf(), ...httpExtrasOf() }];
5054
5076
  if (t === "cmsHook" || t === "cms-hook") {
5055
5077
  return [{
5056
5078
  kind: "cmsHook",
5057
5079
  ...str2("collection") ? { collection: str2("collection") } : {},
5058
5080
  ...str2("event") ? { event: str2("event") } : {},
5059
5081
  ...retryOf(),
5060
- ...overlapOf()
5082
+ ...overlapOf(),
5083
+ ...httpExtrasOf()
5061
5084
  }];
5062
5085
  }
5063
5086
  if (t === "authHook" || t === "auth-hook") {
5064
- return [{ kind: "authHook", ...str2("event") ? { event: str2("event") } : {}, ...retryOf(), ...overlapOf() }];
5087
+ return [{ kind: "authHook", ...str2("event") ? { event: str2("event") } : {}, ...retryOf(), ...overlapOf(), ...httpExtrasOf() }];
5065
5088
  }
5066
- return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {}, ...retryOf(), ...overlapOf() }];
5089
+ return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {}, ...retryOf(), ...overlapOf(), ...httpExtrasOf() }];
5067
5090
  }
5068
5091
  function lowerAllTriggerBindings(def, name) {
5069
5092
  const declared = [def.trigger, ...def.triggers ?? []].filter((t) => Boolean(t));
@@ -5075,9 +5098,9 @@ function lowerAllTriggerBindings(def, name) {
5075
5098
  return bindings;
5076
5099
  }
5077
5100
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
5078
- var VXIL_CONFIG_PKG_VERSION = "0.15.0";
5079
- var VXIL_SDK_PKG_VERSION = "0.22.0";
5080
- var VXIL_CLI_PKG_VERSION = "0.22.0";
5101
+ var VXIL_CONFIG_PKG_VERSION = "0.16.0";
5102
+ var VXIL_SDK_PKG_VERSION = "0.23.0";
5103
+ var VXIL_CLI_PKG_VERSION = "0.23.0";
5081
5104
  function ensureScaffoldPackageJson(cwd, opts = {}) {
5082
5105
  const file = resolve(cwd, "package.json");
5083
5106
  const wanted = {
@@ -8428,6 +8451,8 @@ function describeBinding(b2) {
8428
8451
  else if (b2.kind === "http" && b2.path) parts.push(b2.path);
8429
8452
  if (b2.retry) parts.push(`retry:${b2.retry.maxAttempts}`);
8430
8453
  if (b2.overlap) parts.push(`overlap:${b2.overlap}`);
8454
+ if (b2.forwardHeaders?.length) parts.push(`forwardHeaders:${b2.forwardHeaders.join(",")}`);
8455
+ if (b2.serverScopes?.length) parts.push(`serverScopes:${b2.serverScopes.join(",")}`);
8431
8456
  return parts.join(" ");
8432
8457
  }
8433
8458
  function diffBindings(remote, local) {
@@ -12647,7 +12672,7 @@ var TOOLS = [
12647
12672
  {
12648
12673
  name: "jobs_get_run",
12649
12674
  feature: "jobs",
12650
- 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.",
12675
+ 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, provider_ref (the provider job id, with completion.ref_path) and 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.",
12651
12676
  inputSchema: {
12652
12677
  type: "object",
12653
12678
  properties: {
@@ -12844,7 +12869,7 @@ var TOOLS = [
12844
12869
  {
12845
12870
  name: "jobs_enqueue_generation",
12846
12871
  feature: "jobs",
12847
- 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 answers 422 idempotency_key_reused (every project), 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).",
12872
+ 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). completion.ref_path keeps the provider's job id (provider_ref); with provider.cancel that job is cancelled at the provider when the run is cancelled or expires; completion.error_path keeps a failed answer's detail. Answers 202 { run_id, generation_status: 'pending', expires_at } (deduplicated:true for a repeated idempotency_key with the SAME body; a different body under that key answers 422 idempotency_key_reused (every project), 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).",
12848
12873
  inputSchema: {
12849
12874
  type: "object",
12850
12875
  properties: {
@@ -12855,7 +12880,17 @@ var TOOLS = [
12855
12880
  url: { type: "string", description: "https:// start URL" },
12856
12881
  method: { type: "string" },
12857
12882
  headers: { type: "object", additionalProperties: { type: "string" } },
12858
- body: { type: "object", additionalProperties: true }
12883
+ body: { type: "object", additionalProperties: true },
12884
+ cancel: {
12885
+ type: "object",
12886
+ properties: {
12887
+ url: { type: "string", description: "https:// cancel URL; {provider_ref} is replaced by the job id completion.ref_path read" },
12888
+ method: { type: "string" },
12889
+ headers: { type: "object", additionalProperties: { type: "string" } }
12890
+ },
12891
+ required: ["url"],
12892
+ description: "The provider's cancel call, made once (best-effort) when the run is cancelled or expires after the provider accepted it. Needs completion.ref_path."
12893
+ }
12859
12894
  },
12860
12895
  required: ["url"]
12861
12896
  },
@@ -12876,7 +12911,9 @@ var TOOLS = [
12876
12911
  },
12877
12912
  callback: { type: "object", properties: { query_param: { type: "string" } }, required: ["query_param"] },
12878
12913
  status_map: { type: "object", additionalProperties: { type: "string" } },
12879
- result_path: { type: "string" }
12914
+ result_path: { type: "string" },
12915
+ ref_path: { type: "string", description: "Dotted path of the provider job id in the START answer (e.g. request_id) \u2014 the run's provider_ref." },
12916
+ error_path: { type: "string", description: "Dotted path of the failure detail in a failed answer (e.g. payload.detail) \u2014 kept on the run and mirrored as error_detail." }
12880
12917
  },
12881
12918
  required: ["mode"]
12882
12919
  },
@@ -12905,6 +12942,7 @@ var TOOLS = [
12905
12942
  credit_type: { type: "string" },
12906
12943
  credit_types: { type: "array", items: { type: "string" } },
12907
12944
  reason: { type: "string" },
12945
+ allow_split: { type: "boolean", description: "With credit_types only: hold across the types in order when no single one covers amount (default false)." },
12908
12946
  on_unavailable: { type: "string", enum: ["fail", "proceed"] }
12909
12947
  },
12910
12948
  required: ["amount", "user_id"]
@@ -14130,7 +14168,7 @@ var TOOLS = [
14130
14168
  {
14131
14169
  name: "payments_consume",
14132
14170
  feature: "payments",
14133
- description: "Debit credits from an end-user \u2014 idempotent (idempotency_key REQUIRED; the SAME key always returns the SAME result, never double-charges) and balance-guarded (never oversells; returns 402 insufficient_credits when available can't cover amount). Name ONE credit_type, or an ordered credit_types list (e.g. ['free','subscription','topup']): the WHOLE amount comes from the FIRST type whose available balance covers it \u2014 never split across types \u2014 and the answer's credit_type names the one debited. Pass job_id to make the debit a PROVISIONAL hold tied to a background job: a linked jobs run that terminally fails auto-refunds the hold, a success settles it. amount must be >= 1. Needs payments:write.",
14171
+ description: "Debit credits from an end-user \u2014 idempotent (idempotency_key REQUIRED; the SAME key always returns the SAME result, never double-charges) and balance-guarded (never oversells; returns 402 insufficient_credits when available can't cover amount). Name ONE credit_type, or an ordered credit_types list (e.g. ['free','subscription','topup']): the WHOLE amount comes from the FIRST type whose available balance covers it and the answer's credit_type names the one debited; add allow_split: true to draw across the types in order when no single one covers it (one ledger row per type, listed in the answer's parts). Pass job_id to make the debit a PROVISIONAL hold tied to a background job: a linked jobs run that terminally fails auto-refunds the hold, a success settles it. amount must be >= 1. Needs payments:write.",
14134
14172
  inputSchema: {
14135
14173
  type: "object",
14136
14174
  properties: {
@@ -14146,7 +14184,8 @@ var TOOLS = [
14146
14184
  amount: { type: "number", minimum: 1, description: "Credits to consume (>= 1)." },
14147
14185
  idempotency_key: { type: "string", description: "Unique key for this debit; reusing it replays the first result (no double-charge)." },
14148
14186
  job_id: { type: "string", description: "Optional jobs run_id to link: makes the debit a provisional hold that auto-reverses if the job terminally fails." },
14149
- reason: { type: "string", description: "Optional human-readable reason recorded on the ledger row." }
14187
+ reason: { type: "string", description: "Optional human-readable reason recorded on the ledger row." },
14188
+ allow_split: { type: "boolean", description: "With credit_types only: when no single type covers amount, draw across them in order (default false: 402)." }
14150
14189
  },
14151
14190
  required: ["user_id", "amount", "idempotency_key"]
14152
14191
  },
@@ -14154,6 +14193,21 @@ var TOOLS = [
14154
14193
  path: "/v1/payments/credits/consume",
14155
14194
  idempotencyArg: "idempotency_key"
14156
14195
  },
14196
+ {
14197
+ name: "payments_reverse_credits",
14198
+ feature: "payments",
14199
+ description: "Reverse a COMMITTED credit consume \u2014 the typed refund when you could not deliver what the credits paid for. Name the consume's ledger_entry_id, or the job_id (run_id) of a job hold that settled (every part of a split). Puts back what was CHARGED (a pro-rated settle's actual), as one 'reversal' ledger row per consume, at most once: a repeat answers already_reversed: true and moves nothing. 409 not_committed while the hold is still held (the job settles or releases it itself; a failed run refunds automatically), 409 nothing_charged for a released hold or a recorded 402, 404 for no such consume. Needs payments:write. Server-side key only (403 server_only on a thin-client/end-user key).",
14200
+ inputSchema: {
14201
+ type: "object",
14202
+ properties: {
14203
+ ledger_entry_id: { type: "string", maxLength: 128, description: "The consume's ledger_entry_id (from the consume answer or the ledger). Give this OR job_id." },
14204
+ job_id: { type: "string", maxLength: 256, description: "The jobs run_id a hold was taken for: reverses its settled charge. Give this OR ledger_entry_id." },
14205
+ reason: { type: "string", maxLength: 256, description: "Why \u2014 recorded on the reversal row, e.g. 'delivery failed'." }
14206
+ }
14207
+ },
14208
+ method: "POST",
14209
+ path: "/v1/payments/credits/reverse"
14210
+ },
14157
14211
  {
14158
14212
  name: "payments_list_webhook_events",
14159
14213
  feature: "payments",
@@ -15989,6 +16043,40 @@ function formatSimulate(r) {
15989
16043
  ];
15990
16044
  return lines.join("\n");
15991
16045
  }
16046
+ var GRANT_PERIODS = /* @__PURE__ */ new Set(["once", "monthly", "annual"]);
16047
+ var PERIOD_DAYS = { monthly: 31, annual: 366 };
16048
+ var YEARLY_ID = /annual|year|(^|[._:-])(12m(onths?)?|12_months?|p1y)($|[._:-])/i;
16049
+ function ledgerGrantPeriodWarnings(cfg) {
16050
+ const ledger = cfg.features?.payments?.ledger;
16051
+ if (!ledger) return [];
16052
+ const out = [];
16053
+ const tierMap = ledger.tierMap ?? {};
16054
+ const periodsOf = (tier) => (tierMap[tier]?.grants ?? []).map((g) => g.period ?? "");
16055
+ for (const [tier, rule] of Object.entries(tierMap)) {
16056
+ (rule.grants ?? []).forEach((g, i) => {
16057
+ if (g.period !== void 0 && !GRANT_PERIODS.has(g.period)) {
16058
+ out.push(`/ledger/tierMap/${tier}/grants/${i}/period: '${g.period}' is not 'once' | 'monthly' | 'annual' \u2014 the grant mints once per billing period of the subscription and falls back to a monthly bucket only when the provider sends no period start`);
16059
+ }
16060
+ if (g.period === "once") {
16061
+ out.push(`/ledger/tierMap/${tier}/grants/${i}/period: 'once' still mints again every billing period of a subscription that has a period start (the period only buckets the no-period-start fallback) \u2014 for a one-time bonus on subscribing, call grantOnce from a function keyed by the subscription id`);
16062
+ }
16063
+ });
16064
+ }
16065
+ for (const [priceRef, tier] of Object.entries(ledger.priceMap ?? {})) {
16066
+ if (YEARLY_ID.test(priceRef) && periodsOf(tier).includes("monthly")) {
16067
+ out.push(`/ledger/priceMap/${priceRef}: looks like a yearly price, but tier '${tier}' has a 'monthly' grant \u2014 tier grants mint once per billing period (keyed by its start), so it mints once a year. For monthly credits on a yearly plan, run a cron function that calls grantOnce keyed by month`);
16068
+ }
16069
+ }
16070
+ for (const [productId, rule] of Object.entries(ledger.productMap ?? {})) {
16071
+ const days = rule.durationDays;
16072
+ if (!rule.tier || typeof days !== "number") continue;
16073
+ const short = periodsOf(rule.tier).find((p) => days > (PERIOD_DAYS[p] ?? Infinity));
16074
+ if (short) {
16075
+ out.push(`/ledger/productMap/${productId}: a ${days}-day pass mints tier '${rule.tier}' grants once at purchase, not every ${short === "monthly" ? "month" : "year"} \u2014 sell a shorter pass or grant the rest from a cron function (grantOnce keyed by period)`);
16076
+ }
16077
+ }
16078
+ return out;
16079
+ }
15992
16080
 
15993
16081
  // src/migrate/paymentsSync.ts
15994
16082
  import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
@@ -19505,7 +19593,7 @@ export default defineConfig({
19505
19593
  "id": "fal-media",
19506
19594
  "title": "AI Media Studio (fal.ai images \xB7 Veo video \xB7 credits)",
19507
19595
  "vertical": "ai",
19508
- "summary": "Generate images and Veo video through fal.ai's queue API with NO vendor adapter \u2014 the generation lane's generic completion options carry fal's webhook in the query string, read its OK/ERROR words and settle on the image list or the video \u2014 while the credits for each render are held up front and refunded automatically when fal fails.",
19596
+ "summary": "Generate images and Veo video through fal.ai's queue API with NO vendor adapter \u2014 the generation lane's generic completion options carry fal's webhook in the query string, read its OK/ERROR words and settle on the image list or the video \u2014 while the credits for each render are held up front and refunded automatically when fal fails \u2014 or reversed when a completed render delivered no media.",
19509
19597
  "collections": [
19510
19598
  "renders"
19511
19599
  ],
@@ -19519,10 +19607,11 @@ export default defineConfig({
19519
19607
  "byoKeys": [
19520
19608
  "fal_key"
19521
19609
  ],
19522
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Media Studio\" \u2014 images and video from fal.ai, with credits that cannot\n// leak. One function starts a render; vxil does the rest:\n//\n// start-render (your function, end-user mode)\n// \u2192 writes a `renders` row the user owns\n// \u2192 POST /v1/jobs/generation: a fal QUEUE job, credits HELD for it\n// vxil's generation lane\n// \u2192 calls fal with your key, the signed callback in `?fal_webhook=`\n// \u2192 fal POSTs back `{ status: 'OK' | 'ERROR', payload }`\n// \u2192 status_map reads OK/ERROR, result_path picks the images / the video\n// \u2192 the `renders` row gets generation_status + result\n// \u2192 credits COMMIT on success, REFUND on failure or timeout\n//\n// No fal adapter exists in vxil and none is needed: the three generic options\n// (`completion.callback.query_param`, `completion.status_map`,\n// `completion.result_path`) describe any \"POST the job, we call your webhook\"\n// vendor. The platform owns the part a function cannot \u2014 the held credits,\n// the timeout and the refund.\n//\n// The credits half is a payments INTEGRATION on the deterministic `mock`\n// provider \u2014 no provider account needed to try it. \"Credits\" are usage units,\n// not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n maxConcurrent: 10,\n // fal images finish in seconds, an 8 s Veo clip in a few minutes \u2014\n // a run fal never calls back about fails (and refunds) at 15 minutes\n defaultTimeoutMs: 900_000,\n maxTimeoutMs: 1_800_000,\n pollMaxAttempts: 30,\n // a video costs 10 credits; one run may never hold more than 20\n maxReserveCredits: 20,\n // \u2026nor may all of this project's in-flight renders together hold more than 2,000\n maxOutstandingReserveCredits: 2_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n media_pack_100: { creditType: 'media_credits', amount: 100, period: 'once' },\n },\n // subscription tiers: a monthly top-up for subscribers\n tierMap: {\n studio: {\n entitlements: ['render'],\n quotas: {},\n rank: 10,\n grants: [{ creditType: 'media_credits', amount: 300, period: 'monthly' }],\n },\n },\n // a render that fails or times out gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a collection with no owner is server-only \u2014 renders\n // declares one, so a signed-in user sees only their own renders\n strictEndUserScope: true,\n // Lane-A hook (guide ch. 7): the dedupe key IS owner + ':' + request_key,\n // server-enforced \u2014 so one user's request_key can never collide with, or\n // block, another user's (a body naming another owner is a 400 anyway).\n hooks: {\n render_dedupe_key: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.dedupe_key == concat(item.owner, ':', item.request_key)\",\n message: \"dedupe_key must be owner + ':' + request_key\",\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR \u2014 owner + ':' + the client's own request_key\n // (the hook above enforces the composition), so the key is per user.\n // A retried start (a double tap, a timeout) hits 409 instead of\n // paying twice, and start-render re-drives a start that never got\n // its run.\n dedupe_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n kind: { type: 'string', required: true, indexSlot: 's3', validation: { enum: ['image', 'video'] } },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n generation_status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n prompt: { type: 'text', required: true },\n run_id: { type: 'text' },\n // written by vxil's status mirror on completion: the value at\n // `result_path` \u2014 fal's `images` list, or its `video` object\n result: { type: 'json' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode (with\n // the user's session): the held credits are forced onto that user, and the\n // row is theirs.\n 'start-render': {\n entry: './functions/start-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // your fal API key; it rides the provider call's Authorization header\n secrets: ['secret:fal_key'],\n // the function itself never calls fal \u2014 vxil's generation lane does\n egressAllow: [],\n // `vxil gen` types vx.fn['start-render'] from this\n signature: {\n input: { kind: 'string', prompt: 'string', request_key: 'string' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; generation_status: string }',\n },\n },\n },\n\n secrets: {\n fal_key: {\n feature: 'functions',\n description: 'your fal.ai API key (the \"Key \u2026\" credential) \u2014 vxil never holds a fal account of its own',\n },\n },\n});\n",
19523
- "readme": "# AI Media Studio \u2014 fal.ai images and Veo video, with credits that cannot leak\n\n```bash\nmkdir my-studio && cd my-studio\nvxil init --template fal-media # init scaffolds into the CURRENT directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf '%s' \"$FAL_KEY\" | vxil secrets set functions/fal_key # or run it bare for a hidden prompt\nvxil push # collections + the function; writes vxil.types.ts (vx.fn typing)\n```\n\n`--env staging` matters here beyond the plan: the payments block runs on the `mock` provider, which the\nproduction promotion gate refuses on a project labelled `production`.\n\n> **Plan note.** The function deploys on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nOne function starts a render; vxil carries it to the end. You bring a fal.ai key; vxil holds the\nuser's credits while fal works, writes the image or the video onto the user's `renders` row when\nfal calls back, and gives the credits back when fal fails or never answers.\n\n**What this blueprint teaches that the others do not:** a third-party *queue* API \u2014 \"POST the job,\nwe call your webhook\" \u2014 completed through the generation lane's **generic completion options**,\nwith no vendor adapter in vxil and no long call held open in a function:\n\n| option | what it does | fal's case |\n|---|---|---|\n| `completion.callback.query_param` | puts vxil's signed callback URL in the provider URL's query string, and sends `provider.body` exactly as written | fal reads its webhook from `?fal_webhook=` and the model input from the body |\n| `completion.status_map` | up to 8 of the provider's own status words \u2192 `completed` \xB7 `failed` \xB7 `processing` | fal's webhook says `OK` or `ERROR` \u2014 words vxil's built-in list would read as \"still working\" until the run timed out |\n| `completion.result_path` | the value the run settles with, written to your record as `result` | `payload.images` (a list of `{ url, width, height, \u2026 }`) or `payload.video` (`{ url, \u2026 }`) |\n\nThe same three options fit any vendor that calls you back (a transcode API, a render farm, a\nlong-running inference host) \u2014 change the URL, the words and the path.\n\n## What you get\n\n- **`renders`** \u2014 one row per request, owned by the user who started it (`strictEndUserScope`, so a\n signed-in user reads only their own renders). `dedupe_key` (the owner + `:` + the client's\n `request_key`, the composition enforced by a `beforeWrite` hook) is unique, so keys are **per user**: a\n double tap or a retried start is a `409` the function reads back. A row that already has its run is a\n duplicate; a row with no run (the first start died, or lost its enqueue answer, between the row and\n the enqueue) is **re-driven** \u2014 the same `dedupe_key` is the generation run's `idempotency_key`, so\n jobs hands back the existing run and fal is never asked twice.\n- **`start-render`** (http function, end-user mode) \u2014 writes the row, then enqueues ONE generation run:\n fal's queue URL, your key in `Authorization: Key \u2026`, the three options above, a status mirror onto\n the row, and `reserve_credits` for the render's cost (image 1, video 10 `media_credits`).\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed). The hold is\n taken *before* the run is queued; a user without the credits gets `402` at once and nothing is held.\n Success commits the hold; a fal `ERROR`, a run fal never calls back about (the timeout: 5 min for an\n image, 15 for a video) or a cancel refunds it (`ledger.autoRefundOnJobFailure`).\n\n## Run it\n\nGive a user some credits from your server (or sell `media_pack_100` through your payments provider).\n`$KEY` is a server key: `vxil env pull && set -a && . ./.env.local && set +a && KEY=$VXIL_API_KEY`.\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"media_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser, and a function cannot hold credits for anyone else):\n\n**The two values the browser needs.** `VXIL_PUBLISHABLE_KEY` is a **public-class** key, the kind that\nlives in a browser or an app and only works alongside a signed-in user's session:\n\n```bash\nvxil keys mint --name studio-browser --class public --scopes functions:invoke,cms:read --json\n```\n\n`USER_SESSION` is that user's session token. This blueprint does not enable sign-in: add\n`auth: { methods: { emailPassword: true } }` to `features`, push, and sign the user in with a second,\nsign-in-only key (`vxil keys mint --name studio-signin --scopes auth:signin --json`; it cannot be\npublic-class, because it runs before there is a session) \u2014 vxil.com/docs/guide/06-feature-catalog, auth,\n\"The end-user identity loop, worked end to end\". The `user_id` you grant credits to is that signed-in\nuser's id.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['start-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['start-render']({ kind: 'image', prompt: 'a red fox in the snow', request_key: 'fox-1' });\n// \u2192 { item_id, run_id, credits: 1 }\n// (or { duplicate: true, request_key, item_id, run_id, generation_status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `generation_status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.generation_status \u2192 'completed'\n // row.result \u2192 [{ url: 'https://fal.media/files/\u2026png', width: 1024, height: 1024, \u2026 }]\n}\n```\n\nEvery run ends with `job.generation.completed` or `job.generation.failed` (`generation_id` = the row's\n`item_id`, `correlation_id` = its `request_key`), so a function bound to `job.generation.` can react \u2014\nsend a push, thumbnail the image \u2014 without a lookup table of its own.\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| fal answered `OK` | `completed` | `generation_status: completed`, `result` set | committed |\n| fal answered `ERROR` (its `error` text is the run's `last_error_msg`; `job.generation.failed` carries `error_class: 'ProviderFailed'`) | `failed` | `generation_status: failed` | refunded |\n| fal never called back | `failed` (`GenerationExpired`) at the timeout | `failed` | refunded |\n| the start call to fal failed with a 5xx / network fault | retried with backoff; terminal after the attempts | `processing` \u2192 the outcome above | held until then |\n| the user had too few credits | ended at once (`ReserveInsufficient`), never queued | `failed` (the function marks it); that `request_key` is spent \u2014 retry after a top-up with a new one | nothing held |\n| too many renders in flight, or a jobs-side fault | not created (the function answers the caller `429` with the wait in `retry_after`, or `502`) | `pending`, no run \u2014 calling start-render again with the **same** `request_key` re-drives it | nothing held |\n\n## Evidence\n\n- **Executed** (vxil's own integration tests):\n the `?fal_webhook=` callback URL is signed and verifies, the provider receives exactly the model input,\n `OK` settles completed with `payload.images` mirrored as `result` and the hold committed, `ERROR`\n settles failed with the hold refunded, a progress ping stays processing.\n- **Read in fal's documentation, not executed against fal from vxil** \u2014 the queue base\n `https://queue.fal.run/<model>`, the `fal_webhook` query parameter, the webhook body\n `{ request_id, status: 'OK' | 'ERROR', payload, error }`, and the model ids `fal-ai/flux/dev` and\n `fal-ai/veo3` with their inputs. Check the model page for each model's exact input fields before you\n ship, and keep `num_images` / `duration` to what your model accepts.\n\n## Your key, and where it lives\n\n`fal_key` is a function secret; `start-render` reads it at invoke time and puts it in the generation\nrun's provider headers, which vxil stores with the run (your project only) for as long as the jobs\nretention keeps the run, and sends to fal on the start call. It is **never returned by a read**:\n`GET /v1/jobs/runs/{run_id}` (and the dashboard and MCP reads built on it) shows the header names of a\ngeneration run with every value as `[redacted]`. Rotate it with `vxil secrets set functions/fal_key`;\nruns already queued keep the key they were started with.\n",
19610
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Media Studio\" \u2014 images and video from fal.ai, with credits that cannot\n// leak. One function starts a render; vxil does the rest:\n//\n// start-render (your function, end-user mode)\n// \u2192 writes a `renders` row the user owns\n// \u2192 POST /v1/jobs/generation: a fal QUEUE job, credits HELD for it\n// vxil's generation lane\n// \u2192 calls fal with your key, the signed callback in `?fal_webhook=`\n// \u2192 fal POSTs back `{ status: 'OK' | 'ERROR', payload }`\n// \u2192 status_map reads OK/ERROR, result_path picks the images / the video\n// \u2192 the `renders` row gets generation_status + result\n// \u2192 credits COMMIT on success, REFUND on failure or timeout\n// check-delivery (your function, on job.generation.completed)\n// \u2192 completed but no media? the charge is REVERSED (credits/reverse)\n//\n// No fal adapter exists in vxil and none is needed: the three generic options\n// (`completion.callback.query_param`, `completion.status_map`,\n// `completion.result_path`) describe any \"POST the job, we call your webhook\"\n// vendor. The platform owns the part a function cannot \u2014 the held credits,\n// the timeout and the refund.\n//\n// The credits half is a payments INTEGRATION on the deterministic `mock`\n// provider \u2014 no provider account needed to try it. \"Credits\" are usage units,\n// not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n maxConcurrent: 10,\n // fal images finish in seconds, an 8 s Veo clip in a few minutes \u2014\n // a run fal never calls back about fails (and refunds) at 15 minutes\n defaultTimeoutMs: 900_000,\n maxTimeoutMs: 1_800_000,\n pollMaxAttempts: 30,\n // a video costs 10 credits; one run may never hold more than 20\n maxReserveCredits: 20,\n // \u2026nor may all of this project's in-flight renders together hold more than 2,000\n maxOutstandingReserveCredits: 2_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n media_pack_100: { creditType: 'media_credits', amount: 100, period: 'once' },\n },\n // subscription tiers: a monthly top-up for subscribers\n tierMap: {\n studio: {\n entitlements: ['render'],\n quotas: {},\n rank: 10,\n grants: [{ creditType: 'media_credits', amount: 300, period: 'monthly' }],\n },\n },\n // a render that fails or times out gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a collection with no owner is server-only \u2014 renders\n // declares one, so a signed-in user sees only their own renders\n strictEndUserScope: true,\n // Lane-A hook (guide ch. 7): the dedupe key IS owner + ':' + request_key,\n // server-enforced \u2014 so one user's request_key can never collide with, or\n // block, another user's (a body naming another owner is a 400 anyway).\n hooks: {\n render_dedupe_key: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.dedupe_key == concat(item.owner, ':', item.request_key)\",\n message: \"dedupe_key must be owner + ':' + request_key\",\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR \u2014 owner + ':' + the client's own request_key\n // (the hook above enforces the composition), so the key is per user.\n // A retried start (a double tap, a timeout) hits 409 instead of\n // paying twice, and start-render re-drives a start that never got\n // its run.\n dedupe_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n kind: { type: 'string', required: true, indexSlot: 's3', validation: { enum: ['image', 'video'] } },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n generation_status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n prompt: { type: 'text', required: true },\n run_id: { type: 'text' },\n // written by vxil's status mirror on completion: the value at\n // `result_path` \u2014 fal's `images` list, or its `video` object\n result: { type: 'json' },\n // written by vxil's status mirror on a fal ERROR: the value at\n // `error_path` \u2014 fal's `payload.detail` list, as JSON text\n error_detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode (with\n // the user's session): the held credits are forced onto that user, and the\n // row is theirs.\n 'start-render': {\n entry: './functions/start-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // your fal API key; it rides the provider call's Authorization header\n secrets: ['secret:fal_key'],\n // the function itself never calls fal \u2014 vxil's generation lane does\n egressAllow: [],\n // `vxil gen` types vx.fn['start-render'] from this\n signature: {\n input: { kind: 'string', prompt: 'string', request_key: 'string' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; generation_status: string }',\n },\n },\n\n // job.generation.completed \u2192 a render that completed but delivered no\n // media (an empty image list, a video with no URL) is marked failed and\n // its charged credits are given back with POST /v1/payments/credits/reverse\n // (by the run's job_id; at most once). A webhook-triggered function runs\n // with tenant-only tokens for exactly these scopes.\n 'check-delivery': {\n entry: './functions/check-delivery.ts',\n trigger: { kind: 'webhook', source: 'job.generation.completed', retry: { maxAttempts: 5 } },\n scopes: ['cms:read', 'cms:write', 'payments:write'],\n egressAllow: [],\n },\n },\n\n secrets: {\n fal_key: {\n feature: 'functions',\n description: 'your fal.ai API key (the \"Key \u2026\" credential) \u2014 vxil never holds a fal account of its own',\n },\n },\n});\n",
19611
+ "readme": "# AI Media Studio \u2014 fal.ai images and Veo video, with credits that cannot leak\n\n```bash\nmkdir my-studio && cd my-studio\nvxil init --template fal-media # init scaffolds into the CURRENT directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf '%s' \"$FAL_KEY\" | vxil secrets set functions/fal_key # or run it bare for a hidden prompt\nvxil push # collections + the functions; writes vxil.types.ts (vx.fn typing)\n```\n\n`--env staging` matters here beyond the plan: the payments block runs on the `mock` provider, which the\nproduction promotion gate refuses on a project labelled `production`.\n\n> **Plan note.** The two functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nOne function starts a render; vxil carries it to the end. You bring a fal.ai key; vxil holds the\nuser's credits while fal works, writes the image or the video onto the user's `renders` row when\nfal calls back, and gives the credits back when fal fails or never answers.\n\n**What this blueprint teaches that the others do not:** a third-party *queue* API \u2014 \"POST the job,\nwe call your webhook\" \u2014 completed through the generation lane's **generic completion options**,\nwith no vendor adapter in vxil and no long call held open in a function:\n\n| option | what it does | fal's case |\n|---|---|---|\n| `completion.callback.query_param` | puts vxil's signed callback URL in the provider URL's query string, and sends `provider.body` exactly as written | fal reads its webhook from `?fal_webhook=` and the model input from the body |\n| `completion.status_map` | up to 8 of the provider's own status words \u2192 `completed` \xB7 `failed` \xB7 `processing` | fal's webhook says `OK` or `ERROR` \u2014 words vxil's built-in list would read as \"still working\" until the run timed out |\n| `completion.result_path` | the value the run settles with, written to your record as `result` | `payload.images` (a list of `{ url, width, height, \u2026 }`) or `payload.video` (`{ url, \u2026 }`) |\n| `completion.ref_path` | the provider's job id in its submit answer, kept as the run's `provider_ref` (run reads, `job.generation.completed` / `failed`) | `request_id` |\n| `provider.cancel` | the provider's cancel call, made **once** (best-effort, no retries) when the run is cancelled or times out after the provider accepted it; `{provider_ref}` in the URL is the job id | `PUT https://queue.fal.run/<app>/requests/{provider_ref}/cancel` with your key |\n| `completion.error_path` | the failure detail in a failed answer, kept on the run and written to your record as `error_detail` | `payload.detail` (fal's list of reasons) |\n\nThe same options fit any vendor that calls you back (a transcode API, a render farm, a\nlong-running inference host) \u2014 change the URLs, the words and the paths.\n\n## What you get\n\n- **`renders`** \u2014 one row per request, owned by the user who started it (`strictEndUserScope`, so a\n signed-in user reads only their own renders). `dedupe_key` (the owner + `:` + the client's\n `request_key`, the composition enforced by a `beforeWrite` hook) is unique, so keys are **per user**: a\n double tap or a retried start is a `409` the function reads back. A row that already has its run is a\n duplicate; a row with no run (the first start died, or lost its enqueue answer, between the row and\n the enqueue) is **re-driven** \u2014 the same `dedupe_key` is the generation run's `idempotency_key`, so\n jobs hands back the existing run and fal is never asked twice.\n- **`start-render`** (http function, end-user mode) \u2014 writes the row, then enqueues ONE generation run:\n fal's queue URL, your key in `Authorization: Key \u2026`, the three options above, a status mirror onto\n the row, and `reserve_credits` for the render's cost (image 1, video 10 `media_credits`).\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed). The hold is\n taken *before* the run is queued; a user without the credits gets `402` at once and nothing is held.\n Success commits the hold; a fal `ERROR`, a run fal never calls back about (the timeout: 5 min for an\n image, 15 for a video) or a cancel refunds it (`ledger.autoRefundOnJobFailure`).\n- **`check-delivery`** (webhook function on `job.generation.completed`) \u2014 a run fal completed can still\n deliver nothing (an empty image list, a video with no URL). The hold is already committed and the\n automatic refund covers failed runs only, so this function reads the row and, when there is no media,\n calls `POST /v1/payments/credits/reverse { job_id: <run_id> }`: one `reversal` ledger row puts back\n what the run charged, at most once (a redelivery answers `already_reversed: true`), and the row is\n marked `failed`. It judges a row only once the status mirror has marked it `completed`, and answers 502\n for another attempt while the hold is still being committed (`409 not_committed`); a run that charged nothing\n (`404`, `409 nothing_charged`) is marked `failed` with nothing to give back. Put your own delivery check in its `delivered()` (a copy into your storage that\n failed, a size check): whatever it rejects is refunded.\n\n## Run it\n\nGive a user some credits from your server (or sell `media_pack_100` through your payments provider).\n`$KEY` is a server key: `vxil env pull && set -a && . ./.env.local && set +a && KEY=$VXIL_API_KEY`.\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"media_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser, and a function cannot hold credits for anyone else):\n\n**The two values the browser needs.** `VXIL_PUBLISHABLE_KEY` is a **public-class** key, the kind that\nlives in a browser or an app and only works alongside a signed-in user's session:\n\n```bash\nvxil keys mint --name studio-browser --class public --scopes functions:invoke,cms:read --json\n```\n\n`USER_SESSION` is that user's session token. This blueprint does not enable sign-in: add\n`auth: { methods: { emailPassword: true } }` to `features`, push, and sign the user in with a second,\nsign-in-only key (`vxil keys mint --name studio-signin --scopes auth:signin --json`; it cannot be\npublic-class, because it runs before there is a session) \u2014 vxil.com/docs/guide/06-feature-catalog, auth,\n\"The end-user identity loop, worked end to end\". The `user_id` you grant credits to is that signed-in\nuser's id.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['start-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['start-render']({ kind: 'image', prompt: 'a red fox in the snow', request_key: 'fox-1' });\n// \u2192 { item_id, run_id, credits: 1 }\n// (or { duplicate: true, request_key, item_id, run_id, generation_status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `generation_status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.generation_status \u2192 'completed'\n // row.result \u2192 [{ url: 'https://fal.media/files/\u2026png', width: 1024, height: 1024, \u2026 }]\n}\n```\n\nEvery run ends with `job.generation.completed` or `job.generation.failed` (`generation_id` = the row's\n`item_id`, `correlation_id` = its `request_key`), so a function bound to `job.generation.` can react \u2014\nsend a push, thumbnail the image \u2014 without a lookup table of its own.\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| fal answered `OK` | `completed` | `generation_status: completed`, `result` set | committed |\n| fal answered `OK` with no media (`check-delivery` rejects it) | `completed` | `generation_status: failed` | committed, then reversed (`credits/reverse`) |\n| fal answered `ERROR` (its `error` text is the run's `last_error_msg`; `job.generation.failed` carries `error_class: 'ProviderFailed'`) | `failed` | `generation_status: failed` | refunded |\n| fal never called back | `failed` (`GenerationExpired`) at the timeout | `failed` | refunded |\n| fal answered `ERROR` (its `error` text is the run's `last_error_msg`; `job.generation.failed` carries `error_class: 'ProviderFailed'`; its `payload.detail` is the row's `error_detail`) | `failed` | `generation_status: failed`, `error_detail` set | refunded |\n| you cancelled the run (`POST /v1/jobs/runs/{run_id}/cancel`) | `cancelled` (`job.generation.failed`, `Cancelled`); vxil calls fal's cancel once | `failed` | refunded |\n| the start call to fal failed with a 5xx / network fault | retried with backoff; terminal after the attempts | `processing` \u2192 the outcome above | held until then |\n| the user had too few credits | ended at once (`ReserveInsufficient`), never queued | `failed` (the function marks it); that `request_key` is spent \u2014 retry after a top-up with a new one | nothing held |\n| too many renders in flight, or a jobs-side fault | not created (the function answers the caller `429` with the wait in `retry_after`, or `502`) | `pending`, no run \u2014 calling start-render again with the **same** `request_key` re-drives it | nothing held |\n\n## The contract, in detail\n\n- **What fal receives.** `provider.body` exactly as written, at `<queue url>?fal_webhook=<the signed\n callback URL, encoded once>`. The START also carries the headers `X-Vxil-Jobs-Signature` and\n `x-vxil-run-id` (fal ignores them; a relay of your own can dedupe on the run id).\n- **The callback URL is the credential.** vxil checks the secret token in that URL; fal's own\n signature header is not verified. Do not log the URL or hand it to a client.\n- **What a failure keeps.** fal's top-level `error` string becomes the run's `last_error_msg` (class\n `ProviderFailed`, or the string itself when it is a short identifier). `payload.detail[]` is not\n kept, and `job.generation.failed` carries only `error_class` / `error_hint` \u2014 read the message from\n `GET /v1/jobs/runs/{run_id}`.\n- **What the events carry.** `run_id` plus the two strings `generation_id` and `correlation_id` from\n the run's `payload` (\u2264 128 characters each) \u2014 never the whole payload. Put your row id in\n `generation_id`, as `start-render` does.\n- **No real progress.** fal's queue webhook fires only on completion, so `progress` stays empty; show an\n estimated bar from typical durations.\n- **The deadline runs from enqueue.** `expires_at` = enqueue time + `timeout.after_ms` (5 / 15 min here),\n and the START retries happen inside it. Pad it for fal's queue time.\n- **A second paid fal job is possible, rarely.** When the START to fal times out or gets a `408` / `429` /\n `5xx` *after* fal accepted the job, vxil retries the START and fal may run (and bill) a second job; the\n second callback is deduplicated, so the user sees one result. To rule it out, set `max_attempts: 1`\n on the run (an ambiguous START failure then fails the run and refunds the credits), or put your own\n relay at `provider.url` that dedupes on `x-vxil-run-id`.\n- **Raw provider output on the user's row.** `result` is whatever `result_path` picks. To keep raw output\n off a row the user reads, start with a server key (`reserve_credits.user_id` = the verified user),\n mirror onto a record in an `endUserAccess: 'none'` collection, and copy the vetted fields onto the\n user's row in a function on `job.generation.completed`. The user who holds the credits can also read\n the run's `result` by `run_id`, so don't hand them the run id (or redact the run with\n `POST /v1/jobs/runs/redact` after copying).\n\n## Evidence\n\n- **Executed** (vxil's own integration tests):\n the `?fal_webhook=` callback URL is signed and verifies, the provider receives exactly the model input,\n `OK` settles completed with `payload.images` mirrored as `result` and the hold committed, `ERROR`\n settles failed with the hold refunded, a progress ping stays processing.\n- **Read in fal's documentation, not executed against fal from vxil** \u2014 the queue base\n `https://queue.fal.run/<model>`, the `fal_webhook` query parameter, the submit answer's `request_id`,\n the cancel route `PUT https://queue.fal.run/<app>/requests/<request_id>/cancel` (the app id without a\n model subpath \u2014 `fal-ai/flux`, not `fal-ai/flux/dev`), the webhook body\n `{ request_id, status: 'OK' | 'ERROR', payload, error }` with `payload.detail` on an error, and the\n model ids `fal-ai/flux/dev` and `fal-ai/veo3` with their inputs. fal's cancel stops a request that is\n still queued; whether it stops one already running is fal's call \u2014 the run's `generation_json\n .provider_cancel` records what fal answered. Check the model page for each model's exact input fields before you\n ship, and keep `num_images` / `duration` to what your model accepts.\n\n## Your key, and where it lives\n\n`fal_key` is a function secret; `start-render` reads it at invoke time and puts it in the generation\nrun's provider headers, which vxil stores with the run (your project only) for as long as the jobs\nretention keeps the run, and sends to fal on the start call. It is **never returned by a read**:\n`GET /v1/jobs/runs/{run_id}` (and the dashboard and MCP reads built on it) shows the header names of a\ngeneration run \u2014 the cancel call's included \u2014 with every value as `[redacted]`. Rotate it with `vxil secrets set functions/fal_key`;\nruns already queued keep the key they were started with.\n",
19524
19612
  "functions": {
19525
- "start-render.ts": "// start-render.ts \u2014 start ONE fal.ai render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/start-render (with the user's session)\n// { \"kind\": \"image\" | \"video\", \"prompt\": \"\u2026\", \"request_key\": \"<your idempotency key>\" }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, generation_status }\n// this user's request_key already started one\n//\n// What happens after the 202 is vxil's, not this function's:\n// \u2022 the generation lane POSTs fal's QUEUE API with your key and the signed\n// callback URL in `?fal_webhook=` (completion.callback.query_param);\n// \u2022 fal calls back `{ status: 'OK' | 'ERROR', payload, error? }` \u2014 status_map\n// turns its words into completed / failed;\n// \u2022 result_path picks `payload.images` (or `payload.video`), and the status\n// mirror writes it onto this render's `result` with generation_status;\n// \u2022 the held credits commit on completed and are REFUNDED on failed, on a\n// run fal never calls back about (the timeout), and on a cancel.\n// Every run ends with job.generation.completed or job.generation.failed\n// (generation_id = the render's item_id, correlation_id = its request_key) if\n// you want to react to it in another function.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's `dedupe_key`\n// (owner + ':' + request_key \u2014 PER USER, so one user's key can never block\n// another's) is declared unique, so a second start with the same key is a\n// 409. On a 409 we read THIS user's row: a row that already has its run is a\n// duplicate; a row with no run (the first start died, or its enqueue answer\n// was lost, between the row and the enqueue) is RE-DRIVEN \u2014 the generation\n// run carries the same dedupe_key as its idempotency_key, so jobs hands back\n// the existing run instead of enqueueing a second fal job.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { kind?: string; prompt?: string; request_key?: string };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = { item_id: string; data: { kind?: string; prompt?: string; run_id?: string; generation_status?: string } };\n\n/** fal model ids (https://fal.ai/models) \u2014 swap freely; both are queue APIs. */\nconst MODELS = {\n image: { url: 'https://queue.fal.run/fal-ai/flux/dev', credits: 1, resultPath: 'payload.images' },\n video: { url: 'https://queue.fal.run/fal-ai/veo3', credits: 10, resultPath: 'payload.video' },\n} as const;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const jobs = env.scoped_jwts?.jobs;\n const falKey = env.secrets?.fal_key;\n if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n if (!falKey) return Response.json({ error: 'store your fal key: vxil secrets set functions/fal_key' }, { status: 500 });\n // the held credits are FORCED onto the verified end-user \u2014 a function\n // cannot hold credits against a user it does not act for\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: 'invoke start-render with the user\\'s session (end-user mode)' }, { status: 401 });\n\n const kind = env.payload?.kind === 'video' ? 'video' : env.payload?.kind === 'image' ? 'image' : null;\n const prompt = typeof env.payload?.prompt === 'string' ? env.payload.prompt.trim().slice(0, 2_000) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n if (!kind || !prompt || !requestKey) {\n return Response.json({ error: 'need { kind: image|video, prompt, request_key }' }, { status: 422 });\n }\n const dedupeKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // 1. the render row (owned by the user \u2014 cms forces `owner` in end-user mode;\n // the render_dedupe_key hook checks dedupe_key = owner + ':' + request_key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n dedupe_key: dedupeKey, request_key: requestKey, owner: user, kind, prompt,\n credits: MODELS[kind].credits, generation_status: 'pending', created_at: new Date().toISOString(),\n },\n }),\n });\n if (created.status === 409) {\n // THIS user's row for this key (the read is owner-scoped in end-user mode)\n const filter = encodeURIComponent(JSON.stringify({ dedupe_key: dedupeKey }));\n const found = await fetch(`${base}/v1/cms/items/renders?filter=${filter}&limit=1`, { headers: H });\n const row = found.ok ? ((await found.json()) as { data?: { items?: RenderRow[] } }).data?.items?.[0] : undefined;\n if (!row) return Response.json({ error: `render lookup: ${found.status}` }, { status: 502 });\n if (row.data.run_id || row.data.generation_status === 'failed') {\n return Response.json({\n duplicate: true, request_key: requestKey, item_id: row.item_id,\n run_id: row.data.run_id ?? null, generation_status: row.data.generation_status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on dedupe_key)\n const rowKind = row.data.kind === 'video' ? 'video' : 'image';\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, row.item_id, rowKind, row.data.prompt ?? prompt);\n }\n if (!created.ok) return Response.json({ error: `render row: ${created.status}` }, { status: 502 });\n const itemId = ((await created.json()) as { data: { item_id: string } }).data.item_id;\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, itemId, kind, prompt);\n },\n};\n\n/** 2. the fal queue job, babysat by vxil, credits held for it. Idempotent on\n * `dedupeKey`: a re-drive gets the run that already exists. */\nasync function startRun(\n base: string, H: Record<string, string>, jobs: string, falKey: string, user: string,\n dedupeKey: string, requestKey: string, itemId: string, kind: 'image' | 'video', prompt: string,\n): Promise<Response> {\n const model = MODELS[kind];\n const enq = await fetch(`${base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: `fal.${kind}`,\n // the body is EXACTLY fal's model input (query-string callbacks send\n // provider.body as-is \u2014 no payload / callback_url keys merged in)\n provider: {\n url: model.url,\n method: 'POST',\n headers: { authorization: `Key ${falKey}` },\n body: kind === 'image' ? { prompt, num_images: 1 } : { prompt, duration: '8s' },\n },\n completion: {\n mode: 'webhook',\n status_path: 'status',\n callback: { query_param: 'fal_webhook' },\n status_map: { OK: 'completed', ERROR: 'failed' },\n result_path: model.resultPath,\n },\n status_mirror: { feature: 'cms', collection: 'renders', record_id: itemId, column: 'generation_status' },\n reserve_credits: { amount: model.credits, user_id: user, credit_type: 'media_credits', reason: `fal ${kind}` },\n timeout: { after_ms: kind === 'image' ? 300_000 : 900_000 },\n // rides job.generation.* as generation_id / correlation_id\n payload: { generation_id: itemId, correlation_id: requestKey },\n idempotency_key: dedupeKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held \u2014 mark the row and say so.\n // This key is spent; a new attempt (after a top-up) uses a new request_key.\n await markFailed(base, H, itemId);\n return Response.json({ error: 'insufficient_credits', item_id: itemId }, { status: 402 });\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 5xx: NO run is promised \u2014 leave the row\n // pending with no run, so a retry with the SAME request_key re-drives it\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, item_id: itemId, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 ? 429 : 502 },\n );\n }\n const runId = ((await enq.json()) as { data: { run_id: string } }).data.run_id;\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { run_id: runId } }),\n });\n return Response.json({ item_id: itemId, run_id: runId, credits: model.credits }, { status: 202 });\n}\n\nasync function markFailed(base: string, H: Record<string, string>, itemId: string): Promise<void> {\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { generation_status: 'failed' } }),\n });\n}\n"
19613
+ "check-delivery.ts": "// check-delivery.ts \u2014 job.generation.completed \u2192 did the user actually get\n// media? If not, give the credits back (a vxil function, webhook trigger).\n//\n// A fal run can COMPLETE (fal said `OK`, the hold was committed) and still\n// deliver nothing the user can open: an empty `images` list, a video with no\n// URL. The run is over, so the platform's automatic refund (failed runs only)\n// does not apply \u2014 this function is where \"succeeded but undeliverable\" is\n// decided, and POST /v1/payments/credits/reverse is the typed refund for it:\n// one `reversal` ledger row against the run's committed charge, at most once.\n//\n// AT-LEAST-ONCE: the event can arrive again, and `retry: { maxAttempts: 5 }`\n// sends a non-2xx answer back for another attempt. Both writes are safe to\n// repeat: the reverse answers `already_reversed: true` the second time, and\n// the row patch writes the same value again.\n//\n// ORDERING: the event is written when the run settles, but the status mirror\n// (which writes `result` onto the row) and the commit of the hold land just\n// after it. So this function only judges a row whose `generation_status` is\n// already 'completed'. A row not mirrored yet, and a reverse answered 409\n// `not_committed` (the hold is still being committed), answer 502 for another\n// attempt (the binding's `retry`).\n//\n// Extend `delivered()` with your own checks (copying the media into your own\n// storage, a size or content-type check): anything that returns false here is\n// refunded.\n\nimport type { JobGenerationSettledEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype RenderRow = { kind?: string; result?: unknown; generation_status?: string };\n\n/** true when the render's `result` (the status mirror wrote it from fal's\n * `payload.images` / `payload.video`) holds media the user can open. */\nfunction delivered(kind: string | undefined, result: unknown): boolean {\n const hasUrl = (v: unknown): boolean =>\n typeof v === 'object' && v !== null && typeof (v as { url?: unknown }).url === 'string'\n && (v as { url: string }).url.startsWith('https://');\n if (kind === 'video') return hasUrl(result);\n return Array.isArray(result) && result.length > 0 && result.every(hasUrl);\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 // only a completed run was charged; a failed one was already refunded\n if (!d || 'truncated' in d || d.status !== 'completed' || !d.generation_id) return Response.json({ skipped: true });\n const cms = env.scoped_jwts?.cms;\n const payments = env.scoped_jwts?.payments;\n if (!cms || !payments) return Response.json({ error: 'missing cms/payments 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\n const row = ((await rowRes.json()) as { data: { data: RenderRow } }).data.data;\n // already judged undeliverable (and reversed) by an earlier attempt\n if (row.generation_status === 'failed') return Response.json({ ok: true, delivered: false });\n // the status mirror has not written `result` yet \u2014 judge it on the next attempt\n if (row.generation_status !== 'completed') return Response.json({ error: 'render not mirrored yet' }, { status: 502 });\n if (delivered(row.kind, row.result)) return Response.json({ ok: true, delivered: true });\n\n // Undeliverable: reverse what the run CHARGED (by its run_id = the hold's job_id).\n const rev = await fetch(`${base}/v1/payments/credits/reverse`, {\n method: 'POST',\n headers: { authorization: `Bearer ${payments}`, 'content-type': 'application/json' },\n body: JSON.stringify({ job_id: d.run_id, reason: 'fal returned no media' }),\n });\n if (!rev.ok) {\n const code = rev.status === 409 ? ((await rev.json().catch(() => ({}))) as { error?: { code?: string } }).error?.code : undefined;\n // 404: the run held no credits; 409 nothing_charged: it charged 0 \u2014 nothing to give back.\n // Anything else (409 not_committed: the hold is still being committed, a 5xx) \u2192 another attempt.\n if (rev.status !== 404 && code !== 'nothing_charged') {\n return Response.json({ error: `credits reverse: ${rev.status}${code ? ` ${code}` : ''}` }, { status: 502 });\n }\n }\n\n const patched = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(d.generation_id)}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: { generation_status: 'failed' } }),\n });\n if (!patched.ok) return Response.json({ error: `render patch: ${patched.status}` }, { status: 502 }); // another attempt\n return Response.json({ ok: true, delivered: false, refunded: rev.ok });\n },\n};\n",
19614
+ "start-render.ts": "// start-render.ts \u2014 start ONE fal.ai render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/start-render (with the user's session)\n// { \"kind\": \"image\" | \"video\", \"prompt\": \"\u2026\", \"request_key\": \"<your idempotency key>\" }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, generation_status }\n// this user's request_key already started one\n//\n// What happens after the 202 is vxil's, not this function's:\n// \u2022 the generation lane POSTs fal's QUEUE API with your key and the signed\n// callback URL in `?fal_webhook=` (completion.callback.query_param);\n// \u2022 fal calls back `{ status: 'OK' | 'ERROR', payload, error? }` \u2014 status_map\n// turns its words into completed / failed;\n// \u2022 result_path picks `payload.images` (or `payload.video`), and the status\n// mirror writes it onto this render's `result` with generation_status;\n// \u2022 ref_path keeps fal's `request_id` as the run's provider_ref, and\n// provider.cancel is fal's cancel call: a cancelled or timed-out run is\n// cancelled at fal too (once, best-effort), so fal stops working on it;\n// \u2022 error_path keeps fal's `payload.detail` on a failed run and mirrors it\n// onto this render's `error_detail`;\n// \u2022 the held credits commit on completed and are REFUNDED on failed, on a\n// run fal never calls back about (the timeout), and on a cancel.\n// Every run ends with job.generation.completed or job.generation.failed\n// (generation_id = the render's item_id, correlation_id = its request_key) if\n// you want to react to it in another function.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's `dedupe_key`\n// (owner + ':' + request_key \u2014 PER USER, so one user's key can never block\n// another's) is declared unique, so a second start with the same key is a\n// 409. On a 409 we read THIS user's row: a row that already has its run is a\n// duplicate; a row with no run (the first start died, or its enqueue answer\n// was lost, between the row and the enqueue) is RE-DRIVEN \u2014 the generation\n// run carries the same dedupe_key as its idempotency_key, so jobs hands back\n// the existing run instead of enqueueing a second fal job.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { kind?: string; prompt?: string; request_key?: string };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = { item_id: string; data: { kind?: string; prompt?: string; run_id?: string; generation_status?: string } };\n\n/** fal model ids (https://fal.ai/models) \u2014 swap freely; both are queue APIs.\n * `cancel` is fal's queue cancel endpoint: the request routes use the app id\n * WITHOUT a model subpath (`fal-ai/flux`, not `fal-ai/flux/dev`). */\nconst MODELS = {\n image: {\n url: 'https://queue.fal.run/fal-ai/flux/dev', credits: 1, resultPath: 'payload.images',\n cancel: 'https://queue.fal.run/fal-ai/flux/requests/{provider_ref}/cancel',\n },\n video: {\n url: 'https://queue.fal.run/fal-ai/veo3', credits: 10, resultPath: 'payload.video',\n cancel: 'https://queue.fal.run/fal-ai/veo3/requests/{provider_ref}/cancel',\n },\n} as const;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const jobs = env.scoped_jwts?.jobs;\n const falKey = env.secrets?.fal_key;\n if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n if (!falKey) return Response.json({ error: 'store your fal key: vxil secrets set functions/fal_key' }, { status: 500 });\n // the held credits are FORCED onto the verified end-user \u2014 a function\n // cannot hold credits against a user it does not act for\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: 'invoke start-render with the user\\'s session (end-user mode)' }, { status: 401 });\n\n const kind = env.payload?.kind === 'video' ? 'video' : env.payload?.kind === 'image' ? 'image' : null;\n const prompt = typeof env.payload?.prompt === 'string' ? env.payload.prompt.trim().slice(0, 2_000) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n if (!kind || !prompt || !requestKey) {\n return Response.json({ error: 'need { kind: image|video, prompt, request_key }' }, { status: 422 });\n }\n const dedupeKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // 1. the render row (owned by the user \u2014 cms forces `owner` in end-user mode;\n // the render_dedupe_key hook checks dedupe_key = owner + ':' + request_key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n dedupe_key: dedupeKey, request_key: requestKey, owner: user, kind, prompt,\n credits: MODELS[kind].credits, generation_status: 'pending', created_at: new Date().toISOString(),\n },\n }),\n });\n if (created.status === 409) {\n // THIS user's row for this key (the read is owner-scoped in end-user mode)\n const filter = encodeURIComponent(JSON.stringify({ dedupe_key: dedupeKey }));\n const found = await fetch(`${base}/v1/cms/items/renders?filter=${filter}&limit=1`, { headers: H });\n const row = found.ok ? ((await found.json()) as { data?: { items?: RenderRow[] } }).data?.items?.[0] : undefined;\n if (!row) return Response.json({ error: `render lookup: ${found.status}` }, { status: 502 });\n if (row.data.run_id || row.data.generation_status === 'failed') {\n return Response.json({\n duplicate: true, request_key: requestKey, item_id: row.item_id,\n run_id: row.data.run_id ?? null, generation_status: row.data.generation_status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on dedupe_key)\n const rowKind = row.data.kind === 'video' ? 'video' : 'image';\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, row.item_id, rowKind, row.data.prompt ?? prompt);\n }\n if (!created.ok) return Response.json({ error: `render row: ${created.status}` }, { status: 502 });\n const itemId = ((await created.json()) as { data: { item_id: string } }).data.item_id;\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, itemId, kind, prompt);\n },\n};\n\n/** 2. the fal queue job, babysat by vxil, credits held for it. Idempotent on\n * `dedupeKey`: a re-drive gets the run that already exists. */\nasync function startRun(\n base: string, H: Record<string, string>, jobs: string, falKey: string, user: string,\n dedupeKey: string, requestKey: string, itemId: string, kind: 'image' | 'video', prompt: string,\n): Promise<Response> {\n const model = MODELS[kind];\n const enq = await fetch(`${base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: `fal.${kind}`,\n // the body is EXACTLY fal's model input (query-string callbacks send\n // provider.body as-is \u2014 no payload / callback_url keys merged in)\n provider: {\n url: model.url,\n method: 'POST',\n headers: { authorization: `Key ${falKey}` },\n body: kind === 'image' ? { prompt, num_images: 1 } : { prompt, duration: '8s' },\n // fal's cancel call, made once if this run is cancelled or times out\n // after fal accepted it ({provider_ref} = fal's request_id)\n cancel: { url: model.cancel, method: 'PUT', headers: { authorization: `Key ${falKey}` } },\n },\n completion: {\n mode: 'webhook',\n status_path: 'status',\n callback: { query_param: 'fal_webhook' },\n status_map: { OK: 'completed', ERROR: 'failed' },\n result_path: model.resultPath,\n // fal's submit answer names the job: { request_id, \u2026 }\n ref_path: 'request_id',\n // fal's ERROR webhook carries the reasons in payload.detail\n error_path: 'payload.detail',\n },\n status_mirror: { feature: 'cms', collection: 'renders', record_id: itemId, column: 'generation_status' },\n reserve_credits: { amount: model.credits, user_id: user, credit_type: 'media_credits', reason: `fal ${kind}` },\n timeout: { after_ms: kind === 'image' ? 300_000 : 900_000 },\n // rides job.generation.* as generation_id / correlation_id\n payload: { generation_id: itemId, correlation_id: requestKey },\n idempotency_key: dedupeKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held \u2014 mark the row and say so.\n // This key is spent; a new attempt (after a top-up) uses a new request_key.\n await markFailed(base, H, itemId);\n return Response.json({ error: 'insufficient_credits', item_id: itemId }, { status: 402 });\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 5xx: NO run is promised \u2014 leave the row\n // pending with no run, so a retry with the SAME request_key re-drives it\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, item_id: itemId, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 ? 429 : 502 },\n );\n }\n const runId = ((await enq.json()) as { data: { run_id: string } }).data.run_id;\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { run_id: runId } }),\n });\n return Response.json({ item_id: itemId, run_id: runId, credits: model.credits }, { status: 202 });\n}\n\nasync function markFailed(base: string, H: Record<string, string>, itemId: string): Promise<void> {\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { generation_status: 'failed' } }),\n });\n}\n"
19526
19615
  }
19527
19616
  },
19528
19617
  {
@@ -22032,6 +22121,7 @@ async function runApply(apply, sel = "prod", opts = {}) {
22032
22121
  }
22033
22122
  {
22034
22123
  if (!gate) for (const w of await authMailPushWarnings(cfg, api)) console.error(`vxil: warning \u2014 ${w}`);
22124
+ for (const w of ledgerGrantPeriodWarnings(cfg)) console.error(`vxil: warning \u2014 payments ${w}`);
22035
22125
  const typed = hookTypePreflight(cfg);
22036
22126
  for (const w of typed.warnings) console.error(`vxil: warning \u2014 cms ${w}`);
22037
22127
  if (typed.errors.length) {
@@ -22465,6 +22555,7 @@ ${formatTriggerWiring(w).join("\n")}`);
22465
22555
  }
22466
22556
  {
22467
22557
  for (const w of await authMailPushWarnings(cfg, api)) say(`vxil: warning \u2014 ${w}`);
22558
+ for (const w of ledgerGrantPeriodWarnings(cfg)) say(`vxil: warning \u2014 payments ${w}`);
22468
22559
  const typed = hookTypePreflight(cfg);
22469
22560
  for (const w of typed.warnings) say(`vxil: warning \u2014 cms ${w}`);
22470
22561
  if (typed.errors.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": false,
5
5
  "description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
6
6
  "license": "MIT",
@@ -45,8 +45,8 @@
45
45
  }
46
46
  },
47
47
  "devDependencies": {
48
- "@vxil/config": "0.15.0",
49
- "@vxil/feature-configs": "0.13.0",
48
+ "@vxil/config": "0.16.0",
49
+ "@vxil/feature-configs": "0.14.0",
50
50
  "@vxil/runtime": "0.0.1",
51
51
  "@vxil/types": "0.0.1",
52
52
  "miniflare": "^4.20260730.0"