@vxil/cli 0.8.0 → 0.10.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/README.md +1 -1
- package/dist/vxil.js +1409 -822
- package/package.json +4 -4
package/dist/vxil.js
CHANGED
|
@@ -4044,9 +4044,12 @@ var PaymentsConfigSchema = Type.Object({
|
|
|
4044
4044
|
// { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
|
|
4045
4045
|
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
4046
4046
|
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
4047
|
-
// STACKS
|
|
4048
|
-
//
|
|
4049
|
-
//
|
|
4047
|
+
// STACKS behind the user's live same-tier manual rows that have an end
|
|
4048
|
+
// (earlier passes AND comp grants since 2026-09-25 F38 — never a row
|
|
4049
|
+
// linked to the same charge, an open-ended grant or a provider
|
|
4050
|
+
// subscription) and is ENDED by that charge's full refund / chargeback.
|
|
4051
|
+
// No defaults in either shape, so an existing manifest is
|
|
4052
|
+
// byte-identical after Value.Default.
|
|
4050
4053
|
productMap: Type.Record(Type.String(), Type.Union([
|
|
4051
4054
|
Type.Object({
|
|
4052
4055
|
creditType: Type.String({ minLength: 1 }),
|
|
@@ -4413,7 +4416,8 @@ function lowerAllTriggerBindings(def, name) {
|
|
|
4413
4416
|
}
|
|
4414
4417
|
var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
|
|
4415
4418
|
var VXIL_CONFIG_PKG_VERSION = "0.6.0";
|
|
4416
|
-
var VXIL_SDK_PKG_VERSION = "0.
|
|
4419
|
+
var VXIL_SDK_PKG_VERSION = "0.10.0";
|
|
4420
|
+
var VXIL_CLI_PKG_VERSION = "0.10.0";
|
|
4417
4421
|
function ensureScaffoldPackageJson(cwd, opts = {}) {
|
|
4418
4422
|
const file = resolve(cwd, "package.json");
|
|
4419
4423
|
const wanted = {
|
|
@@ -4614,13 +4618,11 @@ function makeApi({ apiKey, baseUrl, fetchImpl = fetch, retries = 4, sleep, apiVe
|
|
|
4614
4618
|
continue;
|
|
4615
4619
|
}
|
|
4616
4620
|
const text = await res.text();
|
|
4617
|
-
let json2;
|
|
4618
4621
|
try {
|
|
4619
|
-
|
|
4622
|
+
return { status: res.status, body: JSON.parse(text) };
|
|
4620
4623
|
} catch {
|
|
4621
|
-
|
|
4624
|
+
return { status: res.status, body: { error: { code: `http_${res.status}`, message: text.slice(0, 200) } }, raw: text };
|
|
4622
4625
|
}
|
|
4623
|
-
return { status: res.status, body: json2 };
|
|
4624
4626
|
}
|
|
4625
4627
|
};
|
|
4626
4628
|
}
|
|
@@ -7052,6 +7054,10 @@ function scopesForFunctionAudience(aud, scopes) {
|
|
|
7052
7054
|
}
|
|
7053
7055
|
var TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
7054
7056
|
var FUNCTION_SOURCE_MAX_BYTES = 512e3;
|
|
7057
|
+
var FUNCTIONS_RUNTIME = {
|
|
7058
|
+
compatibilityDate: "2025-09-01",
|
|
7059
|
+
compatibilityFlags: []
|
|
7060
|
+
};
|
|
7055
7061
|
|
|
7056
7062
|
// ../runtime/src/hash.ts
|
|
7057
7063
|
async function sha256Hex(input) {
|
|
@@ -7199,6 +7205,9 @@ async function signJobsCallbackWithTenantSecret(tenantSecret, params) {
|
|
|
7199
7205
|
return `t=${t},v1=${v1}`;
|
|
7200
7206
|
}
|
|
7201
7207
|
|
|
7208
|
+
// ../runtime/src/directLane.ts
|
|
7209
|
+
var DIRECT_FN_DECLARED_MAX_MS = 3e4;
|
|
7210
|
+
|
|
7202
7211
|
// src/apiState.ts
|
|
7203
7212
|
var POLICY_LIST_CAP = 100;
|
|
7204
7213
|
var CAMPAIGN_LIST_CAP = 200;
|
|
@@ -7676,7 +7685,10 @@ async function bundleFunction(entryPath, opts = {}) {
|
|
|
7676
7685
|
format: "esm",
|
|
7677
7686
|
platform: "browser",
|
|
7678
7687
|
// Workers runtime (Web-standard globals; no node builtins)
|
|
7679
|
-
|
|
7688
|
+
// The runtime runs ES2024 natively (2026-09-25: was es2022 — every
|
|
7689
|
+
// template function bundled byte-identical at both, so nothing redeployed;
|
|
7690
|
+
// a function using post-ES2022 syntax now ships it as written).
|
|
7691
|
+
target: "es2024",
|
|
7680
7692
|
write: false,
|
|
7681
7693
|
legalComments: "none",
|
|
7682
7694
|
minify: min,
|
|
@@ -7861,7 +7873,7 @@ function formatFunctionsPreflight(e, opts) {
|
|
|
7861
7873
|
}
|
|
7862
7874
|
const plan = /'([a-z]+)' plan/.exec(e.message ?? "")?.[1];
|
|
7863
7875
|
const planLabel = plan ? `the ${plan[0].toUpperCase()}${plan.slice(1)} plan` : "a paid plan";
|
|
7864
|
-
return `functions need ${planLabel}${e.message ? ` (${e.message})` : ""} \u2014 ${n} would be refused with 402 tier_required. Upgrade in the dashboard (${billingPageFor(opts.tenantId, opts.baseUrl)})` + (opts.verb === "push" ? ", or run `vxil push --skip-functions` to push the cms schema and feature config without them" : "") + ".";
|
|
7876
|
+
return `functions need ${planLabel}${e.message ? ` (${e.message})` : ""} \u2014 ${n} would be refused with 402 tier_required. Upgrade in the dashboard (${billingPageFor(opts.tenantId, opts.baseUrl)}) or with \`vxil billing upgrade --tier ${plan ?? "<tier>"}\` (it needs your dashboard session \u2014 \`vxil login\` \u2014 not the project key)` + (opts.verb === "push" ? ", or run `vxil push --skip-functions` to push the cms schema and feature config without them" : "") + ".";
|
|
7865
7877
|
}
|
|
7866
7878
|
function formatLogLines(lines) {
|
|
7867
7879
|
const out = [];
|
|
@@ -8340,25 +8352,101 @@ function formatCollectionDrop(collection, plan, declaredLocally) {
|
|
|
8340
8352
|
// src/apiCmd.ts
|
|
8341
8353
|
var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
8342
8354
|
var API_USAGE = "usage: vxil api <GET|POST|PUT|PATCH|DELETE> </v1/...> ['<json body>' | --data '<json body>'] (`vxil api --help` lists the operator actions)";
|
|
8343
|
-
function
|
|
8344
|
-
const [rawMethod, path, ...extra] = positionals;
|
|
8345
|
-
const method = (rawMethod ?? "").toUpperCase();
|
|
8346
|
-
if (!rawMethod || !API_METHODS.includes(method)) throw new Error(API_USAGE);
|
|
8347
|
-
if (!path || !path.startsWith("/")) throw new Error(API_USAGE);
|
|
8355
|
+
function parseJsonBodyArg(extra, dataFlag, o) {
|
|
8348
8356
|
const positionalBody = extra[0];
|
|
8349
|
-
if (extra.length > 1) throw new Error(`${
|
|
8357
|
+
if (extra.length > 1) throw new Error(`${o.usage} \u2014 too many arguments (${extra.length} after ${o.after}; quote the JSON body as ONE argument)`);
|
|
8350
8358
|
if (positionalBody !== void 0 && dataFlag !== void 0) {
|
|
8351
|
-
throw new Error(
|
|
8359
|
+
throw new Error(`pass the body either as ${o.argName} or with --data, not both`);
|
|
8352
8360
|
}
|
|
8353
8361
|
const raw = positionalBody ?? dataFlag;
|
|
8354
|
-
if (raw === void 0) return {
|
|
8362
|
+
if (raw === void 0) return {};
|
|
8355
8363
|
try {
|
|
8356
|
-
return {
|
|
8364
|
+
return { body: JSON.parse(raw) };
|
|
8357
8365
|
} catch {
|
|
8358
8366
|
const via = positionalBody !== void 0 ? "the body argument" : "--data";
|
|
8359
8367
|
throw new Error(`${via} must be valid JSON (got ${JSON.stringify(raw.length > 60 ? `${raw.slice(0, 57)}\u2026` : raw)})`);
|
|
8360
8368
|
}
|
|
8361
8369
|
}
|
|
8370
|
+
function parseApiArgs(positionals, dataFlag) {
|
|
8371
|
+
const [rawMethod, path, ...extra] = positionals;
|
|
8372
|
+
const method = (rawMethod ?? "").toUpperCase();
|
|
8373
|
+
if (!rawMethod || !API_METHODS.includes(method)) throw new Error(API_USAGE);
|
|
8374
|
+
if (!path || !path.startsWith("/")) throw new Error(API_USAGE);
|
|
8375
|
+
const { body } = parseJsonBodyArg(extra, dataFlag, { usage: API_USAGE, after: "the path", argName: "the third argument" });
|
|
8376
|
+
return body === void 0 ? { method, path } : { method, path, body };
|
|
8377
|
+
}
|
|
8378
|
+
var VALUE_NOUN = { target: "a slot name" };
|
|
8379
|
+
function looksLikeJson(t) {
|
|
8380
|
+
const c = t.trimStart()[0];
|
|
8381
|
+
return c === "{" || c === "[" || c === '"';
|
|
8382
|
+
}
|
|
8383
|
+
function scanBodyVerbArgv(argv, spec) {
|
|
8384
|
+
const positionals = [];
|
|
8385
|
+
const values2 = {};
|
|
8386
|
+
const booleans = [];
|
|
8387
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8388
|
+
const t = argv[i];
|
|
8389
|
+
if (!t.startsWith("--")) {
|
|
8390
|
+
positionals.push(t);
|
|
8391
|
+
continue;
|
|
8392
|
+
}
|
|
8393
|
+
const flagName = t.slice(2);
|
|
8394
|
+
const next = argv[i + 1];
|
|
8395
|
+
if (spec.booleans.has(flagName)) {
|
|
8396
|
+
if (!booleans.includes(flagName)) booleans.push(flagName);
|
|
8397
|
+
continue;
|
|
8398
|
+
}
|
|
8399
|
+
if (spec.values.has(flagName)) {
|
|
8400
|
+
if (next === void 0 || next.startsWith("--")) {
|
|
8401
|
+
if (flagName === "data") throw new Error(`--data needs a value \u2014 ${spec.usage}`);
|
|
8402
|
+
continue;
|
|
8403
|
+
}
|
|
8404
|
+
if (flagName !== "data" && looksLikeJson(next)) {
|
|
8405
|
+
throw new Error(`'${t}' needs ${VALUE_NOUN[flagName] ?? "a value"}, not the JSON body \u2014 ${spec.usage}`);
|
|
8406
|
+
}
|
|
8407
|
+
values2[flagName] = next;
|
|
8408
|
+
i++;
|
|
8409
|
+
continue;
|
|
8410
|
+
}
|
|
8411
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
8412
|
+
if (looksLikeJson(next)) {
|
|
8413
|
+
throw new Error(`'${t}' is followed by the JSON body and would take it as its value \u2014 put the body ${spec.bodyPlace}, or pass it with --data`);
|
|
8414
|
+
}
|
|
8415
|
+
i++;
|
|
8416
|
+
}
|
|
8417
|
+
}
|
|
8418
|
+
return { positionals, values: values2, booleans };
|
|
8419
|
+
}
|
|
8420
|
+
var API_BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "dev", "yes", "help"]);
|
|
8421
|
+
var API_VALUE_FLAGS = /* @__PURE__ */ new Set(["data", "target"]);
|
|
8422
|
+
function parseApiArgv(argv) {
|
|
8423
|
+
const { positionals, values: values2 } = scanBodyVerbArgv(argv, {
|
|
8424
|
+
booleans: API_BOOLEAN_FLAGS,
|
|
8425
|
+
values: API_VALUE_FLAGS,
|
|
8426
|
+
usage: API_USAGE,
|
|
8427
|
+
bodyPlace: "right after the path"
|
|
8428
|
+
});
|
|
8429
|
+
return parseApiArgs(positionals, values2.data);
|
|
8430
|
+
}
|
|
8431
|
+
var INVOKE_USAGE = "usage: vxil functions invoke <name> ['<json body>' | --data '<json body>'] [--async] [--dev | --target <name>]";
|
|
8432
|
+
var INVOKE_BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["async", "json", "dev", "yes", "help"]);
|
|
8433
|
+
var INVOKE_VALUE_FLAGS = /* @__PURE__ */ new Set(["data", "target"]);
|
|
8434
|
+
function parseInvokeArgs(argv) {
|
|
8435
|
+
const { positionals, values: values2, booleans } = scanBodyVerbArgv(argv, {
|
|
8436
|
+
booleans: INVOKE_BOOLEAN_FLAGS,
|
|
8437
|
+
values: INVOKE_VALUE_FLAGS,
|
|
8438
|
+
usage: INVOKE_USAGE,
|
|
8439
|
+
bodyPlace: "right after the function name"
|
|
8440
|
+
});
|
|
8441
|
+
const [sub, name, ...extra] = positionals;
|
|
8442
|
+
if (sub !== "invoke" || !name) throw new Error(INVOKE_USAGE);
|
|
8443
|
+
const { body } = parseJsonBodyArg(extra, values2.data, {
|
|
8444
|
+
usage: INVOKE_USAGE,
|
|
8445
|
+
after: "the function name",
|
|
8446
|
+
argName: "the argument after the function name"
|
|
8447
|
+
});
|
|
8448
|
+
return { name, body: body === void 0 ? {} : body, async: booleans.includes("async") };
|
|
8449
|
+
}
|
|
8362
8450
|
var API_CHEATSHEET = [
|
|
8363
8451
|
// cms
|
|
8364
8452
|
{ feature: "cms", what: "materialize a declared read model now", cmd: "vxil api POST /v1/cms/read-models/<name>/materialize" },
|
|
@@ -8432,8 +8520,10 @@ function apiCheatSheet() {
|
|
|
8432
8520
|
API_USAGE,
|
|
8433
8521
|
"",
|
|
8434
8522
|
"Calls any /v1 route with the bound project key (--dev / --target <name> pick the slot). The body is the third",
|
|
8435
|
-
"argument OR --data, never both
|
|
8436
|
-
"
|
|
8523
|
+
"argument OR --data, never both, and a flag between the path and the body never takes it (an unknown one there",
|
|
8524
|
+
"is refused); the response `data` prints as bare JSON on stdout (`| jq` works; a body with",
|
|
8525
|
+
"no `data` key \u2014 a function's own answer on /v1/fn/<name> \u2014 prints whole), the target banner goes to stderr;",
|
|
8526
|
+
"a GET prints no banner. Exit 1 on an error envelope or any non-2xx status.",
|
|
8437
8527
|
"",
|
|
8438
8528
|
"Operator actions from the CLI \u2014 the dashboard's per-feature actions and the line that does each one:"
|
|
8439
8529
|
];
|
|
@@ -8454,190 +8544,714 @@ function apiCheatSheet() {
|
|
|
8454
8544
|
return lines.join("\n");
|
|
8455
8545
|
}
|
|
8456
8546
|
|
|
8457
|
-
// src/
|
|
8458
|
-
|
|
8459
|
-
|
|
8547
|
+
// src/envelopeOutput.ts
|
|
8548
|
+
var DEFAULT_IO = {
|
|
8549
|
+
out: (t) => console.log(t),
|
|
8550
|
+
err: (l) => console.error(l),
|
|
8551
|
+
exit: (c) => process.exit(c)
|
|
8552
|
+
};
|
|
8553
|
+
var RAW_ERROR_MAX_CHARS = 2e3;
|
|
8554
|
+
var is2xx = (status) => status >= 200 && status < 300;
|
|
8555
|
+
function isRecord(v) {
|
|
8556
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
8460
8557
|
}
|
|
8461
|
-
function
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
return
|
|
8465
|
-
|
|
8558
|
+
function errorEnvelopeOf(body) {
|
|
8559
|
+
if (!isRecord(body) || !isRecord(body.error) || typeof body.error.code !== "string") return null;
|
|
8560
|
+
const e = body.error;
|
|
8561
|
+
return {
|
|
8562
|
+
code: e.code,
|
|
8563
|
+
...typeof e.message === "string" ? { message: e.message } : {},
|
|
8564
|
+
...typeof e.hint === "string" ? { hint: e.hint } : {}
|
|
8565
|
+
};
|
|
8466
8566
|
}
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
const t = tenants.find((x) => x.slug === slug);
|
|
8473
|
-
if (!t) {
|
|
8474
|
-
throw new Error(
|
|
8475
|
-
`no project '${slug}' on this account \u2014 run \`vxil quickstart\`, or ask an admin to add you as a member`
|
|
8476
|
-
);
|
|
8477
|
-
}
|
|
8478
|
-
return { tenant: t };
|
|
8567
|
+
function envelopeLines(status, e) {
|
|
8568
|
+
return [
|
|
8569
|
+
`error ${status}: ${e.code}${e.message !== void 0 ? ` \u2014 ${e.message}` : ""}`,
|
|
8570
|
+
...e.hint !== void 0 ? [`hint: ${e.hint}`] : []
|
|
8571
|
+
];
|
|
8479
8572
|
}
|
|
8480
|
-
|
|
8481
|
-
|
|
8482
|
-
|
|
8483
|
-
|
|
8484
|
-
|
|
8485
|
-
if (
|
|
8486
|
-
|
|
8487
|
-
|
|
8488
|
-
}
|
|
8489
|
-
const
|
|
8490
|
-
|
|
8491
|
-
return {
|
|
8573
|
+
var pretty = (v) => JSON.stringify(v, null, 2) ?? String(v);
|
|
8574
|
+
function clip(text) {
|
|
8575
|
+
return text.length > RAW_ERROR_MAX_CHARS ? `${text.slice(0, RAW_ERROR_MAX_CHARS)}\u2026 (${text.length - RAW_ERROR_MAX_CHARS} more characters)` : text;
|
|
8576
|
+
}
|
|
8577
|
+
function formatEnvelope(res, opts = {}) {
|
|
8578
|
+
if (opts.rawText && res.raw !== void 0 && is2xx(res.status)) return { stdout: res.raw, stderr: [], exitCode: 0 };
|
|
8579
|
+
const e = errorEnvelopeOf(res.body);
|
|
8580
|
+
if (e) return { stderr: envelopeLines(res.status, e), exitCode: 1 };
|
|
8581
|
+
if (!is2xx(res.status)) return { stderr: [`error ${res.status}:`, pretty(res.body)], exitCode: 1 };
|
|
8582
|
+
const b2 = res.body;
|
|
8583
|
+
const envelopeData = isRecord(b2) && "data" in b2 && (b2.error === void 0 || b2.error === null);
|
|
8584
|
+
return { stdout: pretty(envelopeData ? b2.data : b2), stderr: [], exitCode: 0 };
|
|
8585
|
+
}
|
|
8586
|
+
function formatFnAnswer(name, res) {
|
|
8587
|
+
if (is2xx(res.status)) {
|
|
8588
|
+
if (res.raw !== void 0) return { stdout: res.raw, stderr: [], exitCode: 0 };
|
|
8589
|
+
const b2 = res.body;
|
|
8590
|
+
const data2 = isRecord(b2) ? b2.data : void 0;
|
|
8591
|
+
return { stdout: pretty(data2 ?? b2), stderr: [], exitCode: 0 };
|
|
8592
|
+
}
|
|
8593
|
+
const hint = errorEnvelopeOf(res.body)?.hint;
|
|
8594
|
+
return {
|
|
8595
|
+
stderr: [
|
|
8596
|
+
`error ${res.status} from function '${name}':`,
|
|
8597
|
+
res.raw !== void 0 ? clip(res.raw) : pretty(res.body),
|
|
8598
|
+
...hint !== void 0 ? [`hint: ${hint}`] : []
|
|
8599
|
+
],
|
|
8600
|
+
exitCode: 1
|
|
8601
|
+
};
|
|
8492
8602
|
}
|
|
8493
|
-
|
|
8494
|
-
|
|
8495
|
-
const
|
|
8496
|
-
if (
|
|
8497
|
-
throw new Error(`could not start the link (${errText(start)})`);
|
|
8498
|
-
}
|
|
8499
|
-
const d = data(start);
|
|
8500
|
-
if (!d.device_code || !d.user_code) throw new Error("link start returned no device_code");
|
|
8501
|
-
io.log("");
|
|
8502
|
-
io.log(`To link '${slug}', approve this request in your browser:`);
|
|
8503
|
-
io.log("");
|
|
8504
|
-
io.log(` ${d.verification_uri_complete ?? d.verification_uri ?? "(open the dashboard \u2192 CLI link)"}`);
|
|
8505
|
-
io.log(` code: ${d.user_code}`);
|
|
8506
|
-
io.log("");
|
|
8507
|
-
io.log("Waiting for approval (Ctrl-C to cancel)\u2026");
|
|
8508
|
-
const intervalMs = Math.max(1, d.interval ?? 5) * 1e3;
|
|
8509
|
-
const deadline = now() + Math.max(1, d.expires_in ?? 900) * 1e3;
|
|
8510
|
-
while (now() < deadline) {
|
|
8511
|
-
await io.sleep(intervalMs);
|
|
8512
|
-
const poll = await dash("POST", "/dashboard/cli/link/token", { device_code: d.device_code });
|
|
8513
|
-
if (poll.status === 428) continue;
|
|
8514
|
-
if (poll.status === 200) {
|
|
8515
|
-
const p = data(poll);
|
|
8516
|
-
if (!p.api_key || !p.tenant_id) throw new Error("approval returned no api_key");
|
|
8517
|
-
return { slug: p.slug || slug, tenant_id: p.tenant_id, api_key: p.api_key };
|
|
8518
|
-
}
|
|
8519
|
-
throw new Error(`link failed (${errText(poll)})`);
|
|
8520
|
-
}
|
|
8521
|
-
throw new Error("link timed out waiting for browser approval \u2014 run `vxil link <slug>` again");
|
|
8603
|
+
function emitOutput(o, io = DEFAULT_IO) {
|
|
8604
|
+
if (o.stdout !== void 0) io.out(o.stdout);
|
|
8605
|
+
for (const l of o.stderr) io.err(l);
|
|
8606
|
+
if (o.exitCode !== 0) io.exit(o.exitCode);
|
|
8522
8607
|
}
|
|
8523
|
-
|
|
8524
|
-
|
|
8525
|
-
try {
|
|
8526
|
-
res = await api("GET", "/v1/features");
|
|
8527
|
-
} catch (e2) {
|
|
8528
|
-
return { invalid: `could not reach the edge (${e2.message})` };
|
|
8529
|
-
}
|
|
8530
|
-
if (res.status === 200) {
|
|
8531
|
-
const tenantId = res.body.meta?.tenant_id;
|
|
8532
|
-
return { result: { slug, tenant_id: tenantId && tenantId.length ? tenantId : "(unknown)", api_key: storedKey } };
|
|
8533
|
-
}
|
|
8534
|
-
const e = res.body.error;
|
|
8535
|
-
return { invalid: `${e?.code ?? res.status}${e?.message ? ` \u2014 ${e.message}` : ""}` };
|
|
8608
|
+
function printEnvelope(res, io = DEFAULT_IO) {
|
|
8609
|
+
emitOutput(formatEnvelope(res), io);
|
|
8536
8610
|
}
|
|
8537
8611
|
|
|
8538
|
-
// src/
|
|
8539
|
-
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
8543
|
-
|
|
8544
|
-
return
|
|
8612
|
+
// src/store.ts
|
|
8613
|
+
import { homedir as homedir2 } from "node:os";
|
|
8614
|
+
import { join as join2, resolve as resolve3 } from "node:path";
|
|
8615
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2, chmodSync } from "node:fs";
|
|
8616
|
+
function dashboardOrigin(base2) {
|
|
8617
|
+
try {
|
|
8618
|
+
return new URL(base2).origin.toLowerCase();
|
|
8619
|
+
} catch {
|
|
8620
|
+
return base2.trim().replace(/\/+$/, "").toLowerCase();
|
|
8545
8621
|
}
|
|
8546
|
-
return { role: r };
|
|
8547
8622
|
}
|
|
8548
|
-
function
|
|
8549
|
-
|
|
8550
|
-
if (!
|
|
8551
|
-
|
|
8623
|
+
function storedSessionFor(creds, dashBase) {
|
|
8624
|
+
if (!creds.session) return { session: null, reason: "no-session" };
|
|
8625
|
+
if (!creds.session_host) return { session: null, reason: "unbound" };
|
|
8626
|
+
const host = dashboardOrigin(creds.session_host);
|
|
8627
|
+
if (host !== dashboardOrigin(dashBase)) return { session: null, reason: "other-host", host };
|
|
8628
|
+
return { session: creds.session, host };
|
|
8552
8629
|
}
|
|
8553
|
-
function
|
|
8554
|
-
|
|
8555
|
-
if (
|
|
8556
|
-
|
|
8557
|
-
try {
|
|
8558
|
-
const t = new URL(s).searchParams.get("token");
|
|
8559
|
-
if (t && t.trim()) return { token: t.trim() };
|
|
8560
|
-
} catch {
|
|
8561
|
-
}
|
|
8562
|
-
return { error: "that URL carries no ?token= \u2014 paste the invitation link from the e-mail, or the token itself" };
|
|
8563
|
-
}
|
|
8564
|
-
return { token: s };
|
|
8630
|
+
function sessionNotForTarget(s, target) {
|
|
8631
|
+
if (s.reason === "other-host") return `the stored dashboard session belongs to ${s.host}, not this target (${dashboardOrigin(target)}) \u2014 run \`vxil login\` against this target to use it here`;
|
|
8632
|
+
if (s.reason === "unbound") return `the stored dashboard session has no recorded host (written by an older CLI) and is not sent anywhere \u2014 run \`vxil login\` again to bind it`;
|
|
8633
|
+
return "no dashboard session \u2014 run `vxil login`";
|
|
8565
8634
|
}
|
|
8566
|
-
|
|
8567
|
-
|
|
8568
|
-
if (m.status === 401) return { unauthorized: true };
|
|
8569
|
-
if (m.status !== 200) throw new Error(`could not list the members (${errText(m)})`);
|
|
8570
|
-
const p = await dash("GET", `/dashboard/tenants/${tenantId}/members/pending`, void 0, cookie);
|
|
8571
|
-
if (p.status === 401) return { unauthorized: true };
|
|
8572
|
-
const invites = p.status === 200 ? data(p).invites ?? [] : [];
|
|
8573
|
-
return { members: data(m).members ?? [], invites };
|
|
8635
|
+
function credDir() {
|
|
8636
|
+
return join2(homedir2(), ".vxil");
|
|
8574
8637
|
}
|
|
8575
|
-
function
|
|
8576
|
-
|
|
8577
|
-
return Number.isFinite(t) && t <= now;
|
|
8638
|
+
function credFile() {
|
|
8639
|
+
return join2(credDir(), "credentials.json");
|
|
8578
8640
|
}
|
|
8579
|
-
function
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
}
|
|
8585
|
-
if (invites.length) {
|
|
8586
|
-
lines.push("");
|
|
8587
|
-
lines.push(`pending invitations (${invites.length}):`);
|
|
8588
|
-
for (const i of invites) {
|
|
8589
|
-
const state = inviteExpired(i, now) ? "EXPIRED" : `expires ${i.expires_at}`;
|
|
8590
|
-
lines.push(` ${i.role.padEnd(9)} ${i.email} ${i.id} ${state}`);
|
|
8591
|
-
}
|
|
8641
|
+
function loadCredentials() {
|
|
8642
|
+
try {
|
|
8643
|
+
return JSON.parse(readFileSync3(credFile(), "utf8"));
|
|
8644
|
+
} catch {
|
|
8645
|
+
return {};
|
|
8592
8646
|
}
|
|
8593
|
-
return lines;
|
|
8594
8647
|
}
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8648
|
+
function saveCredentials(c) {
|
|
8649
|
+
mkdirSync(credDir(), { recursive: true });
|
|
8650
|
+
writeFileSync2(credFile(), JSON.stringify(c, null, 2));
|
|
8651
|
+
try {
|
|
8652
|
+
chmodSync(credFile(), 384);
|
|
8653
|
+
} catch {
|
|
8600
8654
|
}
|
|
8601
|
-
const d = data(r);
|
|
8602
|
-
return { email: d.email ?? req.email, role: d.role ?? req.role };
|
|
8603
|
-
}
|
|
8604
|
-
async function addMemberViaSession(dash, cookie, tenantId, req) {
|
|
8605
|
-
const r = await dash("POST", `/dashboard/tenants/${tenantId}/members`, { email: req.email, role: req.role }, cookie);
|
|
8606
|
-
if (r.status === 401) return { unauthorized: true };
|
|
8607
|
-
if (r.status !== 201 && r.status !== 200) throw new Error(`could not add ${req.email} (${errText(r)})`);
|
|
8608
|
-
const d = data(r);
|
|
8609
|
-
if (!d.user_id) throw new Error("the add returned no user_id");
|
|
8610
|
-
return { user_id: d.user_id, role: d.role ?? req.role };
|
|
8611
8655
|
}
|
|
8612
|
-
function
|
|
8613
|
-
|
|
8614
|
-
const byId = members.find((m) => m.user_id === r);
|
|
8615
|
-
if (byId) return byId;
|
|
8616
|
-
const lower = r.toLowerCase();
|
|
8617
|
-
return members.find((m) => m.email.toLowerCase() === lower) ?? null;
|
|
8656
|
+
function projectFile(cwd = process.cwd()) {
|
|
8657
|
+
return resolve3(cwd, ".vxil", "project.json");
|
|
8618
8658
|
}
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8659
|
+
function loadProject(cwd = process.cwd()) {
|
|
8660
|
+
try {
|
|
8661
|
+
return JSON.parse(readFileSync3(projectFile(cwd), "utf8"));
|
|
8662
|
+
} catch {
|
|
8663
|
+
return null;
|
|
8664
|
+
}
|
|
8624
8665
|
}
|
|
8625
|
-
function
|
|
8626
|
-
const
|
|
8627
|
-
|
|
8628
|
-
|
|
8629
|
-
const lower = r.toLowerCase();
|
|
8630
|
-
return invites.find((i) => i.email.toLowerCase() === lower) ?? null;
|
|
8666
|
+
function saveProject(binding, cwd = process.cwd()) {
|
|
8667
|
+
const dir = resolve3(cwd, ".vxil");
|
|
8668
|
+
mkdirSync(dir, { recursive: true });
|
|
8669
|
+
writeFileSync2(projectFile(cwd), JSON.stringify(binding, null, 2));
|
|
8631
8670
|
}
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8671
|
+
function envLabelFor(base2) {
|
|
8672
|
+
try {
|
|
8673
|
+
return /(^|\.)api\.vxil\.com$/.test(new URL(base2).hostname) ? "production" : "staging";
|
|
8674
|
+
} catch {
|
|
8675
|
+
return "staging";
|
|
8676
|
+
}
|
|
8638
8677
|
}
|
|
8639
|
-
|
|
8640
|
-
const
|
|
8678
|
+
function mergePrimaryBinding(existing, primary) {
|
|
8679
|
+
const { ephemeral: _e, expires_at: _x, ...keep } = existing ?? {};
|
|
8680
|
+
void _e;
|
|
8681
|
+
void _x;
|
|
8682
|
+
return {
|
|
8683
|
+
...keep,
|
|
8684
|
+
tenant_id: primary.tenant_id,
|
|
8685
|
+
slug: primary.slug,
|
|
8686
|
+
edge_url: primary.edge_url,
|
|
8687
|
+
...primary.env !== void 0 ? { env: primary.env } : {}
|
|
8688
|
+
};
|
|
8689
|
+
}
|
|
8690
|
+
function bindNamedSlot(existing, name, slot) {
|
|
8691
|
+
if (!existing) {
|
|
8692
|
+
throw new Error(
|
|
8693
|
+
`no project bound in this directory yet \u2014 run \`vxil link <slug>\` (or \`vxil quickstart\`) first, then \`vxil link <other-slug> --as ${name}\``
|
|
8694
|
+
);
|
|
8695
|
+
}
|
|
8696
|
+
if (name === "dev") {
|
|
8697
|
+
return {
|
|
8698
|
+
...existing,
|
|
8699
|
+
dev: {
|
|
8700
|
+
tenant_id: slot.tenant_id,
|
|
8701
|
+
slug: slot.slug,
|
|
8702
|
+
edge_url: slot.edge_url,
|
|
8703
|
+
...slot.expires_at ? { expires_at: slot.expires_at } : {}
|
|
8704
|
+
}
|
|
8705
|
+
};
|
|
8706
|
+
}
|
|
8707
|
+
const { expires_at: _x, ...permanent } = slot;
|
|
8708
|
+
void _x;
|
|
8709
|
+
return { ...existing, targets: { ...existing.targets, [name]: permanent } };
|
|
8710
|
+
}
|
|
8711
|
+
var TARGET_NAME_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
8712
|
+
function selectorFrom(flags) {
|
|
8713
|
+
if (flags.target !== void 0) return { kind: "named", name: flags.target };
|
|
8714
|
+
if (flags.dev) return { kind: "dev" };
|
|
8715
|
+
return { kind: "primary" };
|
|
8716
|
+
}
|
|
8717
|
+
function describeSelector(sel) {
|
|
8718
|
+
return sel.kind === "primary" ? "primary" : sel.kind === "dev" ? "--dev" : `--target ${sel.name}`;
|
|
8719
|
+
}
|
|
8720
|
+
var TargetUnboundError = class extends Error {
|
|
8721
|
+
constructor(selector2, fix) {
|
|
8722
|
+
super(
|
|
8723
|
+
sel_msg(selector2) + ` \u2014 ${fix}`
|
|
8724
|
+
);
|
|
8725
|
+
this.selector = selector2;
|
|
8726
|
+
this.fix = fix;
|
|
8727
|
+
this.name = "TargetUnboundError";
|
|
8728
|
+
}
|
|
8729
|
+
selector;
|
|
8730
|
+
fix;
|
|
8731
|
+
};
|
|
8732
|
+
function sel_msg(sel) {
|
|
8733
|
+
if (sel.kind === "dev") return "no dev tenant is bound in .vxil/project.json";
|
|
8734
|
+
if (sel.kind === "named") return `no target '${sel.name}' is bound in .vxil/project.json`;
|
|
8735
|
+
return "no project is bound";
|
|
8736
|
+
}
|
|
8737
|
+
function resolve_target(opts) {
|
|
8738
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
8739
|
+
const proj = opts.project !== void 0 ? opts.project : loadProject(cwd);
|
|
8740
|
+
const creds = opts.credentials ?? loadCredentials();
|
|
8741
|
+
const env = opts.env ?? process.env;
|
|
8742
|
+
const envKey = env.VXIL_API_KEY || void 0;
|
|
8743
|
+
const envBase = env.VXIL_BASE_URL || void 0;
|
|
8744
|
+
const selector2 = selectorFrom({ dev: opts.which === "dev", target: opts.target });
|
|
8745
|
+
if (selector2.kind === "dev") {
|
|
8746
|
+
const slot = proj?.dev;
|
|
8747
|
+
if (!slot) {
|
|
8748
|
+
throw new TargetUnboundError(
|
|
8749
|
+
selector2,
|
|
8750
|
+
"run `vxil dev up`, `vxil dev branch <name>`, or `vxil link <slug> --as dev` to bind one (the primary/production key is never used for --dev)"
|
|
8751
|
+
);
|
|
8752
|
+
}
|
|
8753
|
+
const devKey = env.VXIL_DEV_KEY || void 0;
|
|
8754
|
+
const stored = creds.keys?.[slot.slug];
|
|
8755
|
+
const baseUrl2 = envBase ?? slot.edge_url ?? opts.defaultBase;
|
|
8756
|
+
return {
|
|
8757
|
+
apiKey: devKey ?? stored,
|
|
8758
|
+
baseUrl: baseUrl2,
|
|
8759
|
+
tenantSlug: slot.slug,
|
|
8760
|
+
tenantId: slot.tenant_id,
|
|
8761
|
+
slot: "dev",
|
|
8762
|
+
envLabel: envLabelFor(baseUrl2),
|
|
8763
|
+
keySource: devKey ? "VXIL_DEV_KEY" : stored ? "credentials" : "none",
|
|
8764
|
+
selector: selector2
|
|
8765
|
+
};
|
|
8766
|
+
}
|
|
8767
|
+
if (selector2.kind === "named") {
|
|
8768
|
+
const name = selector2.name;
|
|
8769
|
+
const slot = proj?.targets?.[name] ?? proj?.branches?.[name] ?? (name === "dev" ? proj?.dev : void 0);
|
|
8770
|
+
if (!slot) {
|
|
8771
|
+
throw new TargetUnboundError(
|
|
8772
|
+
selector2,
|
|
8773
|
+
`run \`vxil link <slug> --as ${name}\` to bind it (or \`vxil dev branch ${name}\` for an ephemeral one); the primary key is never substituted for a missing target`
|
|
8774
|
+
);
|
|
8775
|
+
}
|
|
8776
|
+
const stored = creds.keys?.[slot.slug];
|
|
8777
|
+
const baseUrl2 = envBase ?? slot.edge_url ?? opts.defaultBase;
|
|
8778
|
+
return {
|
|
8779
|
+
apiKey: stored,
|
|
8780
|
+
baseUrl: baseUrl2,
|
|
8781
|
+
tenantSlug: slot.slug,
|
|
8782
|
+
tenantId: slot.tenant_id,
|
|
8783
|
+
slot: "named",
|
|
8784
|
+
slotName: name,
|
|
8785
|
+
envLabel: slot.env ?? envLabelFor(baseUrl2),
|
|
8786
|
+
keySource: stored ? "credentials" : "none",
|
|
8787
|
+
selector: selector2
|
|
8788
|
+
};
|
|
8789
|
+
}
|
|
8790
|
+
if (envKey) {
|
|
8791
|
+
const baseUrl2 = envBase ?? proj?.edge_url ?? opts.defaultBase;
|
|
8792
|
+
return {
|
|
8793
|
+
apiKey: envKey,
|
|
8794
|
+
baseUrl: baseUrl2,
|
|
8795
|
+
tenantSlug: proj?.slug,
|
|
8796
|
+
tenantId: proj?.tenant_id,
|
|
8797
|
+
slot: proj ? "primary" : "env",
|
|
8798
|
+
envLabel: proj?.env ?? envLabelFor(baseUrl2),
|
|
8799
|
+
keySource: "VXIL_API_KEY",
|
|
8800
|
+
selector: selector2
|
|
8801
|
+
};
|
|
8802
|
+
}
|
|
8803
|
+
if (proj) {
|
|
8804
|
+
const key = creds.keys?.[proj.slug];
|
|
8805
|
+
const baseUrl2 = envBase ?? proj.edge_url ?? opts.defaultBase;
|
|
8806
|
+
return {
|
|
8807
|
+
apiKey: key,
|
|
8808
|
+
baseUrl: baseUrl2,
|
|
8809
|
+
tenantSlug: proj.slug,
|
|
8810
|
+
tenantId: proj.tenant_id,
|
|
8811
|
+
slot: "primary",
|
|
8812
|
+
envLabel: proj.env ?? envLabelFor(baseUrl2),
|
|
8813
|
+
keySource: key ? "credentials" : "none",
|
|
8814
|
+
selector: selector2
|
|
8815
|
+
};
|
|
8816
|
+
}
|
|
8817
|
+
const baseUrl = envBase ?? opts.defaultBase;
|
|
8818
|
+
return {
|
|
8819
|
+
apiKey: void 0,
|
|
8820
|
+
baseUrl,
|
|
8821
|
+
tenantSlug: void 0,
|
|
8822
|
+
tenantId: void 0,
|
|
8823
|
+
slot: "none",
|
|
8824
|
+
envLabel: envLabelFor(baseUrl),
|
|
8825
|
+
keySource: "none",
|
|
8826
|
+
selector: selector2
|
|
8827
|
+
};
|
|
8828
|
+
}
|
|
8829
|
+
function targetBanner(t, verb) {
|
|
8830
|
+
const host = (() => {
|
|
8831
|
+
try {
|
|
8832
|
+
return new URL(t.baseUrl).host;
|
|
8833
|
+
} catch {
|
|
8834
|
+
return t.baseUrl;
|
|
8835
|
+
}
|
|
8836
|
+
})();
|
|
8837
|
+
const slot = t.slot === "named" ? `target '${t.slotName}'` : t.slot === "dev" ? "dev slot" : t.slot === "env" ? "VXIL_API_KEY (no project binding)" : t.slot === "primary" ? "primary" : "unbound";
|
|
8838
|
+
const tenant = t.tenantSlug ? `tenant '${t.tenantSlug}'` : "tenant (unknown slug)";
|
|
8839
|
+
return `vxil ${verb} \u2192 ${tenant} \xB7 ${t.envLabel.toUpperCase()} \xB7 ${host} [${slot}]`;
|
|
8840
|
+
}
|
|
8841
|
+
function bindingSlots(proj) {
|
|
8842
|
+
if (!proj) return [];
|
|
8843
|
+
const out = [
|
|
8844
|
+
{ label: "primary", slug: proj.slug, tenant_id: proj.tenant_id, edge_url: proj.edge_url }
|
|
8845
|
+
];
|
|
8846
|
+
const seen = /* @__PURE__ */ new Set([proj.slug]);
|
|
8847
|
+
const push = (label, s) => {
|
|
8848
|
+
if (seen.has(s.slug)) return;
|
|
8849
|
+
seen.add(s.slug);
|
|
8850
|
+
out.push({ label, slug: s.slug, tenant_id: s.tenant_id, edge_url: s.edge_url });
|
|
8851
|
+
};
|
|
8852
|
+
if (proj.dev) push("dev", proj.dev);
|
|
8853
|
+
for (const [n, s] of Object.entries(proj.targets ?? {})) push(`target:${n}`, s);
|
|
8854
|
+
for (const [n, s] of Object.entries(proj.branches ?? {})) push(`branch:${n}`, s);
|
|
8855
|
+
return out;
|
|
8856
|
+
}
|
|
8857
|
+
|
|
8858
|
+
// src/promotionGate.ts
|
|
8859
|
+
var NON_PRODUCTION_ENV_LABELS = /* @__PURE__ */ new Set([
|
|
8860
|
+
"staging",
|
|
8861
|
+
"stage",
|
|
8862
|
+
"dev",
|
|
8863
|
+
"development",
|
|
8864
|
+
"test",
|
|
8865
|
+
"qa",
|
|
8866
|
+
"preview",
|
|
8867
|
+
"sandbox",
|
|
8868
|
+
"local"
|
|
8869
|
+
]);
|
|
8870
|
+
function normalizeLabel(label) {
|
|
8871
|
+
return label.trim().toLowerCase();
|
|
8872
|
+
}
|
|
8873
|
+
function isNonProductionLabel(label) {
|
|
8874
|
+
return NON_PRODUCTION_ENV_LABELS.has(normalizeLabel(label));
|
|
8875
|
+
}
|
|
8876
|
+
function isDevSlot(t) {
|
|
8877
|
+
return t.slot === "dev" || t.slot === "named" && t.selector.kind === "named" && t.selector.name === "dev";
|
|
8878
|
+
}
|
|
8879
|
+
function isProductionTarget(t) {
|
|
8880
|
+
if (isDevSlot(t)) return false;
|
|
8881
|
+
if (normalizeLabel(t.envLabel) === "production") return true;
|
|
8882
|
+
return envLabelFor(t.baseUrl) === "production" && !isNonProductionLabel(t.envLabel);
|
|
8883
|
+
}
|
|
8884
|
+
function hostOf(baseUrl) {
|
|
8885
|
+
try {
|
|
8886
|
+
return new URL(baseUrl).host;
|
|
8887
|
+
} catch {
|
|
8888
|
+
return baseUrl;
|
|
8889
|
+
}
|
|
8890
|
+
}
|
|
8891
|
+
function unrecognizedLabelNotice(t) {
|
|
8892
|
+
if (isDevSlot(t)) return null;
|
|
8893
|
+
if (normalizeLabel(t.envLabel) === "production") return null;
|
|
8894
|
+
if (envLabelFor(t.baseUrl) !== "production") return null;
|
|
8895
|
+
if (isNonProductionLabel(t.envLabel)) return null;
|
|
8896
|
+
return `env label '${t.envLabel}' is not a recognized environment and the host is ${hostOf(t.baseUrl)} \u2014 treating this target as PRODUCTION (use --env staging|dev|test|qa|preview|sandbox|local to say otherwise)`;
|
|
8897
|
+
}
|
|
8898
|
+
function envLabelWarning(label, baseUrl) {
|
|
8899
|
+
const l = normalizeLabel(label);
|
|
8900
|
+
const host = hostOf(baseUrl);
|
|
8901
|
+
if (envLabelFor(baseUrl) === "production") {
|
|
8902
|
+
if (l === "production") return null;
|
|
8903
|
+
if (isNonProductionLabel(l)) {
|
|
8904
|
+
return `slot labelled '${label}' on the PRODUCTION host ${host} \u2014 the push promotion gate will NOT fire for it (the documented staging-twin pattern; re-link with --env production if this IS a production tenant)`;
|
|
8905
|
+
}
|
|
8906
|
+
return `env label '${label}' is not a recognized environment \u2014 on the PRODUCTION host ${host} it is treated as PRODUCTION (the push promotion gate WILL fire). Use production|staging|dev|test|qa|preview|sandbox|local.`;
|
|
8907
|
+
}
|
|
8908
|
+
if (l === "production") {
|
|
8909
|
+
return `slot labelled 'production' on the non-production host ${host} \u2014 the push promotion gate WILL fire for it`;
|
|
8910
|
+
}
|
|
8911
|
+
return null;
|
|
8912
|
+
}
|
|
8913
|
+
function quickstartEnvNotice(envFlag, baseUrl, slug, opts = {}) {
|
|
8914
|
+
if (envFlag !== void 0) return envLabelWarning(envFlag, baseUrl);
|
|
8915
|
+
if (envLabelFor(baseUrl) !== "production") return null;
|
|
8916
|
+
const label = opts.dev ? "dev" : "staging";
|
|
8917
|
+
return `bound '${slug}' as 'production' \u2014 the host is ${hostOf(baseUrl)} and no --env was given, so \`vxil push\` runs the production promotion gate for it (mock/sandbox refusals, the typed confirmation). If this is a staging or dev project, relabel it now with \`vxil link ${slug} --env ${label}\` (it reuses the stored key), or next time run \`vxil quickstart --env ${label}\`.`;
|
|
8918
|
+
}
|
|
8919
|
+
function namedSlotConflict(existing, slug, as) {
|
|
8920
|
+
if (existing?.slug !== slug) return null;
|
|
8921
|
+
const why = `'${slug}' is already this repo's PRIMARY binding \u2014 a named slot must be a different tenant (one tenant in two slots would carry two labels, and whether the push promotion gate fires would depend on which slot you selected).`;
|
|
8922
|
+
if (as === "dev") return `${why} For a disposable dev tenant beside it, run \`vxil dev up\`.`;
|
|
8923
|
+
const label = isNonProductionLabel(as) ? as : "staging";
|
|
8924
|
+
return `${why} To change the primary's label instead, re-link it: \`vxil link ${slug} --env ${label}\` (it reuses the stored key and keeps every other slot).`;
|
|
8925
|
+
}
|
|
8926
|
+
function isPlainObject(v) {
|
|
8927
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
8928
|
+
}
|
|
8929
|
+
function leafAt(manifest, dotted) {
|
|
8930
|
+
let cur = manifest;
|
|
8931
|
+
for (const seg of dotted.split(".")) {
|
|
8932
|
+
if (!isPlainObject(cur)) return void 0;
|
|
8933
|
+
cur = cur[seg];
|
|
8934
|
+
}
|
|
8935
|
+
return cur;
|
|
8936
|
+
}
|
|
8937
|
+
function mockFeatures(cfg) {
|
|
8938
|
+
return Object.entries(cfg.features ?? {}).filter(([, conf]) => {
|
|
8939
|
+
if (!isPlainObject(conf)) return false;
|
|
8940
|
+
const c = conf;
|
|
8941
|
+
return c.provider === "mock" || c.defaultProvider === "mock" || (isPlainObject(c.embed) ? c.embed.provider === "mock" : false);
|
|
8942
|
+
}).map(([f]) => f);
|
|
8943
|
+
}
|
|
8944
|
+
var SANDBOX_LEAVES = [
|
|
8945
|
+
{ feature: "payments", path: "paddle.sandbox" },
|
|
8946
|
+
{ feature: "payments", path: "paypal.sandbox" },
|
|
8947
|
+
{ feature: "payments", path: "revenuecat.acceptSandbox" }
|
|
8948
|
+
];
|
|
8949
|
+
function sandboxFeatures(cfg) {
|
|
8950
|
+
const out = [];
|
|
8951
|
+
for (const leaf of SANDBOX_LEAVES) {
|
|
8952
|
+
const manifest = (cfg.features ?? {})[leaf.feature];
|
|
8953
|
+
if (leafAt(manifest, leaf.path) === true) out.push(`${leaf.feature}.${leaf.path}`);
|
|
8954
|
+
}
|
|
8955
|
+
return out;
|
|
8956
|
+
}
|
|
8957
|
+
function isDevHost(host) {
|
|
8958
|
+
const h = host.toLowerCase().replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
|
8959
|
+
if (h === "localhost" || h.endsWith(".localhost")) return true;
|
|
8960
|
+
if (h === "127.0.0.1" || h.startsWith("127.")) return true;
|
|
8961
|
+
if (h === "::1") return true;
|
|
8962
|
+
if (h === "local" || h.endsWith(".local")) return true;
|
|
8963
|
+
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
|
|
8964
|
+
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
|
|
8965
|
+
if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
|
|
8966
|
+
return false;
|
|
8967
|
+
}
|
|
8968
|
+
function devRedirectOrigins(origins = []) {
|
|
8969
|
+
const out = [];
|
|
8970
|
+
for (const raw of origins) {
|
|
8971
|
+
if (typeof raw !== "string" || !raw) continue;
|
|
8972
|
+
if (/^http:\/\//i.test(raw)) {
|
|
8973
|
+
out.push(raw);
|
|
8974
|
+
continue;
|
|
8975
|
+
}
|
|
8976
|
+
const host = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/[/?#].*$/, "");
|
|
8977
|
+
if (host && isDevHost(host)) out.push(raw);
|
|
8978
|
+
}
|
|
8979
|
+
return out;
|
|
8980
|
+
}
|
|
8981
|
+
function redirectOrigins(cfg) {
|
|
8982
|
+
const v = leafAt((cfg.features ?? {}).auth, "security.allowedRedirectOrigins");
|
|
8983
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
8984
|
+
}
|
|
8985
|
+
var PROMOTION_OVERRIDE_FLAGS = {
|
|
8986
|
+
mock: "allow-mock-in-prod",
|
|
8987
|
+
sandbox: "allow-sandbox-in-prod",
|
|
8988
|
+
origins: "allow-dev-origins-in-prod"
|
|
8989
|
+
};
|
|
8990
|
+
function promotionRefusals(cfg, allowed) {
|
|
8991
|
+
const out = [];
|
|
8992
|
+
const mocks = mockFeatures(cfg);
|
|
8993
|
+
if (mocks.length && !allowed(PROMOTION_OVERRIDE_FLAGS.mock)) {
|
|
8994
|
+
out.push({
|
|
8995
|
+
kind: "mock",
|
|
8996
|
+
flag: PROMOTION_OVERRIDE_FLAGS.mock,
|
|
8997
|
+
message: `mock providers on [${mocks.join(", ")}] would ship to production (mock email/payments silently no-op). Set a real provider, or override with --${PROMOTION_OVERRIDE_FLAGS.mock}.`
|
|
8998
|
+
});
|
|
8999
|
+
}
|
|
9000
|
+
const sandboxes = sandboxFeatures(cfg);
|
|
9001
|
+
if (sandboxes.length && !allowed(PROMOTION_OVERRIDE_FLAGS.sandbox)) {
|
|
9002
|
+
out.push({
|
|
9003
|
+
kind: "sandbox",
|
|
9004
|
+
flag: PROMOTION_OVERRIDE_FLAGS.sandbox,
|
|
9005
|
+
message: `sandbox provider flags [${sandboxes.join(", ")}] would ship to a live money path (a sandbox key signs with the wrong secret; RevenueCat folds test purchases as outcome rejected_environment). Set them false, or override with --${PROMOTION_OVERRIDE_FLAGS.sandbox}.`
|
|
9006
|
+
});
|
|
9007
|
+
}
|
|
9008
|
+
const devOrigins = devRedirectOrigins(redirectOrigins(cfg));
|
|
9009
|
+
if (devOrigins.length && !allowed(PROMOTION_OVERRIDE_FLAGS.origins)) {
|
|
9010
|
+
out.push({
|
|
9011
|
+
kind: "origins",
|
|
9012
|
+
flag: PROMOTION_OVERRIDE_FLAGS.origins,
|
|
9013
|
+
message: `auth.security.allowedRedirectOrigins contains non-production origin(s) [${devOrigins.join(", ")}] \u2014 a loopback / .local / http:// redirect target on a production auth tenant is a token-exfiltration footgun. Remove them, or override with --${PROMOTION_OVERRIDE_FLAGS.origins}.`
|
|
9014
|
+
});
|
|
9015
|
+
}
|
|
9016
|
+
return out;
|
|
9017
|
+
}
|
|
9018
|
+
function redirectOriginNotice(cfg) {
|
|
9019
|
+
const origins = redirectOrigins(cfg);
|
|
9020
|
+
if (!origins.length) return null;
|
|
9021
|
+
return `auth.security.allowedRedirectOrigins (${origins.length}): ${origins.join(", ")}`;
|
|
9022
|
+
}
|
|
9023
|
+
|
|
9024
|
+
// src/link.ts
|
|
9025
|
+
function data(r) {
|
|
9026
|
+
return r.json.data ?? r.json;
|
|
9027
|
+
}
|
|
9028
|
+
function errText(r) {
|
|
9029
|
+
const e = r.json.error;
|
|
9030
|
+
if (!e) return `${r.status}`;
|
|
9031
|
+
return `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}${e.hint ? `
|
|
9032
|
+
hint: ${e.hint}` : ""}`;
|
|
9033
|
+
}
|
|
9034
|
+
async function findTenantViaSession(dash, cookie, slug) {
|
|
9035
|
+
const list = await dash("GET", "/dashboard/tenants", void 0, cookie);
|
|
9036
|
+
if (list.status === 401) return { unauthorized: true };
|
|
9037
|
+
if (list.status !== 200) throw new Error(`could not list projects (${errText(list)})`);
|
|
9038
|
+
const tenants = data(list).tenants ?? [];
|
|
9039
|
+
const t = tenants.find((x) => x.slug === slug);
|
|
9040
|
+
if (!t) {
|
|
9041
|
+
throw new Error(
|
|
9042
|
+
`no project '${slug}' on this account \u2014 run \`vxil quickstart\`, or ask an admin to add you as a member`
|
|
9043
|
+
);
|
|
9044
|
+
}
|
|
9045
|
+
return { tenant: t };
|
|
9046
|
+
}
|
|
9047
|
+
async function linkViaSession(dash, cookie, slug, keyName) {
|
|
9048
|
+
const found = await findTenantViaSession(dash, cookie, slug);
|
|
9049
|
+
if ("unauthorized" in found) return found;
|
|
9050
|
+
const t = found.tenant;
|
|
9051
|
+
const mint = await dash("POST", `/dashboard/tenants/${t.id}/api-keys`, { name: keyName }, cookie);
|
|
9052
|
+
if (mint.status === 401) return { unauthorized: true };
|
|
9053
|
+
if (mint.status !== 201 && mint.status !== 200) {
|
|
9054
|
+
throw new Error(`could not mint a key for '${slug}' (${errText(mint)})`);
|
|
9055
|
+
}
|
|
9056
|
+
const apiKey = data(mint).api_key;
|
|
9057
|
+
if (!apiKey) throw new Error("mint returned no api_key");
|
|
9058
|
+
return { result: { slug, tenant_id: t.id, api_key: apiKey } };
|
|
9059
|
+
}
|
|
9060
|
+
async function linkViaDeviceFlow(dash, slug, io) {
|
|
9061
|
+
const now = io.now ?? Date.now;
|
|
9062
|
+
const start = await dash("POST", "/dashboard/cli/link", { slug });
|
|
9063
|
+
if (start.status !== 201 && start.status !== 200) {
|
|
9064
|
+
throw new Error(`could not start the link (${errText(start)})`);
|
|
9065
|
+
}
|
|
9066
|
+
const d = data(start);
|
|
9067
|
+
if (!d.device_code || !d.user_code) throw new Error("link start returned no device_code");
|
|
9068
|
+
io.log("");
|
|
9069
|
+
io.log(`To link '${slug}', approve this request in your browser:`);
|
|
9070
|
+
io.log("");
|
|
9071
|
+
io.log(` ${d.verification_uri_complete ?? d.verification_uri ?? "(open the dashboard \u2192 CLI link)"}`);
|
|
9072
|
+
io.log(` code: ${d.user_code}`);
|
|
9073
|
+
io.log("");
|
|
9074
|
+
io.log("Waiting for approval (Ctrl-C to cancel)\u2026");
|
|
9075
|
+
const intervalMs = Math.max(1, d.interval ?? 5) * 1e3;
|
|
9076
|
+
const deadline = now() + Math.max(1, d.expires_in ?? 900) * 1e3;
|
|
9077
|
+
while (now() < deadline) {
|
|
9078
|
+
await io.sleep(intervalMs);
|
|
9079
|
+
const poll = await dash("POST", "/dashboard/cli/link/token", { device_code: d.device_code });
|
|
9080
|
+
if (poll.status === 428) continue;
|
|
9081
|
+
if (poll.status === 200) {
|
|
9082
|
+
const p = data(poll);
|
|
9083
|
+
if (!p.api_key || !p.tenant_id) throw new Error("approval returned no api_key");
|
|
9084
|
+
return { slug: p.slug || slug, tenant_id: p.tenant_id, api_key: p.api_key };
|
|
9085
|
+
}
|
|
9086
|
+
throw new Error(`link failed (${errText(poll)})`);
|
|
9087
|
+
}
|
|
9088
|
+
throw new Error("link timed out waiting for browser approval \u2014 run `vxil link <slug>` again");
|
|
9089
|
+
}
|
|
9090
|
+
function reconcileLinkTenant(flagTenant, verified) {
|
|
9091
|
+
if (verified === "(unknown)") return { tenantId: flagTenant ?? "(unknown)" };
|
|
9092
|
+
if (flagTenant !== void 0 && flagTenant !== verified) {
|
|
9093
|
+
return {
|
|
9094
|
+
error: `--tenant ${flagTenant} does not match the key's own tenant ${verified} (GET /v1/features meta.tenant_id) \u2014 nothing was linked. Check which key you passed, or drop --tenant to record the key's tenant.`
|
|
9095
|
+
};
|
|
9096
|
+
}
|
|
9097
|
+
return { tenantId: verified };
|
|
9098
|
+
}
|
|
9099
|
+
function knownTenant(id) {
|
|
9100
|
+
return typeof id === "string" && id.length > 0 && id !== "(unknown)";
|
|
9101
|
+
}
|
|
9102
|
+
function everySlot(p) {
|
|
9103
|
+
const out = [{ id: "primary", where: "the primary binding", slug: p.slug, tenant_id: p.tenant_id }];
|
|
9104
|
+
if (p.dev) out.push({ id: "dev", where: "the dev slot", slug: p.dev.slug, tenant_id: p.dev.tenant_id });
|
|
9105
|
+
for (const [n, s] of Object.entries(p.targets ?? {})) out.push({ id: `target:${n}`, where: `target '${n}'`, slug: s.slug, tenant_id: s.tenant_id });
|
|
9106
|
+
for (const [n, s] of Object.entries(p.branches ?? {})) out.push({ id: `branch:${n}`, where: `branch '${n}'`, slug: s.slug, tenant_id: s.tenant_id });
|
|
9107
|
+
return out;
|
|
9108
|
+
}
|
|
9109
|
+
function planLinkBinding(existing, req) {
|
|
9110
|
+
const { slug, tenantId, edgeUrl, env, as } = req;
|
|
9111
|
+
if (as !== void 0 && !TARGET_NAME_RE.test(as)) {
|
|
9112
|
+
return { error: `--as '${as}': a target name is 1\u201340 chars of [a-z0-9-], starting with a letter` };
|
|
9113
|
+
}
|
|
9114
|
+
const rewritten = as === void 0 ? "primary" : as === "dev" ? "dev" : `target:${as}`;
|
|
9115
|
+
if (existing && knownTenant(tenantId)) {
|
|
9116
|
+
const clash = everySlot(existing).find((s) => s.id !== rewritten && s.slug === slug && knownTenant(s.tenant_id) && s.tenant_id !== tenantId);
|
|
9117
|
+
if (clash) {
|
|
9118
|
+
return {
|
|
9119
|
+
error: `'${slug}' already names tenant ${clash.tenant_id} in this repo (${clash.where}), and this key belongs to tenant ${tenantId}. API keys are stored per slug (~/.vxil/credentials.json), so storing this one would silently point ${clash.where} at the other tenant under its old label \u2014 nothing was linked. Bind the other tenant under its own slug: \`vxil link <its-slug> --key <key>${as !== void 0 ? ` --as ${as}` : ""}\`.`
|
|
9120
|
+
};
|
|
9121
|
+
}
|
|
9122
|
+
}
|
|
9123
|
+
if (as !== void 0) {
|
|
9124
|
+
const conflict = namedSlotConflict(existing, slug, as);
|
|
9125
|
+
if (conflict) return { error: conflict };
|
|
9126
|
+
try {
|
|
9127
|
+
const binding2 = bindNamedSlot(existing, as, { tenant_id: tenantId, slug, edge_url: edgeUrl, env });
|
|
9128
|
+
return { binding: binding2, label: as === "dev" ? "as the dev slot (--dev)" : `as target '${as}' (--target ${as})` };
|
|
9129
|
+
} catch (e) {
|
|
9130
|
+
return { error: e.message };
|
|
9131
|
+
}
|
|
9132
|
+
}
|
|
9133
|
+
const binding = mergePrimaryBinding(existing, { tenant_id: tenantId, slug, edge_url: edgeUrl, env });
|
|
9134
|
+
const kept = bindingSlots(binding).slice(1).map((s) => s.label);
|
|
9135
|
+
return { binding, label: kept.length ? `as the primary binding (kept: ${kept.join(", ")})` : "as the primary binding" };
|
|
9136
|
+
}
|
|
9137
|
+
async function linkViaStoredKey(api, slug, storedKey) {
|
|
9138
|
+
let res;
|
|
9139
|
+
try {
|
|
9140
|
+
res = await api("GET", "/v1/features");
|
|
9141
|
+
} catch (e2) {
|
|
9142
|
+
return { invalid: `could not reach the edge (${e2.message})` };
|
|
9143
|
+
}
|
|
9144
|
+
if (res.status === 200) {
|
|
9145
|
+
const tenantId = res.body.meta?.tenant_id;
|
|
9146
|
+
return { result: { slug, tenant_id: tenantId && tenantId.length ? tenantId : "(unknown)", api_key: storedKey } };
|
|
9147
|
+
}
|
|
9148
|
+
const e = res.body.error;
|
|
9149
|
+
return { invalid: `${e?.code ?? res.status}${e?.message ? ` \u2014 ${e.message}` : ""}` };
|
|
9150
|
+
}
|
|
9151
|
+
|
|
9152
|
+
// src/members.ts
|
|
9153
|
+
var MEMBER_ROLES = ["viewer", "developer", "admin", "owner"];
|
|
9154
|
+
function parseMemberRole(raw) {
|
|
9155
|
+
if (raw === void 0 || !raw.trim()) return { error: `--role is required \u2014 one of ${MEMBER_ROLES.join("|")}` };
|
|
9156
|
+
const r = raw.trim();
|
|
9157
|
+
if (!MEMBER_ROLES.includes(r)) {
|
|
9158
|
+
return { error: `--role must be one of ${MEMBER_ROLES.join("|")} (got '${r}')` };
|
|
9159
|
+
}
|
|
9160
|
+
return { role: r };
|
|
9161
|
+
}
|
|
9162
|
+
function parseMemberEmail(raw) {
|
|
9163
|
+
const e = (raw ?? "").trim();
|
|
9164
|
+
if (!e || !e.includes("@")) return { error: "an e-mail address is required, e.g. vxil members invite ada@example.com --role developer" };
|
|
9165
|
+
return { email: e };
|
|
9166
|
+
}
|
|
9167
|
+
function parseInviteToken(raw) {
|
|
9168
|
+
const s = (raw ?? "").trim();
|
|
9169
|
+
if (!s) return { error: "usage: vxil invites accept <token | invite-url> [--json] \u2014 the token from the invitation e-mail" };
|
|
9170
|
+
if (/^https?:\/\//i.test(s)) {
|
|
9171
|
+
try {
|
|
9172
|
+
const t = new URL(s).searchParams.get("token");
|
|
9173
|
+
if (t && t.trim()) return { token: t.trim() };
|
|
9174
|
+
} catch {
|
|
9175
|
+
}
|
|
9176
|
+
return { error: "that URL carries no ?token= \u2014 paste the invitation link from the e-mail, or the token itself" };
|
|
9177
|
+
}
|
|
9178
|
+
return { token: s };
|
|
9179
|
+
}
|
|
9180
|
+
async function listMembersViaSession(dash, cookie, tenantId) {
|
|
9181
|
+
const m = await dash("GET", `/dashboard/tenants/${tenantId}/members`, void 0, cookie);
|
|
9182
|
+
if (m.status === 401) return { unauthorized: true };
|
|
9183
|
+
if (m.status !== 200) throw new Error(`could not list the members (${errText(m)})`);
|
|
9184
|
+
const p = await dash("GET", `/dashboard/tenants/${tenantId}/members/pending`, void 0, cookie);
|
|
9185
|
+
if (p.status === 401) return { unauthorized: true };
|
|
9186
|
+
const invites = p.status === 200 ? data(p).invites ?? [] : [];
|
|
9187
|
+
return { members: data(m).members ?? [], invites };
|
|
9188
|
+
}
|
|
9189
|
+
function inviteExpired(row2, now = Date.now()) {
|
|
9190
|
+
const t = Date.parse(row2.expires_at);
|
|
9191
|
+
return Number.isFinite(t) && t <= now;
|
|
9192
|
+
}
|
|
9193
|
+
function renderMembers(members, invites, now = Date.now()) {
|
|
9194
|
+
const lines = [];
|
|
9195
|
+
if (!members.length) lines.push("(no members)");
|
|
9196
|
+
for (const m of members) {
|
|
9197
|
+
lines.push(`${m.role.padEnd(9)} ${m.email}${m.display_name ? ` (${m.display_name})` : ""} ${m.user_id}`);
|
|
9198
|
+
}
|
|
9199
|
+
if (invites.length) {
|
|
9200
|
+
lines.push("");
|
|
9201
|
+
lines.push(`pending invitations (${invites.length}):`);
|
|
9202
|
+
for (const i of invites) {
|
|
9203
|
+
const state = inviteExpired(i, now) ? "EXPIRED" : `expires ${i.expires_at}`;
|
|
9204
|
+
lines.push(` ${i.role.padEnd(9)} ${i.email} ${i.id} ${state}`);
|
|
9205
|
+
}
|
|
9206
|
+
}
|
|
9207
|
+
return lines;
|
|
9208
|
+
}
|
|
9209
|
+
async function inviteMemberViaSession(dash, cookie, tenantId, req) {
|
|
9210
|
+
const r = await dash("POST", `/dashboard/tenants/${tenantId}/members/invite`, { email: req.email, role: req.role }, cookie);
|
|
9211
|
+
if (r.status === 401) return { unauthorized: true };
|
|
9212
|
+
if (r.status !== 202 && r.status !== 200 && r.status !== 201) {
|
|
9213
|
+
throw new Error(`could not invite ${req.email} (${errText(r)})`);
|
|
9214
|
+
}
|
|
9215
|
+
const d = data(r);
|
|
9216
|
+
return { email: d.email ?? req.email, role: d.role ?? req.role };
|
|
9217
|
+
}
|
|
9218
|
+
async function addMemberViaSession(dash, cookie, tenantId, req) {
|
|
9219
|
+
const r = await dash("POST", `/dashboard/tenants/${tenantId}/members`, { email: req.email, role: req.role }, cookie);
|
|
9220
|
+
if (r.status === 401) return { unauthorized: true };
|
|
9221
|
+
if (r.status !== 201 && r.status !== 200) throw new Error(`could not add ${req.email} (${errText(r)})`);
|
|
9222
|
+
const d = data(r);
|
|
9223
|
+
if (!d.user_id) throw new Error("the add returned no user_id");
|
|
9224
|
+
return { user_id: d.user_id, role: d.role ?? req.role };
|
|
9225
|
+
}
|
|
9226
|
+
function findMember(members, ref) {
|
|
9227
|
+
const r = ref.trim();
|
|
9228
|
+
const byId = members.find((m) => m.user_id === r);
|
|
9229
|
+
if (byId) return byId;
|
|
9230
|
+
const lower = r.toLowerCase();
|
|
9231
|
+
return members.find((m) => m.email.toLowerCase() === lower) ?? null;
|
|
9232
|
+
}
|
|
9233
|
+
async function removeMemberViaSession(dash, cookie, tenantId, userId) {
|
|
9234
|
+
const r = await dash("DELETE", `/dashboard/tenants/${tenantId}/members/${encodeURIComponent(userId)}`, void 0, cookie);
|
|
9235
|
+
if (r.status === 401) return { unauthorized: true };
|
|
9236
|
+
if (r.status !== 200 && r.status !== 204) throw new Error(`could not remove the member (${errText(r)})`);
|
|
9237
|
+
return { removed: userId };
|
|
9238
|
+
}
|
|
9239
|
+
function findPendingInvite(invites, ref) {
|
|
9240
|
+
const r = ref.trim();
|
|
9241
|
+
const byId = invites.find((i) => i.id === r);
|
|
9242
|
+
if (byId) return byId;
|
|
9243
|
+
const lower = r.toLowerCase();
|
|
9244
|
+
return invites.find((i) => i.email.toLowerCase() === lower) ?? null;
|
|
9245
|
+
}
|
|
9246
|
+
async function revokeInviteViaSession(dash, cookie, tenantId, inviteId) {
|
|
9247
|
+
const r = await dash("DELETE", `/dashboard/tenants/${tenantId}/members/pending/${encodeURIComponent(inviteId)}`, void 0, cookie);
|
|
9248
|
+
if (r.status === 401) return { unauthorized: true };
|
|
9249
|
+
if (r.status !== 200) throw new Error(`could not revoke the invitation (${errText(r)})`);
|
|
9250
|
+
const d = data(r);
|
|
9251
|
+
return { revoked: d.revoked ?? inviteId };
|
|
9252
|
+
}
|
|
9253
|
+
async function lookupInvite(dash, token) {
|
|
9254
|
+
const r = await dash("GET", `/dashboard/invites/pending?token=${encodeURIComponent(token)}`);
|
|
8641
9255
|
if (r.status !== 200) return { refused: errText(r) };
|
|
8642
9256
|
const d = data(r);
|
|
8643
9257
|
return {
|
|
@@ -9094,24 +9708,269 @@ function parseUsersFile(text, filename) {
|
|
|
9094
9708
|
const name = nameCol >= 0 ? cells[nameCol] : void 0;
|
|
9095
9709
|
users.push({ id, ...email ? { email } : {}, ...name ? { display_name: name } : {} });
|
|
9096
9710
|
}
|
|
9097
|
-
return { users };
|
|
9711
|
+
return { users };
|
|
9712
|
+
}
|
|
9713
|
+
return { error: `${filename}: expected a .json or .csv file` };
|
|
9714
|
+
}
|
|
9715
|
+
var USERS_IMPORT_CHUNK = 100;
|
|
9716
|
+
async function importUsers(api, users, opts = {}) {
|
|
9717
|
+
const size2 = Math.max(1, Math.min(USERS_IMPORT_CHUNK, opts.chunk ?? USERS_IMPORT_CHUNK));
|
|
9718
|
+
let upserted = 0;
|
|
9719
|
+
let chunks = 0;
|
|
9720
|
+
for (let i = 0; i < users.length; i += size2) {
|
|
9721
|
+
const slice = users.slice(i, i + size2);
|
|
9722
|
+
const r = await api("POST", "/v1/users/bulk", { users: slice });
|
|
9723
|
+
if (!ok2xx(r)) throw new Error(`users import failed at user ${i + 1} of ${users.length} (${apiErrText(r)}) \u2014 ${upserted} already upserted; the call is an upsert, so re-running is safe`);
|
|
9724
|
+
upserted += dataOf(r).upserted ?? slice.length;
|
|
9725
|
+
chunks++;
|
|
9726
|
+
opts.onProgress?.(Math.min(i + size2, users.length), users.length);
|
|
9727
|
+
}
|
|
9728
|
+
return { upserted, chunks };
|
|
9729
|
+
}
|
|
9730
|
+
|
|
9731
|
+
// src/fnLanes.ts
|
|
9732
|
+
var LOCAL_LANES = ["http", "webhook", "cms-hook", "auth-hook", "queue", "cron"];
|
|
9733
|
+
function parseLane(raw) {
|
|
9734
|
+
const v = raw === "cmsHook" ? "cms-hook" : raw === "authHook" ? "auth-hook" : raw;
|
|
9735
|
+
return LOCAL_LANES.includes(v) ? v : null;
|
|
9736
|
+
}
|
|
9737
|
+
function resolveTriggerKind(inner, bindings) {
|
|
9738
|
+
const declared = bindings ?? [];
|
|
9739
|
+
const has = (k) => declared.some((b3) => b3.kind === k);
|
|
9740
|
+
const first = (k) => declared.find((b3) => b3.kind === k);
|
|
9741
|
+
const isFanout = typeof inner.webhook_event === "string" && inner.trigger === void 0;
|
|
9742
|
+
if (isFanout && has("webhook")) {
|
|
9743
|
+
const event = inner.webhook_event;
|
|
9744
|
+
const webhooks = declared.filter((b3) => b3.kind === "webhook");
|
|
9745
|
+
const matched = webhooks.find((b3) => typeof b3.source !== "string" || !b3.source || event.startsWith(b3.source));
|
|
9746
|
+
if (matched) return { ok: true, trigger: "webhook", binding: matched };
|
|
9747
|
+
const fallback = inner.trigger === "queue" ? "queue" : "cron";
|
|
9748
|
+
const fb = first(fallback);
|
|
9749
|
+
if (fb) return { ok: true, trigger: fallback, binding: fb };
|
|
9750
|
+
return { ok: false, skipped: "webhook_event does not match binding source" };
|
|
9751
|
+
}
|
|
9752
|
+
const trigger = inner.trigger === "queue" ? "queue" : "cron";
|
|
9753
|
+
const b2 = first(trigger);
|
|
9754
|
+
if (!b2) return { ok: false, skipped: `no '${trigger}' binding declared` };
|
|
9755
|
+
return { ok: true, trigger, binding: b2 };
|
|
9756
|
+
}
|
|
9757
|
+
var CMS_HOOK_EVENTS_FOR = {
|
|
9758
|
+
beforeCreate: ["cms.item.created"],
|
|
9759
|
+
beforeUpdate: ["cms.item.updated"],
|
|
9760
|
+
beforeWrite: ["cms.item.created", "cms.item.updated"]
|
|
9761
|
+
};
|
|
9762
|
+
function cmsHookBindingMatches(bindings, event, collection) {
|
|
9763
|
+
const hooks = (bindings ?? []).filter((b2) => b2.kind === "cmsHook" || b2.kind === "cms-hook");
|
|
9764
|
+
if (hooks.length === 0) return { ok: true, passthrough: "no cmsHook binding (legacy hand-wired)" };
|
|
9765
|
+
if (hooks.some((b2) => typeof b2.collection !== "string" || !b2.collection)) {
|
|
9766
|
+
return { ok: true, passthrough: "legacy binding without collection", binding: hooks[0] };
|
|
9767
|
+
}
|
|
9768
|
+
if (typeof collection !== "string" || typeof event !== "string") {
|
|
9769
|
+
return { ok: true, passthrough: "delivery lacks collection/event", binding: hooks[0] };
|
|
9770
|
+
}
|
|
9771
|
+
const admitted = hooks.find((b2) => b2.collection === collection && (CMS_HOOK_EVENTS_FOR[b2.event ?? "beforeWrite"] ?? CMS_HOOK_EVENTS_FOR.beforeWrite).includes(event));
|
|
9772
|
+
return admitted ? { ok: true, binding: admitted } : { ok: false, skipped: "binding filter" };
|
|
9773
|
+
}
|
|
9774
|
+
function authHookBindingFor(bindings, event) {
|
|
9775
|
+
if (typeof event !== "string") return void 0;
|
|
9776
|
+
return (bindings ?? []).find((b2) => b2.kind === "authHook" && `auth.${b2.event ?? "user.created"}` === event);
|
|
9777
|
+
}
|
|
9778
|
+
function retryFor(binding, attempt) {
|
|
9779
|
+
const n = binding?.retry?.maxAttempts;
|
|
9780
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1) return void 0;
|
|
9781
|
+
return { maxAttempts: Math.min(n, FN_RETRY_MAX_ATTEMPTS), attempt };
|
|
9782
|
+
}
|
|
9783
|
+
var WEBHOOK_PAYLOAD_MAX_BYTES = 65536;
|
|
9784
|
+
function boundedData(data2, maxBytes, onTruncate) {
|
|
9785
|
+
if (data2 === void 0 || data2 === null) return null;
|
|
9786
|
+
let text;
|
|
9787
|
+
try {
|
|
9788
|
+
text = JSON.stringify(data2) ?? "null";
|
|
9789
|
+
} catch {
|
|
9790
|
+
return { truncated: true };
|
|
9791
|
+
}
|
|
9792
|
+
const bytes = new TextEncoder().encode(text).length;
|
|
9793
|
+
if (bytes > maxBytes) {
|
|
9794
|
+
onTruncate?.(bytes);
|
|
9795
|
+
return { truncated: true };
|
|
9796
|
+
}
|
|
9797
|
+
return data2;
|
|
9798
|
+
}
|
|
9799
|
+
function webhookPayload(inner, maxBytes = WEBHOOK_PAYLOAD_MAX_BYTES) {
|
|
9800
|
+
return {
|
|
9801
|
+
event: inner.webhook_event,
|
|
9802
|
+
audit_id: inner.audit_id ?? null,
|
|
9803
|
+
occurred_at: inner.occurred_at ?? null,
|
|
9804
|
+
actor: inner.actor ?? null,
|
|
9805
|
+
surface: inner.surface ?? null,
|
|
9806
|
+
data: boundedData(inner.data, maxBytes)
|
|
9807
|
+
};
|
|
9808
|
+
}
|
|
9809
|
+
var LANE_IDEMPOTENCY_PREFIX = {
|
|
9810
|
+
webhook: "whook",
|
|
9811
|
+
queue: "fntrig",
|
|
9812
|
+
cron: "fntrig",
|
|
9813
|
+
"cms-hook": "cmshook",
|
|
9814
|
+
"auth-hook": "authhook"
|
|
9815
|
+
};
|
|
9816
|
+
function laneIdempotencyKey(lane, fnName, runId, callerKey) {
|
|
9817
|
+
return lane === "http" ? callerKey ?? runId : `${LANE_IDEMPOTENCY_PREFIX[lane]}:${fnName}:${runId}`;
|
|
9818
|
+
}
|
|
9819
|
+
function auditRowToInner(row2) {
|
|
9820
|
+
return {
|
|
9821
|
+
webhook_event: row2.event,
|
|
9822
|
+
audit_id: Number(row2.id),
|
|
9823
|
+
occurred_at: row2.created_at,
|
|
9824
|
+
actor: row2.actor,
|
|
9825
|
+
surface: row2.surface,
|
|
9826
|
+
data: row2.payload
|
|
9827
|
+
};
|
|
9828
|
+
}
|
|
9829
|
+
function isRecord2(v) {
|
|
9830
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
9831
|
+
}
|
|
9832
|
+
function isAuditRow(v) {
|
|
9833
|
+
return typeof v.event === "string" && "created_at" in v && "payload" in v && !("data" in v);
|
|
9834
|
+
}
|
|
9835
|
+
function parseEventFile(json2, laneFlag) {
|
|
9836
|
+
let flagLane;
|
|
9837
|
+
if (laneFlag !== void 0) {
|
|
9838
|
+
const l = parseLane(laneFlag);
|
|
9839
|
+
if (!l) return { error: `--trigger '${laneFlag}' is not a lane \u2014 use one of ${LOCAL_LANES.join(" | ")}` };
|
|
9840
|
+
flagLane = l;
|
|
9841
|
+
}
|
|
9842
|
+
if (isRecord2(json2) && typeof json2.trigger === "string" && "payload" in json2) {
|
|
9843
|
+
const l = parseLane(json2.trigger);
|
|
9844
|
+
if (!l) return { error: `the event file's trigger '${json2.trigger}' is not a lane \u2014 use one of ${LOCAL_LANES.join(" | ")}` };
|
|
9845
|
+
if (flagLane && flagLane !== l) return { error: `--trigger ${flagLane} disagrees with the event file's trigger '${l}'` };
|
|
9846
|
+
return { lane: l, input: json2.payload };
|
|
9847
|
+
}
|
|
9848
|
+
if (isRecord2(json2) && isAuditRow(json2)) {
|
|
9849
|
+
if (flagLane && flagLane !== "webhook") {
|
|
9850
|
+
return { error: `the event file is an audit row, which reaches a function only on the webhook lane (got --trigger ${flagLane})` };
|
|
9851
|
+
}
|
|
9852
|
+
return { lane: "webhook", input: auditRowToInner(json2) };
|
|
9853
|
+
}
|
|
9854
|
+
return { lane: flagLane ?? "http", input: json2 };
|
|
9855
|
+
}
|
|
9856
|
+
var kindOf = {
|
|
9857
|
+
webhook: "webhook",
|
|
9858
|
+
queue: "queue",
|
|
9859
|
+
cron: "cron",
|
|
9860
|
+
"cms-hook": "cmsHook",
|
|
9861
|
+
"auth-hook": "authHook"
|
|
9862
|
+
};
|
|
9863
|
+
function laneRefusal(lane, bindings, fnName) {
|
|
9864
|
+
if (lane === "http") return null;
|
|
9865
|
+
const kind = kindOf[lane];
|
|
9866
|
+
if (bindings.some((b2) => b2.kind === kind || lane === "cms-hook" && b2.kind === "cms-hook")) return null;
|
|
9867
|
+
const declared = bindings.map((b2) => b2.kind).filter(Boolean).join(", ") || "http";
|
|
9868
|
+
return `'${fnName}' declares no ${kind} binding (it has: ${declared}) \u2014 deployed, the platform never delivers a ${lane} invocation to it. Add \`trigger: { kind: '${kind}', \u2026 }\` (or a triggers[] entry) in vxil.config.ts, or pick a lane it declares.`;
|
|
9869
|
+
}
|
|
9870
|
+
function webhookInner(input) {
|
|
9871
|
+
if (!isRecord2(input)) return null;
|
|
9872
|
+
if (typeof input.webhook_event === "string") {
|
|
9873
|
+
const { trigger: _t, payload: _p, ...rest2 } = input;
|
|
9874
|
+
void _t;
|
|
9875
|
+
void _p;
|
|
9876
|
+
return rest2;
|
|
9877
|
+
}
|
|
9878
|
+
if (typeof input.event === "string") {
|
|
9879
|
+
return {
|
|
9880
|
+
webhook_event: input.event,
|
|
9881
|
+
audit_id: input.audit_id,
|
|
9882
|
+
occurred_at: input.occurred_at,
|
|
9883
|
+
actor: input.actor,
|
|
9884
|
+
surface: input.surface,
|
|
9885
|
+
data: input.data
|
|
9886
|
+
};
|
|
9887
|
+
}
|
|
9888
|
+
return null;
|
|
9889
|
+
}
|
|
9890
|
+
function planLocalDelivery(opts) {
|
|
9891
|
+
const { lane, input, bindings, fnName } = opts;
|
|
9892
|
+
if (lane === "http") return { ok: true, lane, payload: input };
|
|
9893
|
+
const refused = laneRefusal(lane, bindings, fnName);
|
|
9894
|
+
if (refused) return { ok: false, refusal: refused };
|
|
9895
|
+
if (lane === "queue" || lane === "cron") {
|
|
9896
|
+
const inner = { trigger: lane, ...input !== void 0 ? { payload: input } : {} };
|
|
9897
|
+
const r = resolveTriggerKind(inner, bindings);
|
|
9898
|
+
if (!r.ok) return { ok: false, refusal: `deployed, this ${lane} delivery is ACK-200 skipped (${r.skipped})` };
|
|
9899
|
+
return { ok: true, lane, payload: inner.payload ?? {}, ...r.binding ? { binding: r.binding } : {} };
|
|
9900
|
+
}
|
|
9901
|
+
if (lane === "webhook") {
|
|
9902
|
+
const inner = webhookInner(input);
|
|
9903
|
+
if (!inner) {
|
|
9904
|
+
return { ok: false, refusal: 'a webhook delivery needs the event name \u2014 `{ "event": "payments.charge.succeeded", "data": { \u2026 } }` (or a GET /v1/audit row)' };
|
|
9905
|
+
}
|
|
9906
|
+
const event2 = inner.webhook_event;
|
|
9907
|
+
const r = resolveTriggerKind(inner, bindings);
|
|
9908
|
+
if (!r.ok) {
|
|
9909
|
+
const sources = bindings.filter((b3) => b3.kind === "webhook").map((b3) => `'${b3.source ?? ""}'`).join(", ");
|
|
9910
|
+
return { ok: false, refusal: `deployed, '${event2}' is ACK-200 skipped: no webhook binding's source (${sources}) is a prefix of it, and there is no cron/queue binding to fall back to` };
|
|
9911
|
+
}
|
|
9912
|
+
if (r.trigger !== "webhook") {
|
|
9913
|
+
return {
|
|
9914
|
+
ok: false,
|
|
9915
|
+
refusal: `deployed, '${event2}' matches no webhook binding's source, so it falls through to the '${r.trigger}' binding and arrives as trigger '${r.trigger}' with payload {} \u2014 run --trigger ${r.trigger} to exercise that, or widen the binding's source`
|
|
9916
|
+
};
|
|
9917
|
+
}
|
|
9918
|
+
return { ok: true, lane, payload: webhookPayload(inner), ...r.binding ? { binding: r.binding } : {} };
|
|
9098
9919
|
}
|
|
9099
|
-
|
|
9920
|
+
if (lane === "cms-hook") {
|
|
9921
|
+
const rec2 = isRecord2(input) ? input : {};
|
|
9922
|
+
const data2 = isRecord2(rec2.data) ? rec2.data : {};
|
|
9923
|
+
const event2 = typeof rec2.webhook_event === "string" ? rec2.webhook_event : typeof rec2.event === "string" ? rec2.event : "cms.item.created";
|
|
9924
|
+
const collection = typeof rec2.collection === "string" ? rec2.collection : data2.collection;
|
|
9925
|
+
const itemId = typeof rec2.item_id === "string" ? rec2.item_id : data2.item_id;
|
|
9926
|
+
if (typeof collection !== "string" || typeof itemId !== "string") {
|
|
9927
|
+
return { ok: false, refusal: 'a cms-hook delivery needs `{ "event": "cms.item.created", "collection": "<slug>", "item_id": "<id>" }` (event defaults to cms.item.created)' };
|
|
9928
|
+
}
|
|
9929
|
+
const f = cmsHookBindingMatches(bindings, event2, collection);
|
|
9930
|
+
if (!f.ok) {
|
|
9931
|
+
const admits = bindings.filter((b3) => b3.kind === "cmsHook" || b3.kind === "cms-hook").map((b3) => `${b3.collection}: ${(CMS_HOOK_EVENTS_FOR[b3.event ?? "beforeWrite"] ?? []).join("|")}`).join("; ");
|
|
9932
|
+
return { ok: false, refusal: `deployed, ${event2} on '${collection}' is ACK-200 skipped by the binding filter (the cmsHook bindings admit ${admits})` };
|
|
9933
|
+
}
|
|
9934
|
+
return {
|
|
9935
|
+
ok: true,
|
|
9936
|
+
lane,
|
|
9937
|
+
payload: { event: event2, collection, item_id: itemId },
|
|
9938
|
+
...f.binding ? { binding: f.binding } : {}
|
|
9939
|
+
};
|
|
9940
|
+
}
|
|
9941
|
+
const rec = isRecord2(input) ? input : {};
|
|
9942
|
+
const event = typeof rec.webhook_event === "string" ? rec.webhook_event : typeof rec.event === "string" ? rec.event : "auth.user.created";
|
|
9943
|
+
const fields = typeof rec.webhook_event === "string" ? isRecord2(rec.data) ? rec.data : {} : rec;
|
|
9944
|
+
const b2 = authHookBindingFor(bindings, event);
|
|
9945
|
+
if (!b2) {
|
|
9946
|
+
const subscribed = bindings.filter((x) => x.kind === "authHook").map((x) => `auth.${x.event ?? "user.created"}`).join(", ");
|
|
9947
|
+
return { ok: false, refusal: `no authHook binding subscribes to '${event}' (declared: ${subscribed}) \u2014 deployed, the platform wires one subscription per declared authHook event, so this delivery never happens` };
|
|
9948
|
+
}
|
|
9949
|
+
const { event: _e, ...rest2 } = fields;
|
|
9950
|
+
void _e;
|
|
9951
|
+
return { ok: true, lane, payload: { ...rest2, event }, binding: b2 };
|
|
9100
9952
|
}
|
|
9101
|
-
|
|
9102
|
-
|
|
9103
|
-
|
|
9104
|
-
let upserted = 0;
|
|
9105
|
-
let chunks = 0;
|
|
9106
|
-
for (let i = 0; i < users.length; i += size2) {
|
|
9107
|
-
const slice = users.slice(i, i + size2);
|
|
9108
|
-
const r = await api("POST", "/v1/users/bulk", { users: slice });
|
|
9109
|
-
if (!ok2xx(r)) throw new Error(`users import failed at user ${i + 1} of ${users.length} (${apiErrText(r)}) \u2014 ${upserted} already upserted; the call is an upsert, so re-running is safe`);
|
|
9110
|
-
upserted += dataOf(r).upserted ?? slice.length;
|
|
9111
|
-
chunks++;
|
|
9112
|
-
opts.onProgress?.(Math.min(i + size2, users.length), users.length);
|
|
9953
|
+
function deployedVerdict(lane, binding, timeoutMs) {
|
|
9954
|
+
if (lane === "http") {
|
|
9955
|
+
return ["deployed: the sync lane hands your caller this status and body as-is; the async lane records a non-2xx as a dead run"];
|
|
9113
9956
|
}
|
|
9114
|
-
|
|
9957
|
+
const out = [];
|
|
9958
|
+
if (lane === "cron") {
|
|
9959
|
+
out.push("deployed: ACKs 200 whatever this returns \u2014 a cron tick is never retried; the first failure after a clean run emits functions.run.failed");
|
|
9960
|
+
} else {
|
|
9961
|
+
const r = retryFor(binding, 1);
|
|
9962
|
+
out.push(r ? `deployed: retry on \u2014 a non-2xx is attempt 1 of ${r.maxAttempts} (never more than the project's jobs.retry.defaultMaxAttempts); every attempt carries the same idempotency_key` : "deployed: ACKs 200 whatever this returns \u2014 the binding declares no retry, so a non-2xx is not redelivered");
|
|
9963
|
+
}
|
|
9964
|
+
if ((lane === "webhook" || lane === "cms-hook" || lane === "auth-hook") && typeof timeoutMs === "number" && timeoutMs > DIRECT_FN_DECLARED_MAX_MS) {
|
|
9965
|
+
out.push(`limits.timeoutMs ${timeoutMs} > ${DIRECT_FN_DECLARED_MAX_MS}: deployed deliveries skip the direct first attempt and ride the queue with the full 60 s`);
|
|
9966
|
+
}
|
|
9967
|
+
return out;
|
|
9968
|
+
}
|
|
9969
|
+
function productionLaneGuard(o) {
|
|
9970
|
+
if (!o.production || o.explicitSelector) return null;
|
|
9971
|
+
if (o.lane === "http" && !o.oneShot) return null;
|
|
9972
|
+
const what = o.oneShot ? `this --event delivery (${o.lane})` : `every ${o.lane} delivery of the local server`;
|
|
9973
|
+
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>`;
|
|
9115
9974
|
}
|
|
9116
9975
|
|
|
9117
9976
|
// src/fnDev.ts
|
|
@@ -9121,11 +9980,12 @@ var MINTABLE_AUDS = new Set(Object.values(FUNCTION_SCOPE_AUDIENCE));
|
|
|
9121
9980
|
function buildEnvelope(opts) {
|
|
9122
9981
|
const scopedJwts = {};
|
|
9123
9982
|
for (const aud of functionCallbackAudiences(opts.scopes)) scopedJwts[aud] = `${PLACEHOLDER_PREFIX}${aud}`;
|
|
9983
|
+
const trigger = opts.trigger ?? "http";
|
|
9124
9984
|
return {
|
|
9125
|
-
trigger
|
|
9985
|
+
trigger,
|
|
9126
9986
|
tenant_id: opts.tenantId,
|
|
9127
9987
|
request_id: opts.requestId,
|
|
9128
|
-
idempotency_key: opts.
|
|
9988
|
+
idempotency_key: laneIdempotencyKey(trigger, opts.fnName, opts.runId ?? opts.requestId, opts.idempotencyKey),
|
|
9129
9989
|
vxil_base: DEV_VXIL_BASE,
|
|
9130
9990
|
scoped_jwts: scopedJwts,
|
|
9131
9991
|
secrets: opts.secrets ?? {},
|
|
@@ -9429,10 +10289,15 @@ function stripApiVersionsBlock(src) {
|
|
|
9429
10289
|
function canonicalCollections(collections) {
|
|
9430
10290
|
return [...collections].sort((a, b2) => compareCodePoints(a.collection, b2.collection)).map((c) => ({ ...c, fields: [...c.fields].sort((a, b2) => compareCodePoints(a.field, b2.field)) }));
|
|
9431
10291
|
}
|
|
10292
|
+
function liveFeatureKeys(cfg) {
|
|
10293
|
+
const keys = Object.entries(cfg.features ?? {}).filter(([k, v]) => k !== "functions" && !(v !== null && typeof v === "object" && v.enabled === false)).map(([k]) => k);
|
|
10294
|
+
if (Object.keys(cfg.functions ?? {}).length > 0) keys.push("functions");
|
|
10295
|
+
return keys;
|
|
10296
|
+
}
|
|
9432
10297
|
function genInputFromConfig(cfg) {
|
|
9433
10298
|
return {
|
|
9434
10299
|
...cfg.env ? { env: cfg.env } : {},
|
|
9435
|
-
features:
|
|
10300
|
+
features: liveFeatureKeys(cfg),
|
|
9436
10301
|
collections: Object.entries(cfg.cms?.collections ?? {}).map(([collection, def]) => ({
|
|
9437
10302
|
collection,
|
|
9438
10303
|
...def.singular ? { singular: def.singular } : {},
|
|
@@ -9974,15 +10839,15 @@ var TOOLS = [
|
|
|
9974
10839
|
{
|
|
9975
10840
|
name: "rate_limits_check",
|
|
9976
10841
|
feature: "rate-limits",
|
|
9977
|
-
description: "Consume budget against a policy. Returns allowed/remaining/reset_seconds (429 on block-behavior exceed).",
|
|
10842
|
+
description: "Consume budget against a policy, named by EXACTLY ONE of policy_id or policy (its name \u2014 unique per project; both or neither is 422). Returns allowed/remaining/reset_seconds (429 on block-behavior exceed). An unknown name is 404 not_found, like an unknown id; a policy created before 2026-09-23 is found by name only after it is saved or listed again.",
|
|
9978
10843
|
inputSchema: {
|
|
9979
10844
|
type: "object",
|
|
9980
10845
|
properties: {
|
|
9981
|
-
policy_id: { type: "string" },
|
|
10846
|
+
policy_id: { type: "string", description: "the policy id (rl_\u2026); omit when you pass policy" },
|
|
10847
|
+
policy: { type: "string", description: "the policy NAME; omit when you pass policy_id" },
|
|
9982
10848
|
key_values: { type: "object", additionalProperties: { type: "string" } },
|
|
9983
10849
|
cost: { type: "number", default: 1 }
|
|
9984
|
-
}
|
|
9985
|
-
required: ["policy_id"]
|
|
10850
|
+
}
|
|
9986
10851
|
},
|
|
9987
10852
|
method: "POST",
|
|
9988
10853
|
path: "/v1/rate-limits/check"
|
|
@@ -11192,436 +12057,190 @@ function buildMcpCatalog(input, tools = TOOLS) {
|
|
|
11192
12057
|
tools: catalogTools,
|
|
11193
12058
|
cms_collections: cmsCollections,
|
|
11194
12059
|
functions_note: "deployed tenant functions are invoked via REST (POST /v1/fn/<name>) \u2014 the MCP server exposes no function-invoke tools",
|
|
11195
|
-
functions: fns.map((f) => ({
|
|
11196
|
-
name: f.name,
|
|
11197
|
-
invoke: `POST /v1/fn/${f.name}`,
|
|
11198
|
-
// keys sorted at every depth: online reads the signature back in the
|
|
11199
|
-
// stored manifest's key order, offline in declaration order (F2)
|
|
11200
|
-
signature: f.signature ? sortKeysDeep(f.signature) : null
|
|
11201
|
-
}))
|
|
11202
|
-
};
|
|
11203
|
-
}
|
|
11204
|
-
var MCP_CHECK_IGNORED_KEYS = ["generated_at", "tenant", "env"];
|
|
11205
|
-
function mcpCatalogUpToDate(existing, catalog) {
|
|
11206
|
-
if (existing === null) return false;
|
|
11207
|
-
const strip = (o) => {
|
|
11208
|
-
for (const k of MCP_CHECK_IGNORED_KEYS) delete o[k];
|
|
11209
|
-
return canonicalJson(o);
|
|
11210
|
-
};
|
|
11211
|
-
try {
|
|
11212
|
-
const cur = JSON.parse(existing);
|
|
11213
|
-
if (!cur || typeof cur !== "object" || Array.isArray(cur)) return false;
|
|
11214
|
-
return strip(cur) === strip(JSON.parse(JSON.stringify(catalog)));
|
|
11215
|
-
} catch {
|
|
11216
|
-
return false;
|
|
11217
|
-
}
|
|
11218
|
-
}
|
|
11219
|
-
|
|
11220
|
-
// src/exportUrls.ts
|
|
11221
|
-
var EXPORT_429_RETRIES = 6;
|
|
11222
|
-
var EXPORT_429_MAX_WAIT_MS = 12e4;
|
|
11223
|
-
var EXPORT_URL_PACE_DEFAULT_S = 30;
|
|
11224
|
-
var EXPORT_URL_PACE_MAX_S = 600;
|
|
11225
|
-
var EXPORT_EXIT_URLS_EXPIRED = 3;
|
|
11226
|
-
var EXPORT_NET_RETRIES = 3;
|
|
11227
|
-
function retryAfterMs(retryAfter, attempt) {
|
|
11228
|
-
const ra = Number(retryAfter);
|
|
11229
|
-
if (Number.isFinite(ra) && ra > 0) return Math.min(ra * 1e3, EXPORT_429_MAX_WAIT_MS);
|
|
11230
|
-
return Math.min(2 ** attempt, 30) * 1e3;
|
|
11231
|
-
}
|
|
11232
|
-
function describeFetchError(e) {
|
|
11233
|
-
if (!(e instanceof Error)) return String(e);
|
|
11234
|
-
const cause = e.cause;
|
|
11235
|
-
if (!(cause instanceof Error)) return e.message;
|
|
11236
|
-
const code = cause.code;
|
|
11237
|
-
const detail = typeof code === "string" && code && !cause.message.includes(code) ? `${cause.message}, ${code}` : cause.message;
|
|
11238
|
-
return detail ? `${e.message} (${detail})` : e.message;
|
|
11239
|
-
}
|
|
11240
|
-
function incompleteExportMessage(a) {
|
|
11241
|
-
const lines = `${a.written} line${a.written === 1 ? "" : "s"}`;
|
|
11242
|
-
const where = a.out ? `${a.out.path} was ${a.out.existed ? "left untouched (it still holds what it held before this run)" : "not created"}; the ${lines} written before the failure are in ${a.out.partial}` : `the ${lines} already on stdout stop there, and nothing in the stream marks the cut \u2014 a reader must treat it as partial`;
|
|
11243
|
-
const resume = a.resume.length ? ` Resume by hand from ${a.resume.map((r) => `GET /v1/export/${a.feature}?format=ndjson&table=${encodeURIComponent(r.table)}&after=${encodeURIComponent(r.after)}${a.urls && r.table === "objects" ? "&include=urls" : ""}`).join(", then ")} (follow x-vxil-next-after and drop each page's header line), or re-run the export.` : " Re-run the export.";
|
|
11244
|
-
return `${a.reason} \u2014 the export is INCOMPLETE: ${where}.${resume}`;
|
|
11245
|
-
}
|
|
11246
|
-
function parsePace(raw) {
|
|
11247
|
-
if (raw === void 0) return { ms: EXPORT_URL_PACE_DEFAULT_S * 1e3 };
|
|
11248
|
-
const s = Number(raw);
|
|
11249
|
-
if (raw.trim() === "" || !Number.isFinite(s) || s < 0 || s > EXPORT_URL_PACE_MAX_S) {
|
|
11250
|
-
return { err: `--pace takes seconds between two download-URL pages, 0..${EXPORT_URL_PACE_MAX_S} (default ${EXPORT_URL_PACE_DEFAULT_S}; 0 = as fast as the rate limiter allows) \u2014 got '${raw}'` };
|
|
11251
|
-
}
|
|
11252
|
-
return { ms: Math.round(s * 1e3) };
|
|
11253
|
-
}
|
|
11254
|
-
function paceWaitMs(lastStartMs, nowMs, paceMs) {
|
|
11255
|
-
if (lastStartMs === null || paceMs <= 0) return 0;
|
|
11256
|
-
return Math.max(0, lastStartMs + paceMs - nowMs);
|
|
11257
|
-
}
|
|
11258
|
-
function ndjsonDownloads(lines) {
|
|
11259
|
-
const out = [];
|
|
11260
|
-
for (const l of lines) {
|
|
11261
|
-
if (!l.includes('"download"')) continue;
|
|
11262
|
-
try {
|
|
11263
|
-
const d = JSON.parse(l).download;
|
|
11264
|
-
if (d && typeof d === "object" && "url" in d) out.push(d);
|
|
11265
|
-
} catch {
|
|
11266
|
-
}
|
|
11267
|
-
}
|
|
11268
|
-
return out;
|
|
11269
|
-
}
|
|
11270
|
-
function newDownloadTally() {
|
|
11271
|
-
return { total: 0, missing: /* @__PURE__ */ new Map(), expiries: [] };
|
|
11272
|
-
}
|
|
11273
|
-
function tallyDownloads(t, entries) {
|
|
11274
|
-
for (const e of entries) {
|
|
11275
|
-
t.total += 1;
|
|
11276
|
-
if (e.url === null) {
|
|
11277
|
-
const r = e.reason ?? "unknown";
|
|
11278
|
-
t.missing.set(r, (t.missing.get(r) ?? 0) + 1);
|
|
11279
|
-
continue;
|
|
11280
|
-
}
|
|
11281
|
-
const at = typeof e.expires_at === "string" ? Date.parse(e.expires_at) : NaN;
|
|
11282
|
-
if (Number.isFinite(at)) t.expiries.push(at);
|
|
11283
|
-
}
|
|
11284
|
-
return t;
|
|
11285
|
-
}
|
|
11286
|
-
function missingSummary(t) {
|
|
11287
|
-
const missing = [...t.missing.values()].reduce((a, b2) => a + b2, 0);
|
|
11288
|
-
if (!missing) return null;
|
|
11289
|
-
const parts = [...t.missing.entries()].sort((a, b2) => b2[1] - a[1]).map(([r, n]) => `${r} \xD7${n}`);
|
|
11290
|
-
return `vxil: ${missing} of ${t.total} object${t.total === 1 ? "" : "s"} carry NO download URL (${parts.join(", ")}) \u2014 status:* rows have no confirmed bytes yet (or are deleted); insufficient_scope needs files:read on the key; re-run for network_error`;
|
|
11291
|
-
}
|
|
11292
|
-
function expiredReport(t, nowMs, startedMs, readLive) {
|
|
11293
|
-
let deadCount = 0;
|
|
11294
|
-
let oldestAt = Infinity;
|
|
11295
|
-
for (const at of t.expiries) {
|
|
11296
|
-
if (at > nowMs) continue;
|
|
11297
|
-
deadCount += 1;
|
|
11298
|
-
if (at < oldestAt) oldestAt = at;
|
|
11299
|
-
}
|
|
11300
|
-
if (!deadCount) return null;
|
|
11301
|
-
const minted = t.expiries.length;
|
|
11302
|
-
const ranMin = Math.max(1, Math.round((nowMs - startedMs) / 6e4));
|
|
11303
|
-
const oldest = new Date(oldestAt).toISOString();
|
|
11304
|
-
const remedy = "raise `features.files.downloadUrlTtl` (seconds, max 86400) above the export's run time (~0.3 s per object at the default --pace), read the NDJSON as it streams (pipe it to your fetcher), or mint per object at fetch time with GET /v1/files/{id}/download-url";
|
|
11305
|
-
if (readLive) {
|
|
11306
|
-
return {
|
|
11307
|
-
exitCode: 0,
|
|
11308
|
-
message: `vxil: ${deadCount} of ${minted} download URL${minted === 1 ? "" : "s"} had expired by the time the export finished (it ran ~${ranMin} min; the oldest died at ${oldest}) \u2014 fine if your reader fetched each page as it streamed; a reader that waits for the end gets 403 on them. To keep them alive: ${remedy}.`
|
|
11309
|
-
};
|
|
11310
|
-
}
|
|
11311
|
-
return {
|
|
11312
|
-
exitCode: EXPORT_EXIT_URLS_EXPIRED,
|
|
11313
|
-
message: `vxil: ${deadCount} of ${minted} download URL${minted === 1 ? "" : "s"} had ALREADY EXPIRED when the export finished (it ran ~${ranMin} min; the oldest died at ${oldest}) \u2014 a GET on them answers 403, so those objects' bytes are NOT one fetch away. The metadata is complete (exit ${EXPORT_EXIT_URLS_EXPIRED}, not 1). Fix: ${remedy}.`
|
|
11314
|
-
};
|
|
11315
|
-
}
|
|
11316
|
-
var OBJECTS_TRUNCATED = /^objects: truncated at /;
|
|
11317
|
-
function objectsCut(b2) {
|
|
11318
|
-
const tr = Array.isArray(b2.truncated) ? b2.truncated.find((t) => t.table === "objects") : void 0;
|
|
11319
|
-
if (tr) return typeof tr.after === "string" && tr.after ? { after: tr.after } : { unresumable: true };
|
|
11320
|
-
if (Array.isArray(b2.skipped) && b2.skipped.some((s) => OBJECTS_TRUNCATED.test(s))) return { unresumable: true };
|
|
11321
|
-
return null;
|
|
11322
|
-
}
|
|
11323
|
-
function mergeObjectsPage(b2, lines) {
|
|
11324
|
-
b2.tables ??= [];
|
|
11325
|
-
let objects = b2.tables.find((t) => t.name === "objects");
|
|
11326
|
-
if (!objects) {
|
|
11327
|
-
objects = { name: "objects", rows: [] };
|
|
11328
|
-
b2.tables.push(objects);
|
|
11329
|
-
}
|
|
11330
|
-
b2.downloads ??= [];
|
|
11331
|
-
let n = 0;
|
|
11332
|
-
for (const l of lines) {
|
|
11333
|
-
let parsed;
|
|
11334
|
-
try {
|
|
11335
|
-
parsed = JSON.parse(l);
|
|
11336
|
-
} catch {
|
|
11337
|
-
continue;
|
|
11338
|
-
}
|
|
11339
|
-
if (parsed.t !== "objects" || !parsed.r || typeof parsed.r !== "object") continue;
|
|
11340
|
-
objects.rows.push(parsed.r);
|
|
11341
|
-
n += 1;
|
|
11342
|
-
const d = parsed.download;
|
|
11343
|
-
if (d && typeof d === "object" && "url" in d) {
|
|
11344
|
-
b2.downloads.push({
|
|
11345
|
-
object_id: String(parsed.r.object_id ?? ""),
|
|
11346
|
-
url: d.url,
|
|
11347
|
-
expires_at: d.expires_at ?? null,
|
|
11348
|
-
...d.reason ? { reason: d.reason } : {}
|
|
11349
|
-
});
|
|
11350
|
-
}
|
|
11351
|
-
}
|
|
11352
|
-
return n;
|
|
11353
|
-
}
|
|
11354
|
-
function markObjectsComplete(b2) {
|
|
11355
|
-
if (Array.isArray(b2.skipped)) {
|
|
11356
|
-
b2.skipped = b2.skipped.filter((s) => !OBJECTS_TRUNCATED.test(s));
|
|
11357
|
-
if (!b2.skipped.length) delete b2.skipped;
|
|
11358
|
-
}
|
|
11359
|
-
if (Array.isArray(b2.truncated)) {
|
|
11360
|
-
b2.truncated = b2.truncated.filter((t) => t.table !== "objects");
|
|
11361
|
-
if (!b2.truncated.length) delete b2.truncated;
|
|
11362
|
-
}
|
|
11363
|
-
}
|
|
11364
|
-
function truncationNotes(b2) {
|
|
11365
|
-
return Array.isArray(b2.skipped) ? b2.skipped.filter((s) => /: truncated at \d+ rows/.test(s)) : [];
|
|
11366
|
-
}
|
|
11367
|
-
|
|
11368
|
-
// src/store.ts
|
|
11369
|
-
import { homedir as homedir2 } from "node:os";
|
|
11370
|
-
import { join as join2, resolve as resolve3 } from "node:path";
|
|
11371
|
-
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2, chmodSync } from "node:fs";
|
|
11372
|
-
function dashboardOrigin(base2) {
|
|
11373
|
-
try {
|
|
11374
|
-
return new URL(base2).origin.toLowerCase();
|
|
11375
|
-
} catch {
|
|
11376
|
-
return base2.trim().replace(/\/+$/, "").toLowerCase();
|
|
11377
|
-
}
|
|
11378
|
-
}
|
|
11379
|
-
function storedSessionFor(creds, dashBase) {
|
|
11380
|
-
if (!creds.session) return { session: null, reason: "no-session" };
|
|
11381
|
-
if (!creds.session_host) return { session: null, reason: "unbound" };
|
|
11382
|
-
const host = dashboardOrigin(creds.session_host);
|
|
11383
|
-
if (host !== dashboardOrigin(dashBase)) return { session: null, reason: "other-host", host };
|
|
11384
|
-
return { session: creds.session, host };
|
|
11385
|
-
}
|
|
11386
|
-
function sessionNotForTarget(s, target) {
|
|
11387
|
-
if (s.reason === "other-host") return `the stored dashboard session belongs to ${s.host}, not this target (${dashboardOrigin(target)}) \u2014 run \`vxil login\` against this target to use it here`;
|
|
11388
|
-
if (s.reason === "unbound") return `the stored dashboard session has no recorded host (written by an older CLI) and is not sent anywhere \u2014 run \`vxil login\` again to bind it`;
|
|
11389
|
-
return "no dashboard session \u2014 run `vxil login`";
|
|
11390
|
-
}
|
|
11391
|
-
function credDir() {
|
|
11392
|
-
return join2(homedir2(), ".vxil");
|
|
11393
|
-
}
|
|
11394
|
-
function credFile() {
|
|
11395
|
-
return join2(credDir(), "credentials.json");
|
|
11396
|
-
}
|
|
11397
|
-
function loadCredentials() {
|
|
11398
|
-
try {
|
|
11399
|
-
return JSON.parse(readFileSync3(credFile(), "utf8"));
|
|
11400
|
-
} catch {
|
|
11401
|
-
return {};
|
|
11402
|
-
}
|
|
11403
|
-
}
|
|
11404
|
-
function saveCredentials(c) {
|
|
11405
|
-
mkdirSync(credDir(), { recursive: true });
|
|
11406
|
-
writeFileSync2(credFile(), JSON.stringify(c, null, 2));
|
|
11407
|
-
try {
|
|
11408
|
-
chmodSync(credFile(), 384);
|
|
11409
|
-
} catch {
|
|
11410
|
-
}
|
|
11411
|
-
}
|
|
11412
|
-
function projectFile(cwd = process.cwd()) {
|
|
11413
|
-
return resolve3(cwd, ".vxil", "project.json");
|
|
12060
|
+
functions: fns.map((f) => ({
|
|
12061
|
+
name: f.name,
|
|
12062
|
+
invoke: `POST /v1/fn/${f.name}`,
|
|
12063
|
+
// keys sorted at every depth: online reads the signature back in the
|
|
12064
|
+
// stored manifest's key order, offline in declaration order (F2)
|
|
12065
|
+
signature: f.signature ? sortKeysDeep(f.signature) : null
|
|
12066
|
+
}))
|
|
12067
|
+
};
|
|
11414
12068
|
}
|
|
11415
|
-
|
|
12069
|
+
var MCP_CHECK_IGNORED_KEYS = ["generated_at", "tenant", "env"];
|
|
12070
|
+
function mcpCatalogUpToDate(existing, catalog) {
|
|
12071
|
+
if (existing === null) return false;
|
|
12072
|
+
const strip = (o) => {
|
|
12073
|
+
for (const k of MCP_CHECK_IGNORED_KEYS) delete o[k];
|
|
12074
|
+
return canonicalJson(o);
|
|
12075
|
+
};
|
|
11416
12076
|
try {
|
|
11417
|
-
|
|
12077
|
+
const cur = JSON.parse(existing);
|
|
12078
|
+
if (!cur || typeof cur !== "object" || Array.isArray(cur)) return false;
|
|
12079
|
+
return strip(cur) === strip(JSON.parse(JSON.stringify(catalog)));
|
|
11418
12080
|
} catch {
|
|
11419
|
-
return
|
|
12081
|
+
return false;
|
|
11420
12082
|
}
|
|
11421
12083
|
}
|
|
11422
|
-
|
|
11423
|
-
|
|
11424
|
-
|
|
11425
|
-
|
|
12084
|
+
|
|
12085
|
+
// src/exportUrls.ts
|
|
12086
|
+
var EXPORT_429_RETRIES = 6;
|
|
12087
|
+
var EXPORT_429_MAX_WAIT_MS = 12e4;
|
|
12088
|
+
var EXPORT_URL_PACE_DEFAULT_S = 30;
|
|
12089
|
+
var EXPORT_URL_PACE_MAX_S = 600;
|
|
12090
|
+
var EXPORT_EXIT_URLS_EXPIRED = 3;
|
|
12091
|
+
var EXPORT_NET_RETRIES = 3;
|
|
12092
|
+
function retryAfterMs(retryAfter, attempt) {
|
|
12093
|
+
const ra = Number(retryAfter);
|
|
12094
|
+
if (Number.isFinite(ra) && ra > 0) return Math.min(ra * 1e3, EXPORT_429_MAX_WAIT_MS);
|
|
12095
|
+
return Math.min(2 ** attempt, 30) * 1e3;
|
|
11426
12096
|
}
|
|
11427
|
-
function
|
|
11428
|
-
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
|
|
11432
|
-
}
|
|
12097
|
+
function describeFetchError(e) {
|
|
12098
|
+
if (!(e instanceof Error)) return String(e);
|
|
12099
|
+
const cause = e.cause;
|
|
12100
|
+
if (!(cause instanceof Error)) return e.message;
|
|
12101
|
+
const code = cause.code;
|
|
12102
|
+
const detail = typeof code === "string" && code && !cause.message.includes(code) ? `${cause.message}, ${code}` : cause.message;
|
|
12103
|
+
return detail ? `${e.message} (${detail})` : e.message;
|
|
11433
12104
|
}
|
|
11434
|
-
function
|
|
11435
|
-
const
|
|
11436
|
-
|
|
11437
|
-
|
|
11438
|
-
return {
|
|
11439
|
-
...keep,
|
|
11440
|
-
tenant_id: primary.tenant_id,
|
|
11441
|
-
slug: primary.slug,
|
|
11442
|
-
edge_url: primary.edge_url,
|
|
11443
|
-
...primary.env !== void 0 ? { env: primary.env } : {}
|
|
11444
|
-
};
|
|
12105
|
+
function incompleteExportMessage(a) {
|
|
12106
|
+
const lines = `${a.written} line${a.written === 1 ? "" : "s"}`;
|
|
12107
|
+
const where = a.out ? `${a.out.path} was ${a.out.existed ? "left untouched (it still holds what it held before this run)" : "not created"}; the ${lines} written before the failure are in ${a.out.partial}` : `the ${lines} already on stdout stop there, and nothing in the stream marks the cut \u2014 a reader must treat it as partial`;
|
|
12108
|
+
const resume = a.resume.length ? ` Resume by hand from ${a.resume.map((r) => `GET /v1/export/${a.feature}?format=ndjson&table=${encodeURIComponent(r.table)}&after=${encodeURIComponent(r.after)}${a.urls && r.table === "objects" ? "&include=urls" : ""}`).join(", then ")} (follow x-vxil-next-after and drop each page's header line), or re-run the export.` : " Re-run the export.";
|
|
12109
|
+
return `${a.reason} \u2014 the export is INCOMPLETE: ${where}.${resume}`;
|
|
11445
12110
|
}
|
|
11446
|
-
function
|
|
11447
|
-
if (
|
|
11448
|
-
|
|
11449
|
-
|
|
11450
|
-
);
|
|
11451
|
-
}
|
|
11452
|
-
if (name === "dev") {
|
|
11453
|
-
return {
|
|
11454
|
-
...existing,
|
|
11455
|
-
dev: {
|
|
11456
|
-
tenant_id: slot.tenant_id,
|
|
11457
|
-
slug: slot.slug,
|
|
11458
|
-
edge_url: slot.edge_url,
|
|
11459
|
-
...slot.expires_at ? { expires_at: slot.expires_at } : {}
|
|
11460
|
-
}
|
|
11461
|
-
};
|
|
12111
|
+
function parsePace(raw) {
|
|
12112
|
+
if (raw === void 0) return { ms: EXPORT_URL_PACE_DEFAULT_S * 1e3 };
|
|
12113
|
+
const s = Number(raw);
|
|
12114
|
+
if (raw.trim() === "" || !Number.isFinite(s) || s < 0 || s > EXPORT_URL_PACE_MAX_S) {
|
|
12115
|
+
return { err: `--pace takes seconds between two download-URL pages, 0..${EXPORT_URL_PACE_MAX_S} (default ${EXPORT_URL_PACE_DEFAULT_S}; 0 = as fast as the rate limiter allows) \u2014 got '${raw}'` };
|
|
11462
12116
|
}
|
|
11463
|
-
|
|
11464
|
-
void _x;
|
|
11465
|
-
return { ...existing, targets: { ...existing.targets, [name]: permanent } };
|
|
11466
|
-
}
|
|
11467
|
-
var TARGET_NAME_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
11468
|
-
function selectorFrom(flags) {
|
|
11469
|
-
if (flags.target !== void 0) return { kind: "named", name: flags.target };
|
|
11470
|
-
if (flags.dev) return { kind: "dev" };
|
|
11471
|
-
return { kind: "primary" };
|
|
11472
|
-
}
|
|
11473
|
-
function describeSelector(sel) {
|
|
11474
|
-
return sel.kind === "primary" ? "primary" : sel.kind === "dev" ? "--dev" : `--target ${sel.name}`;
|
|
12117
|
+
return { ms: Math.round(s * 1e3) };
|
|
11475
12118
|
}
|
|
11476
|
-
|
|
11477
|
-
|
|
11478
|
-
|
|
11479
|
-
sel_msg(selector2) + ` \u2014 ${fix}`
|
|
11480
|
-
);
|
|
11481
|
-
this.selector = selector2;
|
|
11482
|
-
this.fix = fix;
|
|
11483
|
-
this.name = "TargetUnboundError";
|
|
11484
|
-
}
|
|
11485
|
-
selector;
|
|
11486
|
-
fix;
|
|
11487
|
-
};
|
|
11488
|
-
function sel_msg(sel) {
|
|
11489
|
-
if (sel.kind === "dev") return "no dev tenant is bound in .vxil/project.json";
|
|
11490
|
-
if (sel.kind === "named") return `no target '${sel.name}' is bound in .vxil/project.json`;
|
|
11491
|
-
return "no project is bound";
|
|
12119
|
+
function paceWaitMs(lastStartMs, nowMs, paceMs) {
|
|
12120
|
+
if (lastStartMs === null || paceMs <= 0) return 0;
|
|
12121
|
+
return Math.max(0, lastStartMs + paceMs - nowMs);
|
|
11492
12122
|
}
|
|
11493
|
-
function
|
|
11494
|
-
const
|
|
11495
|
-
const
|
|
11496
|
-
|
|
11497
|
-
|
|
11498
|
-
|
|
11499
|
-
|
|
11500
|
-
|
|
11501
|
-
if (selector2.kind === "dev") {
|
|
11502
|
-
const slot = proj?.dev;
|
|
11503
|
-
if (!slot) {
|
|
11504
|
-
throw new TargetUnboundError(
|
|
11505
|
-
selector2,
|
|
11506
|
-
"run `vxil dev up`, `vxil dev branch <name>`, or `vxil link <slug> --as dev` to bind one (the primary/production key is never used for --dev)"
|
|
11507
|
-
);
|
|
12123
|
+
function ndjsonDownloads(lines) {
|
|
12124
|
+
const out = [];
|
|
12125
|
+
for (const l of lines) {
|
|
12126
|
+
if (!l.includes('"download"')) continue;
|
|
12127
|
+
try {
|
|
12128
|
+
const d = JSON.parse(l).download;
|
|
12129
|
+
if (d && typeof d === "object" && "url" in d) out.push(d);
|
|
12130
|
+
} catch {
|
|
11508
12131
|
}
|
|
11509
|
-
const devKey = env.VXIL_DEV_KEY || void 0;
|
|
11510
|
-
const stored = creds.keys?.[slot.slug];
|
|
11511
|
-
const baseUrl2 = envBase ?? slot.edge_url ?? opts.defaultBase;
|
|
11512
|
-
return {
|
|
11513
|
-
apiKey: devKey ?? stored,
|
|
11514
|
-
baseUrl: baseUrl2,
|
|
11515
|
-
tenantSlug: slot.slug,
|
|
11516
|
-
tenantId: slot.tenant_id,
|
|
11517
|
-
slot: "dev",
|
|
11518
|
-
envLabel: envLabelFor(baseUrl2),
|
|
11519
|
-
keySource: devKey ? "VXIL_DEV_KEY" : stored ? "credentials" : "none",
|
|
11520
|
-
selector: selector2
|
|
11521
|
-
};
|
|
11522
12132
|
}
|
|
11523
|
-
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
11529
|
-
|
|
11530
|
-
|
|
12133
|
+
return out;
|
|
12134
|
+
}
|
|
12135
|
+
function newDownloadTally() {
|
|
12136
|
+
return { total: 0, missing: /* @__PURE__ */ new Map(), expiries: [] };
|
|
12137
|
+
}
|
|
12138
|
+
function tallyDownloads(t, entries) {
|
|
12139
|
+
for (const e of entries) {
|
|
12140
|
+
t.total += 1;
|
|
12141
|
+
if (e.url === null) {
|
|
12142
|
+
const r = e.reason ?? "unknown";
|
|
12143
|
+
t.missing.set(r, (t.missing.get(r) ?? 0) + 1);
|
|
12144
|
+
continue;
|
|
11531
12145
|
}
|
|
11532
|
-
const
|
|
11533
|
-
|
|
11534
|
-
return {
|
|
11535
|
-
apiKey: stored,
|
|
11536
|
-
baseUrl: baseUrl2,
|
|
11537
|
-
tenantSlug: slot.slug,
|
|
11538
|
-
tenantId: slot.tenant_id,
|
|
11539
|
-
slot: "named",
|
|
11540
|
-
slotName: name,
|
|
11541
|
-
envLabel: slot.env ?? envLabelFor(baseUrl2),
|
|
11542
|
-
keySource: stored ? "credentials" : "none",
|
|
11543
|
-
selector: selector2
|
|
11544
|
-
};
|
|
12146
|
+
const at = typeof e.expires_at === "string" ? Date.parse(e.expires_at) : NaN;
|
|
12147
|
+
if (Number.isFinite(at)) t.expiries.push(at);
|
|
11545
12148
|
}
|
|
11546
|
-
|
|
11547
|
-
|
|
11548
|
-
|
|
11549
|
-
|
|
11550
|
-
|
|
11551
|
-
|
|
11552
|
-
|
|
11553
|
-
|
|
11554
|
-
|
|
11555
|
-
|
|
11556
|
-
|
|
11557
|
-
|
|
12149
|
+
return t;
|
|
12150
|
+
}
|
|
12151
|
+
function missingSummary(t) {
|
|
12152
|
+
const missing = [...t.missing.values()].reduce((a, b2) => a + b2, 0);
|
|
12153
|
+
if (!missing) return null;
|
|
12154
|
+
const parts = [...t.missing.entries()].sort((a, b2) => b2[1] - a[1]).map(([r, n]) => `${r} \xD7${n}`);
|
|
12155
|
+
return `vxil: ${missing} of ${t.total} object${t.total === 1 ? "" : "s"} carry NO download URL (${parts.join(", ")}) \u2014 status:* rows have no confirmed bytes yet (or are deleted); insufficient_scope needs files:read on the key; re-run for network_error`;
|
|
12156
|
+
}
|
|
12157
|
+
function expiredReport(t, nowMs, startedMs, readLive) {
|
|
12158
|
+
let deadCount = 0;
|
|
12159
|
+
let oldestAt = Infinity;
|
|
12160
|
+
for (const at of t.expiries) {
|
|
12161
|
+
if (at > nowMs) continue;
|
|
12162
|
+
deadCount += 1;
|
|
12163
|
+
if (at < oldestAt) oldestAt = at;
|
|
11558
12164
|
}
|
|
11559
|
-
if (
|
|
11560
|
-
|
|
11561
|
-
|
|
12165
|
+
if (!deadCount) return null;
|
|
12166
|
+
const minted = t.expiries.length;
|
|
12167
|
+
const ranMin = Math.max(1, Math.round((nowMs - startedMs) / 6e4));
|
|
12168
|
+
const oldest = new Date(oldestAt).toISOString();
|
|
12169
|
+
const remedy = "raise `features.files.downloadUrlTtl` (seconds, max 86400) above the export's run time (~0.3 s per object at the default --pace), read the NDJSON as it streams (pipe it to your fetcher), or mint per object at fetch time with GET /v1/files/{id}/download-url";
|
|
12170
|
+
if (readLive) {
|
|
11562
12171
|
return {
|
|
11563
|
-
|
|
11564
|
-
|
|
11565
|
-
tenantSlug: proj.slug,
|
|
11566
|
-
tenantId: proj.tenant_id,
|
|
11567
|
-
slot: "primary",
|
|
11568
|
-
envLabel: proj.env ?? envLabelFor(baseUrl2),
|
|
11569
|
-
keySource: key ? "credentials" : "none",
|
|
11570
|
-
selector: selector2
|
|
12172
|
+
exitCode: 0,
|
|
12173
|
+
message: `vxil: ${deadCount} of ${minted} download URL${minted === 1 ? "" : "s"} had expired by the time the export finished (it ran ~${ranMin} min; the oldest died at ${oldest}) \u2014 fine if your reader fetched each page as it streamed; a reader that waits for the end gets 403 on them. To keep them alive: ${remedy}.`
|
|
11571
12174
|
};
|
|
11572
12175
|
}
|
|
11573
|
-
const baseUrl = envBase ?? opts.defaultBase;
|
|
11574
12176
|
return {
|
|
11575
|
-
|
|
11576
|
-
|
|
11577
|
-
tenantSlug: void 0,
|
|
11578
|
-
tenantId: void 0,
|
|
11579
|
-
slot: "none",
|
|
11580
|
-
envLabel: envLabelFor(baseUrl),
|
|
11581
|
-
keySource: "none",
|
|
11582
|
-
selector: selector2
|
|
12177
|
+
exitCode: EXPORT_EXIT_URLS_EXPIRED,
|
|
12178
|
+
message: `vxil: ${deadCount} of ${minted} download URL${minted === 1 ? "" : "s"} had ALREADY EXPIRED when the export finished (it ran ~${ranMin} min; the oldest died at ${oldest}) \u2014 a GET on them answers 403, so those objects' bytes are NOT one fetch away. The metadata is complete (exit ${EXPORT_EXIT_URLS_EXPIRED}, not 1). Fix: ${remedy}.`
|
|
11583
12179
|
};
|
|
11584
12180
|
}
|
|
11585
|
-
|
|
11586
|
-
|
|
12181
|
+
var OBJECTS_TRUNCATED = /^objects: truncated at /;
|
|
12182
|
+
function objectsCut(b2) {
|
|
12183
|
+
const tr = Array.isArray(b2.truncated) ? b2.truncated.find((t) => t.table === "objects") : void 0;
|
|
12184
|
+
if (tr) return typeof tr.after === "string" && tr.after ? { after: tr.after } : { unresumable: true };
|
|
12185
|
+
if (Array.isArray(b2.skipped) && b2.skipped.some((s) => OBJECTS_TRUNCATED.test(s))) return { unresumable: true };
|
|
12186
|
+
return null;
|
|
12187
|
+
}
|
|
12188
|
+
function mergeObjectsPage(b2, lines) {
|
|
12189
|
+
b2.tables ??= [];
|
|
12190
|
+
let objects = b2.tables.find((t) => t.name === "objects");
|
|
12191
|
+
if (!objects) {
|
|
12192
|
+
objects = { name: "objects", rows: [] };
|
|
12193
|
+
b2.tables.push(objects);
|
|
12194
|
+
}
|
|
12195
|
+
b2.downloads ??= [];
|
|
12196
|
+
let n = 0;
|
|
12197
|
+
for (const l of lines) {
|
|
12198
|
+
let parsed;
|
|
11587
12199
|
try {
|
|
11588
|
-
|
|
12200
|
+
parsed = JSON.parse(l);
|
|
11589
12201
|
} catch {
|
|
11590
|
-
|
|
12202
|
+
continue;
|
|
11591
12203
|
}
|
|
11592
|
-
|
|
11593
|
-
|
|
11594
|
-
|
|
11595
|
-
|
|
12204
|
+
if (parsed.t !== "objects" || !parsed.r || typeof parsed.r !== "object") continue;
|
|
12205
|
+
objects.rows.push(parsed.r);
|
|
12206
|
+
n += 1;
|
|
12207
|
+
const d = parsed.download;
|
|
12208
|
+
if (d && typeof d === "object" && "url" in d) {
|
|
12209
|
+
b2.downloads.push({
|
|
12210
|
+
object_id: String(parsed.r.object_id ?? ""),
|
|
12211
|
+
url: d.url,
|
|
12212
|
+
expires_at: d.expires_at ?? null,
|
|
12213
|
+
...d.reason ? { reason: d.reason } : {}
|
|
12214
|
+
});
|
|
12215
|
+
}
|
|
12216
|
+
}
|
|
12217
|
+
return n;
|
|
11596
12218
|
}
|
|
11597
|
-
function
|
|
11598
|
-
if (
|
|
11599
|
-
|
|
11600
|
-
|
|
11601
|
-
|
|
11602
|
-
|
|
11603
|
-
|
|
11604
|
-
if (
|
|
11605
|
-
|
|
11606
|
-
|
|
11607
|
-
|
|
11608
|
-
|
|
11609
|
-
for (const [n, s] of Object.entries(proj.targets ?? {})) push(`target:${n}`, s);
|
|
11610
|
-
for (const [n, s] of Object.entries(proj.branches ?? {})) push(`branch:${n}`, s);
|
|
11611
|
-
return out;
|
|
12219
|
+
function markObjectsComplete(b2) {
|
|
12220
|
+
if (Array.isArray(b2.skipped)) {
|
|
12221
|
+
b2.skipped = b2.skipped.filter((s) => !OBJECTS_TRUNCATED.test(s));
|
|
12222
|
+
if (!b2.skipped.length) delete b2.skipped;
|
|
12223
|
+
}
|
|
12224
|
+
if (Array.isArray(b2.truncated)) {
|
|
12225
|
+
b2.truncated = b2.truncated.filter((t) => t.table !== "objects");
|
|
12226
|
+
if (!b2.truncated.length) delete b2.truncated;
|
|
12227
|
+
}
|
|
12228
|
+
}
|
|
12229
|
+
function truncationNotes(b2) {
|
|
12230
|
+
return Array.isArray(b2.skipped) ? b2.skipped.filter((s) => /: truncated at \d+ rows/.test(s)) : [];
|
|
11612
12231
|
}
|
|
11613
12232
|
|
|
11614
12233
|
// src/drift.ts
|
|
11615
|
-
function
|
|
12234
|
+
function isPlainObject2(v) {
|
|
11616
12235
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
11617
12236
|
}
|
|
11618
12237
|
function flattenLeaves(obj, prefix = "") {
|
|
11619
12238
|
const out = /* @__PURE__ */ new Map();
|
|
11620
|
-
if (!
|
|
12239
|
+
if (!isPlainObject2(obj)) return out;
|
|
11621
12240
|
for (const k of Object.keys(obj).sort()) {
|
|
11622
12241
|
const path = prefix ? `${prefix}.${k}` : k;
|
|
11623
12242
|
const v = obj[k];
|
|
11624
|
-
if (
|
|
12243
|
+
if (isPlainObject2(v) && Object.keys(v).length) {
|
|
11625
12244
|
for (const [p, lv] of flattenLeaves(v, path)) out.set(p, lv);
|
|
11626
12245
|
} else {
|
|
11627
12246
|
out.set(path, v);
|
|
@@ -12493,159 +13112,6 @@ async function createBranchTenant(dash, opts) {
|
|
|
12493
13112
|
};
|
|
12494
13113
|
}
|
|
12495
13114
|
|
|
12496
|
-
// src/promotionGate.ts
|
|
12497
|
-
var NON_PRODUCTION_ENV_LABELS = /* @__PURE__ */ new Set([
|
|
12498
|
-
"staging",
|
|
12499
|
-
"stage",
|
|
12500
|
-
"dev",
|
|
12501
|
-
"development",
|
|
12502
|
-
"test",
|
|
12503
|
-
"qa",
|
|
12504
|
-
"preview",
|
|
12505
|
-
"sandbox",
|
|
12506
|
-
"local"
|
|
12507
|
-
]);
|
|
12508
|
-
function normalizeLabel(label) {
|
|
12509
|
-
return label.trim().toLowerCase();
|
|
12510
|
-
}
|
|
12511
|
-
function isNonProductionLabel(label) {
|
|
12512
|
-
return NON_PRODUCTION_ENV_LABELS.has(normalizeLabel(label));
|
|
12513
|
-
}
|
|
12514
|
-
function isDevSlot(t) {
|
|
12515
|
-
return t.slot === "dev" || t.slot === "named" && t.selector.kind === "named" && t.selector.name === "dev";
|
|
12516
|
-
}
|
|
12517
|
-
function isProductionTarget(t) {
|
|
12518
|
-
if (isDevSlot(t)) return false;
|
|
12519
|
-
if (normalizeLabel(t.envLabel) === "production") return true;
|
|
12520
|
-
return envLabelFor(t.baseUrl) === "production" && !isNonProductionLabel(t.envLabel);
|
|
12521
|
-
}
|
|
12522
|
-
function hostOf(baseUrl) {
|
|
12523
|
-
try {
|
|
12524
|
-
return new URL(baseUrl).host;
|
|
12525
|
-
} catch {
|
|
12526
|
-
return baseUrl;
|
|
12527
|
-
}
|
|
12528
|
-
}
|
|
12529
|
-
function unrecognizedLabelNotice(t) {
|
|
12530
|
-
if (isDevSlot(t)) return null;
|
|
12531
|
-
if (normalizeLabel(t.envLabel) === "production") return null;
|
|
12532
|
-
if (envLabelFor(t.baseUrl) !== "production") return null;
|
|
12533
|
-
if (isNonProductionLabel(t.envLabel)) return null;
|
|
12534
|
-
return `env label '${t.envLabel}' is not a recognized environment and the host is ${hostOf(t.baseUrl)} \u2014 treating this target as PRODUCTION (use --env staging|dev|test|qa|preview|sandbox|local to say otherwise)`;
|
|
12535
|
-
}
|
|
12536
|
-
function envLabelWarning(label, baseUrl) {
|
|
12537
|
-
const l = normalizeLabel(label);
|
|
12538
|
-
const host = hostOf(baseUrl);
|
|
12539
|
-
if (envLabelFor(baseUrl) === "production") {
|
|
12540
|
-
if (l === "production") return null;
|
|
12541
|
-
if (isNonProductionLabel(l)) {
|
|
12542
|
-
return `slot labelled '${label}' on the PRODUCTION host ${host} \u2014 the push promotion gate will NOT fire for it (the documented staging-twin pattern; re-link with --env production if this IS a production tenant)`;
|
|
12543
|
-
}
|
|
12544
|
-
return `env label '${label}' is not a recognized environment \u2014 on the PRODUCTION host ${host} it is treated as PRODUCTION (the push promotion gate WILL fire). Use production|staging|dev|test|qa|preview|sandbox|local.`;
|
|
12545
|
-
}
|
|
12546
|
-
if (l === "production") {
|
|
12547
|
-
return `slot labelled 'production' on the non-production host ${host} \u2014 the push promotion gate WILL fire for it`;
|
|
12548
|
-
}
|
|
12549
|
-
return null;
|
|
12550
|
-
}
|
|
12551
|
-
function isPlainObject2(v) {
|
|
12552
|
-
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
12553
|
-
}
|
|
12554
|
-
function leafAt(manifest, dotted) {
|
|
12555
|
-
let cur = manifest;
|
|
12556
|
-
for (const seg of dotted.split(".")) {
|
|
12557
|
-
if (!isPlainObject2(cur)) return void 0;
|
|
12558
|
-
cur = cur[seg];
|
|
12559
|
-
}
|
|
12560
|
-
return cur;
|
|
12561
|
-
}
|
|
12562
|
-
function mockFeatures(cfg) {
|
|
12563
|
-
return Object.entries(cfg.features ?? {}).filter(([, conf]) => {
|
|
12564
|
-
if (!isPlainObject2(conf)) return false;
|
|
12565
|
-
const c = conf;
|
|
12566
|
-
return c.provider === "mock" || c.defaultProvider === "mock" || (isPlainObject2(c.embed) ? c.embed.provider === "mock" : false);
|
|
12567
|
-
}).map(([f]) => f);
|
|
12568
|
-
}
|
|
12569
|
-
var SANDBOX_LEAVES = [
|
|
12570
|
-
{ feature: "payments", path: "paddle.sandbox" },
|
|
12571
|
-
{ feature: "payments", path: "paypal.sandbox" },
|
|
12572
|
-
{ feature: "payments", path: "revenuecat.acceptSandbox" }
|
|
12573
|
-
];
|
|
12574
|
-
function sandboxFeatures(cfg) {
|
|
12575
|
-
const out = [];
|
|
12576
|
-
for (const leaf of SANDBOX_LEAVES) {
|
|
12577
|
-
const manifest = (cfg.features ?? {})[leaf.feature];
|
|
12578
|
-
if (leafAt(manifest, leaf.path) === true) out.push(`${leaf.feature}.${leaf.path}`);
|
|
12579
|
-
}
|
|
12580
|
-
return out;
|
|
12581
|
-
}
|
|
12582
|
-
function isDevHost(host) {
|
|
12583
|
-
const h = host.toLowerCase().replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
|
12584
|
-
if (h === "localhost" || h.endsWith(".localhost")) return true;
|
|
12585
|
-
if (h === "127.0.0.1" || h.startsWith("127.")) return true;
|
|
12586
|
-
if (h === "::1") return true;
|
|
12587
|
-
if (h === "local" || h.endsWith(".local")) return true;
|
|
12588
|
-
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
|
|
12589
|
-
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
|
|
12590
|
-
if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
|
|
12591
|
-
return false;
|
|
12592
|
-
}
|
|
12593
|
-
function devRedirectOrigins(origins = []) {
|
|
12594
|
-
const out = [];
|
|
12595
|
-
for (const raw of origins) {
|
|
12596
|
-
if (typeof raw !== "string" || !raw) continue;
|
|
12597
|
-
if (/^http:\/\//i.test(raw)) {
|
|
12598
|
-
out.push(raw);
|
|
12599
|
-
continue;
|
|
12600
|
-
}
|
|
12601
|
-
const host = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/[/?#].*$/, "");
|
|
12602
|
-
if (host && isDevHost(host)) out.push(raw);
|
|
12603
|
-
}
|
|
12604
|
-
return out;
|
|
12605
|
-
}
|
|
12606
|
-
function redirectOrigins(cfg) {
|
|
12607
|
-
const v = leafAt((cfg.features ?? {}).auth, "security.allowedRedirectOrigins");
|
|
12608
|
-
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
12609
|
-
}
|
|
12610
|
-
var PROMOTION_OVERRIDE_FLAGS = {
|
|
12611
|
-
mock: "allow-mock-in-prod",
|
|
12612
|
-
sandbox: "allow-sandbox-in-prod",
|
|
12613
|
-
origins: "allow-dev-origins-in-prod"
|
|
12614
|
-
};
|
|
12615
|
-
function promotionRefusals(cfg, allowed) {
|
|
12616
|
-
const out = [];
|
|
12617
|
-
const mocks = mockFeatures(cfg);
|
|
12618
|
-
if (mocks.length && !allowed(PROMOTION_OVERRIDE_FLAGS.mock)) {
|
|
12619
|
-
out.push({
|
|
12620
|
-
kind: "mock",
|
|
12621
|
-
flag: PROMOTION_OVERRIDE_FLAGS.mock,
|
|
12622
|
-
message: `mock providers on [${mocks.join(", ")}] would ship to production (mock email/payments silently no-op). Set a real provider, or override with --${PROMOTION_OVERRIDE_FLAGS.mock}.`
|
|
12623
|
-
});
|
|
12624
|
-
}
|
|
12625
|
-
const sandboxes = sandboxFeatures(cfg);
|
|
12626
|
-
if (sandboxes.length && !allowed(PROMOTION_OVERRIDE_FLAGS.sandbox)) {
|
|
12627
|
-
out.push({
|
|
12628
|
-
kind: "sandbox",
|
|
12629
|
-
flag: PROMOTION_OVERRIDE_FLAGS.sandbox,
|
|
12630
|
-
message: `sandbox provider flags [${sandboxes.join(", ")}] would ship to a live money path (a sandbox key signs with the wrong secret; RevenueCat folds test purchases as outcome rejected_environment). Set them false, or override with --${PROMOTION_OVERRIDE_FLAGS.sandbox}.`
|
|
12631
|
-
});
|
|
12632
|
-
}
|
|
12633
|
-
const devOrigins = devRedirectOrigins(redirectOrigins(cfg));
|
|
12634
|
-
if (devOrigins.length && !allowed(PROMOTION_OVERRIDE_FLAGS.origins)) {
|
|
12635
|
-
out.push({
|
|
12636
|
-
kind: "origins",
|
|
12637
|
-
flag: PROMOTION_OVERRIDE_FLAGS.origins,
|
|
12638
|
-
message: `auth.security.allowedRedirectOrigins contains non-production origin(s) [${devOrigins.join(", ")}] \u2014 a loopback / .local / http:// redirect target on a production auth tenant is a token-exfiltration footgun. Remove them, or override with --${PROMOTION_OVERRIDE_FLAGS.origins}.`
|
|
12639
|
-
});
|
|
12640
|
-
}
|
|
12641
|
-
return out;
|
|
12642
|
-
}
|
|
12643
|
-
function redirectOriginNotice(cfg) {
|
|
12644
|
-
const origins = redirectOrigins(cfg);
|
|
12645
|
-
if (!origins.length) return null;
|
|
12646
|
-
return `auth.security.allowedRedirectOrigins (${origins.length}): ${origins.join(", ")}`;
|
|
12647
|
-
}
|
|
12648
|
-
|
|
12649
13115
|
// src/try.ts
|
|
12650
13116
|
async function mintTry(fetchImpl, base2, opts = {}) {
|
|
12651
13117
|
const url = `${base2.replace(/\/$/, "")}/v1/try`;
|
|
@@ -16692,7 +17158,7 @@ export default defineConfig({
|
|
|
16692
17158
|
"hasFunctions": true,
|
|
16693
17159
|
"byoKeys": [],
|
|
16694
17160
|
"configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Funnel SaaS\" \u2014 the STUDIO STARTER. A web-first funnel product, declared\n// end-to-end in ONE typed file:\n//\n// guest session \u2192 guided steps \u2192 e-mail claim \u2192 account \u2192 pass \u2192 AI brief\n//\n// What it composes (every block is a shipped feature; nothing here is a\n// platform primitive you would have to build):\n// \u2022 auth \u2192 anonymous sign-in for the visitor, an OTP e-mail claim that\n// PROMOTES the guest in place (same id, same rows), a session\n// pair the server refreshes behind a long-lived cookie\n// \u2022 cms \u2192 two owner-scoped collections behind `strictEndUserScope`:\n// a signed-in visitor sees ONLY their own rows, and a\n// collection without an owner field is server-only\n// \u2022 rate-limits \u2192 two policies DECLARED here, converged by `vxil push`\n// \u2022 webhooks \u2192 failure alerts to the owners + ONE declared outbound\n// subscription for your ops endpoint\n// \u2022 ai \u2192 a stored prompt template DECLARED here; the brief runs on\n// the job lane with a `correlation_id` = the session row id\n// \u2022 payments \u2192 a payments INTEGRATION: your own provider account (mock\n// here), vxil never in the flow of funds\n// \u2022 functions \u2192 three event-driven functions (below)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n // Visitors never see a password form: a guest session first, an e-mail\n // code to claim it later (magic link stays on as the alternative).\n methods: { emailPassword: false, magicLink: true },\n anonymous: { enabled: true },\n // The claim rides the OTP machinery (POST /v1/auth/anonymous/link/request\n // + /verify). `testRecipients` is where your CI address goes \u2014 a listed\n // address gets no mail and the 202 carries `test_code`.\n otp: { enabled: true, codeTtlMinutes: 10, maxAttempts: 5, resendCooldownSec: 60 },\n // The pair your server middleware rotates (README: \"keep the token fresh\").\n session: { ttlMinutes: 60, refreshTtlDays: 30 },\n },\n\n cms: {\n // A funnel row is live the moment it is written \u2014 no editorial draft step.\n draftPublish: false,\n // THE FAIL-SAFE. In end-user mode a collection with no `ownerField` is\n // server-only (403 server_only on read AND write) instead of\n // tenant-wide-shared. Every collection below declares an owner, so a\n // visitor can only ever reach their own rows; a collection you add later\n // without one is closed to them until you say otherwise.\n strictEndUserScope: true,\n hooks: {\n // Steps only move forward \u2014 a client cannot rewind a completed intake.\n // (`coalesce` so a row created without a step can still be stepped.)\n steps_forward_only: {\n collection: 'funnel_sessions',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: 'coalesce(item.step, 0) >= coalesce(before.step, 0)',\n message: 'steps only move forward',\n },\n // The session state machine: in_progress \u2192 completed, and completed is\n // terminal (the brief function flips it; nothing flips it back).\n session_transition: {\n collection: 'funnel_sessions',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: \"item.status == before.status || (before.status == 'in_progress' && item.status == 'completed')\",\n message: 'illegal session status transition',\n },\n // A pass is bound to the charge that paid for it, forever.\n pass_charge_immutable: {\n collection: 'passes',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: 'item.charge_id == before.charge_id',\n message: 'a pass keeps the charge that paid for it',\n },\n },\n },\n\n // Auth mail (the claim code) and the \"your brief is ready\" message ride\n // this. `mock` needs no account; point it at your own provider to go live.\n notifications: { provider: 'mock', fromEmail: 'hello@studio.example' },\n\n // DECLARED API STATE #1 \u2014 rate-limit policies. These used to be rows you\n // created by hand with POST /v1/rate-limits/policies and re-created in every\n // environment; now `vxil push` converges the live set onto this list by\n // name (creates what is missing, patches what differs, REPORTS what you did\n // not declare). Your server checks them with POST /v1/rate-limits/check.\n 'rate-limits': {\n enabled: true,\n policies: [\n // one visitor, thirty step writes a minute \u2014 enough for a human, not a loop\n { name: 'funnel-step', key_template: '{tenant_id}:{user_id}', limit: 30, window_seconds: 60 },\n // three claim codes per address per ten minutes; pass a HASH of the\n // address as `email_hash`, never the address itself (it becomes a key)\n { name: 'claim-request', key_template: '{tenant_id}:{email_hash}', limit: 3, window_seconds: 600, behavior: 'block' },\n ],\n },\n\n webhooks: {\n enabled: true,\n // FAILURE ALERTS. Every error-level failure event in this backend (a dead\n // delivery, a failed generation, a missed schedule) is digested into a\n // mail to the project's owner accounts every 15 minutes. Recipients are\n // deliberately not configurable; `digestMinutes: 0` makes it immediate.\n alerts: { enabled: true, minLevel: 'error', digestMinutes: 15 },\n // DECLARED API STATE #2 \u2014 an outbound subscription. Converged by\n // target_url on push; a live row you did not declare is left and\n // reported. The three function triggers below are NOT declared here \u2014\n // their subscriptions derive from the functions manifest.\n subscriptions: [\n { target_url: 'https://ops.studio.example/vxil/events', event_prefixes: ['payments.', 'auth.user.'] },\n ],\n },\n\n ai: {\n enabled: true,\n defaultProvider: 'mock', // BYO key later: providers.openaiKeyRef / anthropicKeyRef / geminiKeyRef\n defaults: { maxTokens: 800, temperature: 0.4 },\n // DECLARED API STATE #3 \u2014 the stored prompt template. Converged by\n // CONTENT: a push stores a new version only when the text changed, so\n // pushes are idempotent and versions stay monotonic. vxil stores and\n // renders it; the wording is yours.\n templates: [\n {\n template: 'studio-brief',\n system:\n 'You are a senior brand strategist. Write in plain, confident English. ' +\n 'Never invent facts about the client; when something is unknown, say what you would need.',\n user:\n 'Write a one-page creative brief for a studio client.\\n\\n' +\n 'Goal: {{goal}}\\nAudience: {{audience}}\\nTone: {{tone}}\\nConstraints: {{constraints}}\\n\\n' +\n 'Sections: Objective, Audience insight, Key message, Deliverables, Next steps.',\n },\n ],\n },\n\n // The job lane the brief runs on. `retry` is the ladder for JOB RUNS \u2014 the\n // generation itself, and deliveries to your own endpoints. It is NOT a\n // retry of what a platform-delivered function ANSWERS: the dispatcher ACKs\n // every trigger delivery with a 200 whatever the function returned (guide\n // 08, \"What is retried, and what is not\"), so the three functions below\n // make their failures recoverable through the rows they own \u2014 a 5xx from\n // one of them is observed (`functions.run.failed` \u2192 `webhooks.alerts`),\n // never re-delivered.\n jobs: { enabled: true, retry: { defaultMaxAttempts: 3 } },\n\n // A payments INTEGRATION: the tenant's own Stripe/Paddle/PayPal/RevenueCat\n // account; vxil is never in the flow of funds. `mock` needs no account and\n // runs everything UP TO the paywall \u2014 push, deploy, the intake, the claim\n // and every refusal (402 pass_required, 409 intake_incomplete). It cannot\n // put a PAID charge on the ledger: its checkout URL is a placeholder, its\n // webhooks are signed with the platform's secret, and\n // `vx.payments.simulate` scripts subscriptions, not one-off charges \u2014 so on\n // `mock` grant-pass never wakes and start-brief answers 402 to everyone.\n // The paid path needs your provider's sandbox (README, \"Running the paid\n // path\"), e.g. a Stripe test-mode account:\n // payments: { enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n // stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' } },\n payments: { enabled: true, provider: 'mock', defaults: { currency: 'usd' } },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // One row per visitor intake. Created and stepped by the browser as the\n // (guest, then claimed) end-user; completed and settled by functions.\n funnel_sessions: {\n singular: 'funnel_session',\n // The owner. In end-user mode the platform STAMPS this with the verified\n // user id on create and scopes every read/patch/delete to it \u2014 a body\n // naming someone else is a 400, and a foreign row is an honest 404.\n ownerField: 'user',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' },\n status: { type: 'string', indexSlot: 's2', validation: { enum: ['in_progress', 'completed'] } },\n generation_id: { type: 'string', indexSlot: 's3' }, // the job-lane generation that owns the result\n result_status: { type: 'string', indexSlot: 's4', validation: { enum: ['queued', 'completed', 'failed'] } },\n step: { type: 'int', indexSlot: 'n1', validation: { min: 1, max: 4 } }, // 1..4, forward only\n started_at: { type: 'datetime', indexSlot: 't1' },\n completed_at: { type: 'datetime', indexSlot: 't2' },\n answers: { type: 'json' }, // { goal, audience, tone, constraints } \u2014 the template's inputs\n result: { type: 'text' }, // the settled brief\n error_hint: { type: 'string' }, // the provider's own words when the brief failed\n },\n },\n\n // One row per PAID pass, written by the grant function on the provider's\n // charge event. `charge_id` is the dedupe anchor: an at-least-once\n // redelivery of the same charge is a clean 409 the function treats as\n // \"already granted\". It is the DISPLAY RECORD, never the entitlement: the\n // collection is owned, so the visitor's own thin-client key (cms:write)\n // can create a pass or stretch its expires_at \u2014 start-brief decides from\n // the owner-bound payments ledger instead.\n passes: {\n singular: 'pass',\n ownerField: 'user',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' },\n charge_id: { type: 'string', required: true, unique: true, indexSlot: 's2' },\n product: { type: 'string', indexSlot: 's3' },\n amount_cents: { type: 'int', indexSlot: 'n1' },\n granted_at: { type: 'datetime', indexSlot: 't1' },\n expires_at: { type: 'datetime', indexSlot: 't2' }, // the brief function's range filter\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: three event-driven functions \u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // THE BRIEF. Invoked by the browser AS THE SIGNED-IN VISITOR\n // (vx.asEndUser(token).fn['start-brief']({ session_id })): the end-user\n // principal rides into the function, so its cms reads are owner-scoped and\n // the generation is billed to that user. Requires a PAID pass, read from\n // the owner-bound ledger (`payments:read`) on every call \u2014 never from the\n // visitor-writable `passes` row, which it re-writes as the record when a\n // grant delivery was lost; CLAIMS the row with If-Match before spending\n // anything (a double click is one generation);\n // starts the brief on the ai JOB lane with correlation_id = the session row id.\n 'start-brief': {\n entry: './functions/start-brief.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'payments:read'],\n egressAllow: [],\n signature: {\n input: { session_id: 'string' },\n // Two 202 answers, so the output is a whole TS type (a union): a new\n // brief's handle, or `already_queued` when one is running \u2014 whose\n // generation_id is null while that claim has not recorded its handle.\n output:\n '{ generation_id: string; run_id: string; correlation_id: string }' +\n \" | { generation_id: string | null; status: 'already_queued' }\",\n },\n },\n\n // THE GRANT. Woken by ONE platform event \u2014 `payments.charge.succeeded`, the\n // full event name as the `source` (the prefix `payments.charge.` would wake\n // it twice per Paddle charge, on `succeeded` and on `completed`) \u2014 a few\n // seconds after your provider's webhook folds. If your pass were a TIER for\n // a number of days rather than a count of briefs, you would not write this\n // function at all: `payments.ledger.productMap['studio-pass'] = { tier: 'pass',\n // durationDays: 30 }` and the platform writes the pass on the charge event\n // (stacking, refund-aware). Re-reads the charge from the ledger (the event name is a\n // routing claim, not an attestation), then writes the pass. A 5xx from it\n // is ACKed and observed, not re-delivered \u2014 start-brief decides from the\n // same ledger, so a lost grant never blocks a payer.\n 'grant-pass': {\n entry: './functions/grant-pass.ts',\n trigger: { kind: 'webhook', source: 'payments.charge.succeeded' },\n scopes: ['payments:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE SETTLE. Woken by the `job.generation.` prefix \u2014 completed AND failed \u2014\n // and finds its own row through the event's `correlation_id` (no run\u2192record\n // table of your own). On completed: the brief text from the replay buffer\n // + a \"ready\" message. On failed: `error_hint` \u2014 the provider's own words \u2014\n // onto the row, so the visitor sees why, not just that. A settle that\n // cannot complete marks the row `failed` (the retry is one click) \u2014 the\n // delivery is ACKed whatever it answers, so recovery lives in the row.\n 'settle-brief': {\n entry: './functions/settle-brief.ts',\n trigger: { kind: 'webhook', source: 'job.generation.' },\n scopes: ['cms:read', 'cms:write', 'ai:read', 'notifications:send'],\n egressAllow: [],\n },\n },\n});\n",
|
|
16695
|
-
"readme": "# Funnel SaaS \u2014 the studio starter (saas)\n\nThe **web-first funnel** blueprint: a visitor lands, starts as a **guest**, walks a guided intake,\n**claims** the session with an e-mail code, buys a **pass** through your own payments provider,\nand gets an AI-written brief **settled back onto their record** \u2014 all of it declared in one typed\n`vxil.config.ts`, with three small functions for the parts that are code.\n\nIt is the shape most \"studio\" products have \u2014 a lead magnet, a paid deliverable, an account that\nonly exists once someone cares \u2014 and it is also the blueprint that shows **declared API state**:\nrate-limit policies, an outbound subscription and a stored prompt template live in the config and\nconverge on `vxil push`, so a second environment is a push, not a runbook.\n\n```bash\nvxil init --template funnel-saas\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the three functions + the declared API state\nvxil gen # typed SDK: vx.from('funnel_sessions'), vx.fn['start-brief']\n```\n\n**What it provisions:**\n- `funnel_sessions` \u2014 `user` (the **owner field**), `status` (`in_progress \u2192 completed`, hook-guarded),\n `step` (1\u20134, forward only), `answers` (json), then the brief's `generation_id`, `result_status`\n (`queued | completed | failed`), `result` and `error_hint`.\n- `passes` \u2014 `user` (owner), `charge_id` (**unique** \u2014 the dedupe anchor), `product`, `amount_cents`,\n `granted_at`, `expires_at`. The record the page shows \u2014 the payments ledger is the entitlement.\n- Features: `auth` (guest + OTP claim + a refreshable session pair) \xB7 `cms` (**`strictEndUserScope: true`**) \xB7\n `rate-limits` (two **declared policies**) \xB7 `webhooks` (**`alerts` on** + one **declared subscription**) \xB7\n `ai` (one **declared template**) \xB7 `jobs` \xB7 `payments` (a payments **integration** \u2014 your provider; `mock`\n here, which runs everything *up to* the paywall \u2014 [Running the paid path](#running-the-paid-path)) \xB7\n `notifications` (`mock`) \xB7 `functions`.\n- Functions: `start-brief` (http, invoked as the visitor), `grant-pass` (webhook on\n `payments.charge.succeeded`), `settle-brief` (webhook on the `job.generation.` prefix).\n\n> **A simpler pass, if your pass is a tier for a number of days.** Since 2026-09-25 a\n> `ledger.productMap` entry may be `{ tier: 'pass', durationDays: 30 }`: the platform then writes\n> the pass itself on the charge event (stacking behind a live one, ended by a full refund or\n> chargeback), and `GET /v1/payments/entitlements` answers \"is this visitor on a pass, until when\".\n> This blueprint keeps its own `passes` record and `grant-pass` function on purpose \u2014 its pass is a\n> *count of briefs*, decided from the owner-bound charge ledger, not a tier for a duration.\n\n## The funnel, step by step\n\nEvery call below is the browser talking to your backend \u2014 with **two keys**, the way\nvxil.com/docs/guide/09-security-and-multitenancy prescribes: a **sign-in key** (the ordinary class,\ncarrying only `auth:signin`) for the moment *before* there is a session, and a **thin-client key**\n(`key_class: 'public'` \u2014 `cms:read`, `cms:write`, `functions:invoke`) for everything after, which the\nedge refuses unless it rides with a valid end-user token. A public key *cannot* carry `payments:write`\n(a `422` at mint), so the checkout is the one call your server makes \u2014 the last section.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst signin = Vxil.connect({ apiKey: SIGNIN_KEY }); // auth:signin only \u2014 works with no session\nconst thin = Vxil.connect({ apiKey: THIN_CLIENT_KEY }); // public class \u2014 needs a session on every call\n\n// 1. A guest. Real user id, real session, no e-mail yet (the token carries `anon: true`).\nconst guest = await signin.auth.anonymous.signIn();\nconst me = thin.asEndUser(guest.session.token);\n\n// 2. The intake. Owner-scoped: the platform stamps `user` with the guest's id on create,\n// and this session (and only this session) can read and step the row.\nconst { item_id: session_id } = await me.from('funnel_sessions').create({\n status: 'in_progress', step: 1, started_at: new Date().toISOString(),\n answers: { goal: 'Launch a coffee subscription' },\n});\nawait me.from('funnel_sessions').patch(session_id, { step: 2, answers: { goal: '\u2026', audience: '\u2026' } });\n// \u2026steps 3 and 4. A PATCH that moves `step` backwards is a 422 from the `steps_forward_only` hook.\n\n// 3. The claim. A 6-digit code proves the address; the guest is PROMOTED IN PLACE \u2014\n// same user id, so the intake rows are already theirs. (Sign-in class: the sign-in key.)\nawait signin.auth.anonymous.link.request({ token: guest.session.token, email: 'ada@example.com' });\nconst claimed = await signin.auth.anonymous.link.verify({ token: guest.session.token, email: 'ada@example.com', code: '123456' });\n// claimed.user_id === guest.user_id \u2014 unless the address already had an account: then\n// `merged: true`, a fresh session, and `rekeyed` says whether the guest's rows moved already.\n\n// 4. The pass \u2014 only after the claim. Your SERVER refuses a guest, creates the hosted checkout\n// (below) and hands the URL back; the provider's webhook folds the charge \u2192\n// `payments.charge.succeeded` \u2192 grant-pass writes the pass record.\n\n// 5. The brief. Invoked AS THE VISITOR: the function's reads are owner-scoped and the\n// generation is metered to them. Until the visitor's ledger holds a paid charge the call\n// throws a VxilError whose `.code` is 'pass_required' (402); an unfinished intake is\n// 'intake_incomplete' (409).\nconst handle = await me.fn['start-brief']({ session_id });\n// \u2192 { generation_id, run_id, correlation_id } for a new brief, or \u2014 a second click while one\n// is running \u2014 { generation_id, status: 'already_queued' }: one generation, metered once,\n// and `generation_id` is null while that attempt has not recorded its handle yet (the\n// declared signature is that union). Either way, watch the ROW, not the handle:\n// \u2026seconds to minutes later, settle-brief writes `result` (or `error_hint`) onto the row and\n// notifications delivers \"Your brief is ready\". A failed brief can be started again: the\n// same call on a row whose result_status is 'failed' queues a new generation.\n```\n\n## What to learn from this\n\n- **`strictEndUserScope: true` is the fail-safe version of owner scoping.** Both collections declare an\n `ownerField`, so a signed-in visitor reaches exactly their own rows; a collection you add later *without*\n one is `403 server_only` for end-users on read and write instead of tenant-wide-shared. Nothing about a\n server call changes \u2014 a server key still sees everything\n (vxil.com/docs/guide/09-security-and-multitenancy).\n- **A guest is a real end-user.** `vx.auth.anonymous.signIn()` mints a real id; owner-scoped rows written\n before the claim stay attached because the OTP claim (`POST /v1/auth/anonymous/link/request` + `/verify`)\n promotes the guest in place. Passing the guest's token as `anonymous_token` on a magic-link request does\n the same through mail (vxil.com/docs/guide/06-feature-catalog).\n- **Declared API state.** Three things that used to be rows you created by hand \u2014 and re-created in every\n environment \u2014 are config now, and `vxil push` converges the live rows onto them, reporting (never\n deleting) what you did not declare:\n\n | Block | Converged by | The live rows it replaces |\n |---|---|---|\n | `features['rate-limits'].policies[]` | `name` | `POST /v1/rate-limits/policies` |\n | `features.webhooks.subscriptions[]` | `target_url` | `POST /v1/webhooks/subscriptions` |\n | `features.ai.templates[]` | content hash | `POST /v1/ai/templates` (a new version only when the text changed) |\n\n The three function triggers are *not* declared here \u2014 a `webhook`-trigger binding on a function is its\n own declaration, and the platform reconciles that subscription from the functions manifest.\n- **An event-driven grant, done honestly.** `grant-pass` wakes on the full event name\n `payments.charge.succeeded` a few seconds after your provider's webhook folds. The event name is a\n *routing claim*, so the function re-reads the ledger (`GET /v1/payments/charges`) before writing; the\n `passes.charge_id` unique field (the ledger's charge id) turns an at-least-once redelivery into a `409`\n it reads as \"already granted\". And it is honest about **what is not retried**: the dispatcher ACKs a\n platform-delivered function with a `200` *whatever it answered*, so a `5xx` from the API is never\n re-delivered \u2014 it is *observed* (`functions.run.failed`, once per failure streak, in the\n `webhooks.alerts` digest), and recovery is data, not delivery: `start-brief` decides from the same\n owner-bound ledger on every request, so a lost grant never blocks a payer, and it writes the missing\n pass record for that charge (a `409` there just means it is recorded already)\n (vxil.com/docs/guide/08-running-your-code-functions).\n- **An owned row the visitor can write is never an entitlement.** `passes` declares an `ownerField`, so\n the thin-client key (`cms:write`) lets a visitor *create* a pass of their own or PATCH its\n `expires_at` to 2099 \u2014 the platform stamps them as the owner, which proves who wrote the row, not that\n anyone paid, and a write hook's `caller` is always `null`, so no hook can tell that write from\n `grant-pass`'s (vxil.com/docs/guide/07-validation-and-hooks). So `start-brief` never reads `passes` to\n decide: on every call it reads `GET /v1/payments/charges?status=succeeded` as the visitor (bound to\n the verified principal; no key a browser holds can write the ledger) and needs a succeeded, unrefunded\n charge from the last 365 days. The pass row is what the page *shows*; the ledger is what it *checks*.\n- **`correlation_id` closes the loop.** `start-brief` sends the session row id as `correlation_id` on\n `POST /v1/ai/generate` (`mode: 'job'`); the platform echoes it on `job.generation.completed` /\n `job.generation.failed` next to `generation_id`, so `settle-brief` finds its own row from the event alone.\n On `failed` it writes **`error_hint`** \u2014 the provider's own status and message \u2014 onto the row, so the\n visitor sees *why* (and `start-brief` accepts that row again, so a retry is one more click); on\n `completed` the text comes from the generation's replay buffer\n (`GET /v1/ai/generations/{generation_id}/stream?since=0`). Every settle write carries\n `if: { generation_id }`, so a late delivery for an earlier attempt can never overwrite a newer one; and a\n settle that cannot complete marks the row `failed` with `settle failed: <reason>` instead of answering a\n status nothing will act on \u2014 the row, not the delivery, is where recovery lives.\n- **The claim comes before the spend.** `start-brief` reads the row's `version`, PATCHes\n `result_status: 'queued'` with `If-Match: <version>`, and only then calls generate. A double click that\n lands inside the generate round-trip loses the compare-and-set with `409 version_conflict` and gets a\n `202 already_queued` \u2014 one generation, one credit reserve. A claim whose generate never recorded, or a\n queued handle older than the job lane's ceiling, is treated as failed on the next click, so a row can\n never stick in `queued`. Every write after the claim (recording the handle, marking a failure) carries\n `If-Match: <the claim's version>`, so an attempt the next click already swept can never overwrite the\n newer claim \u2014 its `409` means \"the row moved on\", and it steps aside without a write.\n- **Function errors are coded.** Every refusal a function answers is `{ error: { code, message } }` \u2014\n `POST /v1/fn/:name` hands that body back verbatim, and the SDK raises it as a `VxilError` whose `.code`\n is the function's own (`pass_required`, `intake_incomplete`, `already_settled`), so a page branches on a\n code, never on a message. The platform-delivered functions answer `200 { skipped }` for a decision, and a\n `500` with a code (`unsettled`, `grant_unsettled`) only when not even the row could record the failure \u2014\n not to be retried (nothing re-delivers it) but to be *seen* through `functions.run.failed`.\n- **Failure alerts are on.** `webhooks.alerts` mails the project's owner accounts every 15 minutes with the\n error-level failure events \u2014 a dead delivery, a failed generation, a missed schedule. Recipients are\n deliberately not configurable; `digestMinutes: 0` makes it immediate.\n- **The payments feature is an integration.** Your Stripe, Paddle, PayPal or RevenueCat account, the\n provider's hosted checkout, the provider's webhook; vxil folds the events and keeps the ledger. The\n scaffold's `mock` provider stops at the paywall, so the paid path runs on your provider's sandbox \u2014 the\n next section. To rehearse the grant chain without a card, Paddle's simulator signs with your real endpoint\n secret and your `payments.` functions run for real (vxil.com/docs/guide/06-feature-catalog).\n\n## Server side: keep the visitor's token fresh\n\nA session token lives `session.ttlMinutes` (60 here); the cookie your app sets lives days. Every sign-in\nreturns a pair \u2014 the token and a `refresh_token` good for `session.refreshTtlDays` (30) \u2014 and\n`POST /v1/auth/sessions/refresh` rotates it. So a server-rendered app needs one root middleware, not a\nredesign (vxil.com/docs/guide/09-security-and-multitenancy):\n\n```ts\n// once per request, before any loader runs\nconst { token, refresh_token, expires_at } = readSessionCookie(request);\nlet session = { token, refresh_token, expires_at };\nif (Date.parse(expires_at) - Date.now() < 5 * 60_000) { // within 5 min of expiry (or past it)\n try {\n session = (await vx.auth.sessions.refresh(refresh_token)).session; // rotated pair\n setSessionCookie(response, session); // store BOTH halves again\n } catch {\n clearSessionCookie(response); // refresh revoked or expired: sign in again\n }\n}\nconst asUser = vx.asEndUser(session.token); // every per-visitor read below is scoped by the platform\n```\n\nStore the refresh token only in the `HttpOnly` cookie; refresh *early* rather than on `401`, so parallel\nloaders do not race to rotate the same token; keep the server key for the tenant-wide rows only.\n\nThe same server owns the **checkout** \u2014 `POST /v1/payments/checkout-sessions` needs `payments:write`, which\na public key cannot carry, and it is server-only (a `403 server_only` in end-user mode). Three rules shape it:\n\n- **The buyer is the session, never a form field.** Ask the platform whose token it is:\n `vx.auth.sessions.verify` answers with the account *as it is now* (`auth:read` on the server key).\n- **A guest cannot buy \u2014 claim first.** A charge stays with the user id that paid it. When a guest claims an\n address that already has an account, the merge re-keys the guest's subscriptions, customer links and\n credit balances onto that account (vxil.com/docs/guide/06-feature-catalog, \"What a merge moves\") \u2014 not its\n one-off charges \u2014 and `start-brief` decides from the charges ledger. A guest who paid and *then* merged\n would have paid for a brief the merged account can never start. So refuse the checkout until the claim:\n `verified` is `false` while the visitor has no proven address \u2014 a guest has none, and the claim *is* the\n proof (every other way into this blueprint, a magic link or a code, proves one too). Not the token's\n `anon` claim: that is a snapshot from when the token was minted, not the account as it is now.\n- **One Idempotency-Key per purchase *attempt*, never per visitor.** A key the payments feature has seen\n replays its first session verbatim, and the key never expires \u2014 so `pass:${user_id}` would hand a visitor\n whose checkout expired, who was refunded, or who comes back after the 365-day pass the same dead URL\n forever. Key the attempt: the buy page renders a fresh attempt id into its form, so a double click posts\n the same id (one session), while a reload, a return from the checkout or next year's renewal renders a\n new one. The id is not a credential \u2014 the `user_id` next to it is the verified one, so an invented id\n only ever opens another checkout for the visitor's own account.\n\n```ts\n// the visitor is signed in (the middleware above keeps `session` fresh); the form posts `attempt`\nconst who = await vx.auth.sessions.verify(session.token); // { user_id, verified, \u2026 } \u2014 the account NOW\nif (!who.verified) { // still a guest: charges never follow a merge\n return Response.json({ error: { code: 'claim_required', message: 'claim your e-mail before you buy' } }, { status: 409 });\n}\nconst attempt = String(form.get('attempt') ?? ''); // crypto.randomUUID(), rendered with the buy page\nif (!/^[\\w-]{16,64}$/.test(attempt)) {\n return Response.json({ error: { code: 'bad_attempt', message: 'reload the page and try again' } }, { status: 400 });\n}\nconst { url } = await vx.payments.createCheckoutSession(\n { user_id: who.user_id, mode: 'payment',\n line_items: [{ price_ref: 'studio-pass', amount_cents: 4900 }], // a real provider takes ITS price id \u2014 below\n success_url: 'https://studio.example/thanks', cancel_url: 'https://studio.example/pass' },\n { idempotencyKey: `pass:${who.user_id}:${attempt}` },\n);\n// redirect the visitor to `url` \u2014 a hosted checkout on YOUR provider; vxil is never in the flow of funds.\n```\n\nThe same server is where the declared rate-limit policies are checked. `POST /v1/rate-limits/check` takes a\n`policy_id`, never a name \u2014 list the policies once after the first push and keep the two ids:\n\n```ts\nconst policies = await vx.rateLimits.listPolicies(); // RateLimitPolicy[] \u2014 \u2264100, no cursor\nconst stepPolicy = policies.find((p) => p.name === 'funnel-step')!.policy_id;\n// before forwarding a step write\nawait vx.rateLimits.check({ policy_id: stepPolicy, key_values: { user_id }, cost: 1 }); // 429 + Retry-After when blocked\n// before forwarding a claim request \u2014 a HASH of the address, never the address (it becomes a key)\nawait vx.rateLimits.check({ policy_id: claimPolicy, key_values: { email_hash: sha256(email) } });\n```\n\n## Running the paid path\n\nThe scaffold ships `payments.provider: 'mock'`, so `vxil push` works with no provider account, and\neverything but the payment runs on it: the intake, the claim, the deploy, and every refusal\n(`402 pass_required`, `409 intake_incomplete`, a guest's `claim_required`). What `mock` cannot do is put a\n**paid** charge on the ledger: its checkout URL is a placeholder host nothing serves, its webhooks are\nsigned with the platform's own secret rather than one you hold, and `vx.payments.simulate` scripts\n*subscription* lifecycles, not a one-off charge. So on `mock`, `grant-pass` never wakes and `start-brief`\nanswers `402 pass_required` to every visitor \u2014 the paywall doing its job. To run the brief end to end,\npoint the block at your provider's sandbox, in a project of its own (the signing-secret ref is one per\nprovider \u2014 vxil.com/docs/guide/06-feature-catalog):\n\n```ts\n// vxil.config.ts \u2014 a Stripe TEST-mode account (a Paddle sandbox is `provider: 'paddle'` + `paddle: { \u2026 }`)\npayments: {\n enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n},\n```\n\n```bash\nvxil secrets set payments/stripe_secret # the test-mode secret key, at the hidden prompt\nvxil secrets set payments/stripe_webhook # the signing secret of the endpoint below\nvxil push\n```\n\nRegister `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenantId>` as the endpoint in the\nprovider's dashboard. On a real provider `line_items[].price_ref` is the provider's own price id (Stripe\n`price_\u2026`), not `studio-pass`. Pay with the provider's test card: the charge folds \u2192\n`payments.charge.succeeded` \u2192 `grant-pass` records the pass, and the next `start-brief` queues the brief.\n\n**Go deeper:** vxil.com/docs/guide/09-security-and-multitenancy (end-user mode \xB7 `strictEndUserScope` \xB7 the\nrefresh middleware) \xB7 vxil.com/docs/guide/06-feature-catalog (auth guests and claims \xB7 rate-limits \xB7\nwebhooks alerts and declared subscriptions \xB7 ai `mode: 'job'` \xB7 payments) \xB7\nvxil.com/docs/guide/08-running-your-code-functions (the `webhook` trigger \xB7 at-least-once) \xB7\nvxil.com/docs/guide/04-data-with-cms (owner scoping \xB7 the query DSL).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
|
|
17161
|
+
"readme": "# Funnel SaaS \u2014 the studio starter (saas)\n\nThe **web-first funnel** blueprint: a visitor lands, starts as a **guest**, walks a guided intake,\n**claims** the session with an e-mail code, buys a **pass** through your own payments provider,\nand gets an AI-written brief **settled back onto their record** \u2014 all of it declared in one typed\n`vxil.config.ts`, with three small functions for the parts that are code.\n\nIt is the shape most \"studio\" products have \u2014 a lead magnet, a paid deliverable, an account that\nonly exists once someone cares \u2014 and it is also the blueprint that shows **declared API state**:\nrate-limit policies, an outbound subscription and a stored prompt template live in the config and\nconverge on `vxil push`, so a second environment is a push, not a runbook.\n\n```bash\nvxil init --template funnel-saas\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the three functions + the declared API state\nvxil gen # typed SDK: vx.from('funnel_sessions'), vx.fn['start-brief']\n```\n\n**What it provisions:**\n- `funnel_sessions` \u2014 `user` (the **owner field**), `status` (`in_progress \u2192 completed`, hook-guarded),\n `step` (1\u20134, forward only), `answers` (json), then the brief's `generation_id`, `result_status`\n (`queued | completed | failed`), `result` and `error_hint`.\n- `passes` \u2014 `user` (owner), `charge_id` (**unique** \u2014 the dedupe anchor), `product`, `amount_cents`,\n `granted_at`, `expires_at`. The record the page shows \u2014 the payments ledger is the entitlement.\n- Features: `auth` (guest + OTP claim + a refreshable session pair) \xB7 `cms` (**`strictEndUserScope: true`**) \xB7\n `rate-limits` (two **declared policies**) \xB7 `webhooks` (**`alerts` on** + one **declared subscription**) \xB7\n `ai` (one **declared template**) \xB7 `jobs` \xB7 `payments` (a payments **integration** \u2014 your provider; `mock`\n here, which runs everything *up to* the paywall \u2014 [Running the paid path](#running-the-paid-path)) \xB7\n `notifications` (`mock`) \xB7 `functions`.\n- Functions: `start-brief` (http, invoked as the visitor), `grant-pass` (webhook on\n `payments.charge.succeeded`), `settle-brief` (webhook on the `job.generation.` prefix).\n\n> **A simpler pass, if your pass is a tier for a number of days.** Since 2026-09-25 a\n> `ledger.productMap` entry may be `{ tier: 'pass', durationDays: 30 }`: the platform then writes\n> the pass itself on the charge event (stacking behind a live one, ended by a full refund or\n> chargeback), and `GET /v1/payments/entitlements` answers \"is this visitor on a pass, until when\".\n> This blueprint keeps its own `passes` record and `grant-pass` function on purpose \u2014 its pass is a\n> *count of briefs*, decided from the owner-bound charge ledger, not a tier for a duration.\n\n## The funnel, step by step\n\nEvery call below is the browser talking to your backend \u2014 with **two keys**, the way\nvxil.com/docs/guide/09-security-and-multitenancy prescribes: a **sign-in key** (the ordinary class,\ncarrying only `auth:signin`) for the moment *before* there is a session, and a **thin-client key**\n(`key_class: 'public'` \u2014 `cms:read`, `cms:write`, `functions:invoke`) for everything after, which the\nedge refuses unless it rides with a valid end-user token. A public key *cannot* carry `payments:write`\n(a `422` at mint), so the checkout is the one call your server makes \u2014 the last section.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst signin = Vxil.connect({ apiKey: SIGNIN_KEY }); // auth:signin only \u2014 works with no session\nconst thin = Vxil.connect({ apiKey: THIN_CLIENT_KEY }); // public class \u2014 needs a session on every call\n\n// 1. A guest. Real user id, real session, no e-mail yet (the token carries `anon: true`).\nconst guest = await signin.auth.anonymous.signIn();\nconst me = thin.asEndUser(guest.session.token);\n\n// 2. The intake. Owner-scoped: the platform stamps `user` with the guest's id on create,\n// and this session (and only this session) can read and step the row.\nconst { item_id: session_id } = await me.from('funnel_sessions').create({\n status: 'in_progress', step: 1, started_at: new Date().toISOString(),\n answers: { goal: 'Launch a coffee subscription' },\n});\nawait me.from('funnel_sessions').patch(session_id, { step: 2, answers: { goal: '\u2026', audience: '\u2026' } });\n// \u2026steps 3 and 4. A PATCH that moves `step` backwards is a 422 from the `steps_forward_only` hook.\n\n// 3. The claim. A 6-digit code proves the address; the guest is PROMOTED IN PLACE \u2014\n// same user id, so the intake rows are already theirs. (Sign-in class: the sign-in key.)\nawait signin.auth.anonymous.link.request({ token: guest.session.token, email: 'ada@example.com' });\nconst claimed = await signin.auth.anonymous.link.verify({ token: guest.session.token, email: 'ada@example.com', code: '123456' });\n// claimed.user_id === guest.user_id \u2014 unless the address already had an account: then\n// `merged: true`, a fresh session, and `rekeyed` says whether the guest's rows moved already.\n\n// 4. The pass \u2014 only after the claim. Your SERVER refuses a guest, creates the hosted checkout\n// (below) and hands the URL back; the provider's webhook folds the charge \u2192\n// `payments.charge.succeeded` \u2192 grant-pass writes the pass record.\n\n// 5. The brief. Invoked AS THE VISITOR: the function's reads are owner-scoped and the\n// generation is metered to them. Until the visitor's ledger holds a paid charge the call\n// throws a VxilError whose `.code` is 'pass_required' (402); an unfinished intake is\n// 'intake_incomplete' (409).\nconst handle = await me.fn['start-brief']({ session_id });\n// \u2192 { generation_id, run_id, correlation_id } for a new brief, or \u2014 a second click while one\n// is running \u2014 { generation_id, status: 'already_queued' }: one generation, metered once,\n// and `generation_id` is null while that attempt has not recorded its handle yet (the\n// declared signature is that union). Either way, watch the ROW, not the handle:\n// \u2026seconds to minutes later, settle-brief writes `result` (or `error_hint`) onto the row and\n// notifications delivers \"Your brief is ready\". A failed brief can be started again: the\n// same call on a row whose result_status is 'failed' queues a new generation.\n```\n\n## What to learn from this\n\n- **`strictEndUserScope: true` is the fail-safe version of owner scoping.** Both collections declare an\n `ownerField`, so a signed-in visitor reaches exactly their own rows; a collection you add later *without*\n one is `403 server_only` for end-users on read and write instead of tenant-wide-shared. Nothing about a\n server call changes \u2014 a server key still sees everything\n (vxil.com/docs/guide/09-security-and-multitenancy).\n- **A guest is a real end-user.** `vx.auth.anonymous.signIn()` mints a real id; owner-scoped rows written\n before the claim stay attached because the OTP claim (`POST /v1/auth/anonymous/link/request` + `/verify`)\n promotes the guest in place. Passing the guest's token as `anonymous_token` on a magic-link request does\n the same through mail (vxil.com/docs/guide/06-feature-catalog).\n- **Declared API state.** Three things that used to be rows you created by hand \u2014 and re-created in every\n environment \u2014 are config now, and `vxil push` converges the live rows onto them, reporting (never\n deleting) what you did not declare:\n\n | Block | Converged by | The live rows it replaces |\n |---|---|---|\n | `features['rate-limits'].policies[]` | `name` | `POST /v1/rate-limits/policies` |\n | `features.webhooks.subscriptions[]` | `target_url` | `POST /v1/webhooks/subscriptions` |\n | `features.ai.templates[]` | content hash | `POST /v1/ai/templates` (a new version only when the text changed) |\n\n The three function triggers are *not* declared here \u2014 a `webhook`-trigger binding on a function is its\n own declaration, and the platform reconciles that subscription from the functions manifest.\n- **An event-driven grant, done honestly.** `grant-pass` wakes on the full event name\n `payments.charge.succeeded` a few seconds after your provider's webhook folds. The event name is a\n *routing claim*, so the function re-reads the ledger (`GET /v1/payments/charges`) before writing; the\n `passes.charge_id` unique field (the ledger's charge id) turns an at-least-once redelivery into a `409`\n it reads as \"already granted\". And it is honest about **what is not retried**: the dispatcher ACKs a\n platform-delivered function with a `200` *whatever it answered*, so a `5xx` from the API is never\n re-delivered \u2014 it is *observed* (`functions.run.failed`, once per failure streak, in the\n `webhooks.alerts` digest), and recovery is data, not delivery: `start-brief` decides from the same\n owner-bound ledger on every request, so a lost grant never blocks a payer, and it writes the missing\n pass record for that charge (a `409` there just means it is recorded already)\n (vxil.com/docs/guide/08-running-your-code-functions).\n- **An owned row the visitor can write is never an entitlement.** `passes` declares an `ownerField`, so\n the thin-client key (`cms:write`) lets a visitor *create* a pass of their own or PATCH its\n `expires_at` to 2099 \u2014 the platform stamps them as the owner, which proves who wrote the row, not that\n anyone paid, and a write hook's `caller` is always `null`, so no hook can tell that write from\n `grant-pass`'s (vxil.com/docs/guide/07-validation-and-hooks). So `start-brief` never reads `passes` to\n decide: on every call it reads `GET /v1/payments/charges?status=succeeded` as the visitor (bound to\n the verified principal; no key a browser holds can write the ledger) and needs a succeeded, unrefunded\n charge from the last 365 days. The pass row is what the page *shows*; the ledger is what it *checks*.\n- **`correlation_id` closes the loop.** `start-brief` sends the session row id as `correlation_id` on\n `POST /v1/ai/generate` (`mode: 'job'`); the platform echoes it on `job.generation.completed` /\n `job.generation.failed` next to `generation_id`, so `settle-brief` finds its own row from the event alone.\n On `failed` it writes **`error_hint`** \u2014 the provider's own status and message \u2014 onto the row, so the\n visitor sees *why* (and `start-brief` accepts that row again, so a retry is one more click); on\n `completed` the text comes from the generation's replay buffer\n (`GET /v1/ai/generations/{generation_id}/stream?since=0`). Every settle write carries\n `if: { generation_id }`, so a late delivery for an earlier attempt can never overwrite a newer one; and a\n settle that cannot complete marks the row `failed` with `settle failed: <reason>` instead of answering a\n status nothing will act on \u2014 the row, not the delivery, is where recovery lives.\n- **The claim comes before the spend.** `start-brief` reads the row's `version`, PATCHes\n `result_status: 'queued'` with `If-Match: <version>`, and only then calls generate. A double click that\n lands inside the generate round-trip loses the compare-and-set with `409 version_conflict` and gets a\n `202 already_queued` \u2014 one generation, one credit reserve. A claim whose generate never recorded, or a\n queued handle older than the job lane's ceiling, is treated as failed on the next click, so a row can\n never stick in `queued`. Every write after the claim (recording the handle, marking a failure) carries\n `If-Match: <the claim's version>`, so an attempt the next click already swept can never overwrite the\n newer claim \u2014 its `409` means \"the row moved on\", and it steps aside without a write.\n- **Function errors are coded.** Every refusal a function answers is `{ error: { code, message } }` \u2014\n `POST /v1/fn/:name` hands that body back verbatim, and the SDK raises it as a `VxilError` whose `.code`\n is the function's own (`pass_required`, `intake_incomplete`, `already_settled`), so a page branches on a\n code, never on a message. The platform-delivered functions answer `200 { skipped }` for a decision, and a\n `500` with a code (`unsettled`, `grant_unsettled`) only when not even the row could record the failure \u2014\n not to be retried (nothing re-delivers it) but to be *seen* through `functions.run.failed`.\n- **Failure alerts are on.** `webhooks.alerts` mails the project's owner accounts every 15 minutes with the\n error-level failure events \u2014 a dead delivery, a failed generation, a missed schedule. Recipients are\n deliberately not configurable; `digestMinutes: 0` makes it immediate.\n- **The payments feature is an integration.** Your Stripe, Paddle, PayPal or RevenueCat account, the\n provider's hosted checkout, the provider's webhook; vxil folds the events and keeps the ledger. The\n scaffold's `mock` provider stops at the paywall, so the paid path runs on your provider's sandbox \u2014 the\n next section. To rehearse the grant chain without a card, Paddle's simulator signs with your real endpoint\n secret and your `payments.` functions run for real (vxil.com/docs/guide/06-feature-catalog).\n\n## Server side: keep the visitor's token fresh\n\nA session token lives `session.ttlMinutes` (60 here); the cookie your app sets lives days. Every sign-in\nreturns a pair \u2014 the token and a `refresh_token` good for `session.refreshTtlDays` (30) \u2014 and\n`vx.auth.sessions.ensureFresh(pair)` rotates it a few minutes before it expires (`POST /v1/auth/sessions/refresh`\nunderneath). So a server-rendered app needs one root middleware, not a redesign. The full recipe, with its\n`refreshFailure()` classifier and the `markRefreshFailed()` marker, is in\nvxil.com/docs/guide/09-security-and-multitenancy (\"Keeping the end-user token fresh behind a long-lived cookie\"):\n\n```ts\n// once per request, before any loader runs\nlet asUser = null;\nconst pair = readSessionCookie(request); // { token, refresh_token, expires_at }\nif (pair) {\n try {\n const { session, refreshed } = await vx.auth.sessions.ensureFresh(pair);\n if (refreshed) setSessionCookie(response, session); // rotated: store BOTH halves again\n asUser = vx.asEndUser(session.token); // every per-visitor read below is scoped by the platform\n } catch (e) {\n // NOT \"sign in again\" on any failure \u2014 see below. refreshFailure() is guide 09's classifier.\n const failure = refreshFailure(e, pair, readCookie(request, 'vx_refresh_failed'));\n if (failure === 'dead') clearSessionCookie(response); // revoked or expired: sign in again\n if (failure === 'race') markRefreshFailed(response, pair); // keep the cookie: the winner's is on its way\n // 'transient': keep the cookie; this one request runs signed out\n }\n}\n```\n\n**A failed refresh is not always a dead session.** Two requests carrying the same pair can reach two server\ninstances: one rotates, the other re-sends the refresh token that was revoked a moment ago and gets\n`401 invalid_refresh` \u2014 the same answer a truly revoked or expired token gets. Clearing the cookie on *any*\nfailure lets that loser's response overwrite the winner's new cookie, and a signed-in visitor is signed out for\nnothing. So the classifier keeps the cookie for anything that is not `invalid_refresh` (a network failure, a\ntimeout, a `429` or `5xx`), treats the first `invalid_refresh` of a pair as a lost race (a short-lived marker names\nthe pair), and clears the cookie only when the *same* pair fails again after a 15-second grace, or the cookie holds\nno refresh token at all.\n\nStore the refresh token only in the `HttpOnly` cookie; refresh *early* rather than on `401`, so parallel\nloaders in one process share one rotation; keep the server key for the tenant-wide rows only.\n\nThe same server owns the **checkout** \u2014 `POST /v1/payments/checkout-sessions` needs `payments:write`, which\na public key cannot carry, and it is server-only (a `403 server_only` in end-user mode). Three rules shape it:\n\n- **The buyer is the session, never a form field.** Ask the platform whose token it is:\n `vx.auth.sessions.verify` answers with the account *as it is now* (`auth:read` on the server key).\n- **A guest cannot buy \u2014 claim first.** A charge stays with the user id that paid it. When a guest claims an\n address that already has an account, the merge re-keys the guest's subscriptions, customer links and\n credit balances onto that account (vxil.com/docs/guide/06-feature-catalog, \"What a merge moves\") \u2014 not its\n one-off charges \u2014 and `start-brief` decides from the charges ledger. A guest who paid and *then* merged\n would have paid for a brief the merged account can never start. So refuse the checkout until the claim:\n `verified` is `false` while the visitor has no proven address \u2014 a guest has none, and the claim *is* the\n proof (every other way into this blueprint, a magic link or a code, proves one too). Not the token's\n `anon` claim: that is a snapshot from when the token was minted, not the account as it is now.\n- **One Idempotency-Key per purchase *attempt*, never per visitor.** A key the payments feature has seen\n replays its first session verbatim, and the key never expires \u2014 so `pass:${user_id}` would hand a visitor\n whose checkout expired, who was refunded, or who comes back after the 365-day pass the same dead URL\n forever. Key the attempt: the buy page renders a fresh attempt id into its form, so a double click posts\n the same id (one session), while a reload, a return from the checkout or next year's renewal renders a\n new one. The id is not a credential \u2014 the `user_id` next to it is the verified one, so an invented id\n only ever opens another checkout for the visitor's own account.\n\n```ts\n// the visitor is signed in (the middleware above keeps `session` fresh); the form posts `attempt`\nconst who = await vx.auth.sessions.verify(session.token); // { user_id, verified, \u2026 } \u2014 the account NOW\nif (!who.verified) { // still a guest: charges never follow a merge\n return Response.json({ error: { code: 'claim_required', message: 'claim your e-mail before you buy' } }, { status: 409 });\n}\nconst attempt = String(form.get('attempt') ?? ''); // crypto.randomUUID(), rendered with the buy page\nif (!/^[\\w-]{16,64}$/.test(attempt)) {\n return Response.json({ error: { code: 'bad_attempt', message: 'reload the page and try again' } }, { status: 400 });\n}\nconst { url } = await vx.payments.createCheckoutSession(\n { user_id: who.user_id, mode: 'payment',\n line_items: [{ price_ref: 'studio-pass', amount_cents: 4900 }], // a real provider takes ITS price id \u2014 below\n success_url: 'https://studio.example/thanks', cancel_url: 'https://studio.example/pass' },\n { idempotencyKey: `pass:${who.user_id}:${attempt}` },\n);\n// redirect the visitor to `url` \u2014 a hosted checkout on YOUR provider; vxil is never in the flow of funds.\n```\n\nThe same server is where the declared rate-limit policies are checked. `POST /v1/rate-limits/check` takes a\n`policy_id`, never a name \u2014 list the policies once after the first push and keep the two ids:\n\n```ts\nconst policies = await vx.rateLimits.listPolicies(); // RateLimitPolicy[] \u2014 \u2264100, no cursor\nconst stepPolicy = policies.find((p) => p.name === 'funnel-step')!.policy_id;\n// before forwarding a step write\nawait vx.rateLimits.check({ policy_id: stepPolicy, key_values: { user_id }, cost: 1 }); // 429 + Retry-After when blocked\n// before forwarding a claim request \u2014 a HASH of the address, never the address (it becomes a key)\nawait vx.rateLimits.check({ policy_id: claimPolicy, key_values: { email_hash: sha256(email) } });\n```\n\n## Running the paid path\n\nThe scaffold ships `payments.provider: 'mock'`, so `vxil push` works with no provider account, and\neverything but the payment runs on it: the intake, the claim, the deploy, and every refusal\n(`402 pass_required`, `409 intake_incomplete`, a guest's `claim_required`). What `mock` cannot do is put a\n**paid** charge on the ledger: its checkout URL is a placeholder host nothing serves, its webhooks are\nsigned with the platform's own secret rather than one you hold, and `vx.payments.simulate` scripts\n*subscription* lifecycles, not a one-off charge. So on `mock`, `grant-pass` never wakes and `start-brief`\nanswers `402 pass_required` to every visitor \u2014 the paywall doing its job. To run the brief end to end,\npoint the block at your provider's sandbox, in a project of its own (the signing-secret ref is one per\nprovider \u2014 vxil.com/docs/guide/06-feature-catalog):\n\n```ts\n// vxil.config.ts \u2014 a Stripe TEST-mode account (a Paddle sandbox is `provider: 'paddle'` + `paddle: { \u2026 }`)\npayments: {\n enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n},\n```\n\n```bash\nvxil secrets set payments/stripe_secret # the test-mode secret key, at the hidden prompt\nvxil secrets set payments/stripe_webhook # the signing secret of the endpoint below\nvxil push\n```\n\nRegister `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenantId>` as the endpoint in the\nprovider's dashboard. On a real provider `line_items[].price_ref` is the provider's own price id (Stripe\n`price_\u2026`), not `studio-pass`. Pay with the provider's test card: the charge folds \u2192\n`payments.charge.succeeded` \u2192 `grant-pass` records the pass, and the next `start-brief` queues the brief.\n\n**Go deeper:** vxil.com/docs/guide/09-security-and-multitenancy (end-user mode \xB7 `strictEndUserScope` \xB7 the\nrefresh middleware) \xB7 vxil.com/docs/guide/06-feature-catalog (auth guests and claims \xB7 rate-limits \xB7\nwebhooks alerts and declared subscriptions \xB7 ai `mode: 'job'` \xB7 payments) \xB7\nvxil.com/docs/guide/08-running-your-code-functions (the `webhook` trigger \xB7 at-least-once) \xB7\nvxil.com/docs/guide/04-data-with-cms (owner scoping \xB7 the query DSL).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
|
|
16696
17162
|
"functions": {
|
|
16697
17163
|
"grant-pass.ts": "// grant-pass.ts \u2014 GRANT A PASS ON A SUCCESSFUL CHARGE (a vxil function, \xA77.3).\n//\n// Trigger: webhook, source 'payments.charge.succeeded' \u2014 the FULL event name, so\n// this function wakes for exactly one event: the moment your provider's webhook\n// (Stripe, Paddle, PayPal, RevenueCat, or the mock) folds a succeeded charge.\n// The platform wires the subscription for you on deploy; the first attempt is\n// delivered directly, a few seconds after the event.\n//\n// The envelope's `payload` is the event, flattened:\n// { event, audit_id, occurred_at, actor, surface, data }\n// and `data` is the payments.charge.succeeded payload:\n// { end_user_id, provider, provider_charge_id, amount_cents, currency, product_id, environment }\n//\n// THREE RULES this function lives by:\n// \u2022 The event NAME is a routing claim, not an attestation \u2014 anyone who can\n// enqueue a job in your own project can produce a delivery with a chosen\n// name. So the ledger is re-read (GET /v1/payments/charges) before a pass\n// is written; a charge the ledger does not know is skipped.\n// \u2022 Delivery is at-least-once. `passes.charge_id` is UNIQUE (the LEDGER's\n// charge id \u2014 the same key start-brief's record write uses), so a redelivery\n// is a clean 409 unique_violation treated as \"already granted\".\n// \u2022 Nothing this function ANSWERS is re-delivered. The dispatcher ACKs every\n// platform-delivered trigger with a 200 whatever the handler returned\n// (guide 08, \"What is retried, and what is not\") \u2014 so a 5xx here is not a\n// retry request, it is the OBSERVATION channel: `functions.run.failed`\n// (once per failure streak) \u2192 the `webhooks.alerts` digest. Recovery is\n// data, not delivery: the pass row is a DISPLAY RECORD (an owned row the\n// visitor's own key could write, so it is never the entitlement) \u2014\n// start-brief decides from the same owner-bound ledger on every request,\n// so a grant lost here never blocks a payer, and it re-writes the missing\n// record. A decision (skip) answers 200 and is a decision.\n\nimport type { WebhookFunctionEnvelope } from '@vxil/sdk';\n\n/** the `payments.charge.succeeded` event payload (the fields read here) */\ninterface ChargeSucceeded {\n end_user_id?: string | null;\n provider_charge_id?: string | null;\n amount_cents?: number | null;\n currency?: string | null;\n product_id?: string | null;\n}\ntype Env = WebhookFunctionEnvelope<ChargeSucceeded>;\ninterface Charge { charge_id: string; amount_cents: number; currency: string; status: string }\ninterface ChargeList { data?: { charges?: Charge[] } }\ninterface Created { data?: { item_id?: string } }\ninterface ErrBody { error?: { code?: string } }\n\nconst PASS_DAYS = 365; // mirrored in start-brief.ts (the ledger gate)\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const pay = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n if (!pay || !cms) return fail(403, 'missing_scopes', 'the function needs payments:read and cms:write');\n // The binding's `source` already filters deliveries; this stays as belt-and-braces.\n if (env.payload?.event !== 'payments.charge.succeeded') {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n const raw = env.payload.data;\n // Past 64 KiB the whole `data` value is replaced by { truncated: true } \u2014\n // never a half object. A charge payload is tiny, but the check is free.\n if (raw && 'truncated' in raw) return Response.json({ skipped: true, reason: 'payload truncated' });\n const data: ChargeSucceeded = raw ?? {};\n const userId = data.end_user_id ?? null;\n const providerChargeId = data.provider_charge_id ?? null;\n if (!userId || !providerChargeId || data.amount_cents == null) {\n return Response.json({ skipped: true, reason: 'charge carries no user, id or amount' });\n }\n\n // 1. Re-read the LEDGER (the authoritative record) \u2014 a succeeded charge of\n // this amount must exist for this user. This is what turns the event\n // name from a claim into a fact. (One product, one pass per purchase:\n // the match is by amount; the list is newest-first.)\n const led = await fetch(\n `${base}/v1/payments/charges?user_id=${encodeURIComponent(userId)}&status=succeeded&limit=50`,\n { headers: H(pay) },\n ).catch(() => new Response(null, { status: 599 }));\n if (led.status >= 500) return unsettled(`ledger read ${led.status}`);\n if (!led.ok) return Response.json({ skipped: true, reason: `ledger read ${led.status}` });\n const charges = ((await led.json()) as ChargeList).data?.charges ?? [];\n const match = charges.find((c) => c.status === 'succeeded' && c.amount_cents === data.amount_cents\n && (!data.currency || c.currency === data.currency));\n if (!match) return Response.json({ skipped: true, reason: 'no succeeded charge of that amount on the ledger' });\n\n // 2. Write the pass RECORD. Server mode, so `user` is set explicitly (in\n // end-user mode the platform would stamp it). The ledger's `charge_id` is\n // the unique anchor \u2014 start-brief's record write keys on the same id.\n const now = Date.now();\n const created = await fetch(`${base}/v1/cms/items/passes`, {\n method: 'POST',\n headers: H(cms),\n body: JSON.stringify({\n data: {\n user: userId,\n charge_id: match.charge_id,\n product: data.product_id ?? 'studio-pass',\n amount_cents: data.amount_cents,\n granted_at: new Date(now).toISOString(),\n expires_at: new Date(now + PASS_DAYS * 86_400_000).toISOString(),\n },\n }),\n }).catch(() => new Response(null, { status: 599 }));\n if (created.status === 409) {\n const code = ((await created.json().catch(() => ({}))) as ErrBody).error?.code;\n // the redelivery case \u2014 the pass is already there\n return Response.json({ granted: false, reason: code ?? 'already granted', charge_id: match.charge_id });\n }\n if (created.status >= 500) return unsettled(`pass write ${created.status}`);\n if (!created.ok) return Response.json({ skipped: true, reason: `pass write ${created.status}` });\n const itemId = ((await created.json()) as Created).data?.item_id;\n return Response.json({\n granted: true, pass: itemId, user: userId, charge_id: match.charge_id, provider_charge_id: providerChargeId,\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** The charge succeeded and no pass was written. Answer 500 so the run is SEEN\n * (`functions.run.failed` \u2192 the alert digest); it is ACKed, never re-delivered\n * \u2014 start-brief decides from the ledger (the payer is never blocked) and\n * re-writes the missing record on the next request. */\nconst unsettled = (reason: string) =>\n fail(500, 'grant_unsettled', `${reason} \u2014 no pass written; surfaced via functions.run.failed (not re-delivered), repaired by start-brief from the ledger`);\n",
|
|
16698
17164
|
"settle-brief.ts": "// settle-brief.ts \u2014 SETTLE THE BRIEF ONTO ITS ROW (a vxil function, \xA77.3).\n//\n// Trigger: webhook, source 'job.generation.' \u2014 the PREFIX, so this function\n// wakes for both `job.generation.completed` and `job.generation.failed`. The\n// envelope's `payload.data` is the event payload:\n// { run_id, generation_id, correlation_id, status, error_class, error_hint, state, level }\n//\n// `correlation_id` is the funnel_sessions row id that start-brief sent with\n// the generate call \u2014 the platform echoes it on the event next to\n// generation_id, so this function needs no run\u2192record table of its own.\n//\n// On COMPLETED: the settled text lives in the generation's replay buffer\n// (GET /v1/ai/generations/{id}/stream?since=0 \u2014 every recorded frame, plus\n// `done`); the token frames are joined and PATCHed onto the row, then the\n// visitor gets a \"ready\" message. On FAILED: `error_hint` carries the\n// provider's own status and message (`Upstream said: \u2026`) and `error_class`\n// the class \u2014 the row gets the hint, so the visitor (and your support inbox)\n// sees WHY, not just that it failed. On completed both are null by contract.\n//\n// THE DELIVERY CONTRACT (guide 08, \"What is retried, and what is not\"): on\n// every platform-delivered trigger the dispatcher ACKs the delivery with a 200\n// WHATEVER this handler returns. A 5xx from here is never re-delivered \u2014 it is\n// OBSERVED (`functions.run.failed`, once per failure streak, digested by\n// `webhooks.alerts`). Only a crash of the dispatch itself is retried. So this\n// function never answers a 5xx hoping for a redelivery; it makes every failure\n// RECOVERABLE THROUGH THE ROW instead:\n// \u2022 a settle that cannot complete marks the row `result_status: 'failed'`\n// with `error_hint: 'settle failed: <reason>'` \u2014 start-brief accepts that\n// row again, so the visitor's retry is one click;\n// \u2022 only when not even that mark could be written does it answer 500 \u2014 not\n// for a retry (there is none) but so the failure is SEEN in the alert\n// digest instead of vanishing behind a 200.\n// The one transient it waits out ITSELF is the replay buffer: `done: false`\n// means the terminal frame is a moment behind the event, so the read is\n// repeated a few times before giving up (the guide's \"retry internally\").\n//\n// Every write carries `if: { generation_id }` \u2014 the row is touched only while\n// it is still waiting on THIS generation, so a late delivery for an earlier\n// attempt can never overwrite a newer one (409 precondition_failed = stale).\n// At-least-once: the same PATCH twice is the same row, and the \"ready\" send\n// carries the delivery's idempotency key, so a redelivery never double-mails.\n\nimport type { JobGenerationSettledEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = WebhookFunctionEnvelope<JobGenerationSettledEventPayload>;\ninterface SessionData { user?: string; generation_id?: string | null; result_status?: string }\ninterface Item { data?: { data?: SessionData } }\ninterface Replay { data?: { frames?: Array<{ type?: string; delta?: string; done?: boolean }>; done?: boolean } }\n\nconst REPLAY_TRIES = 4; // reads of the replay buffer before giving up \u2026\nconst REPLAY_WAIT_MS = 500; // \u2026 and the pause between them (wall time, not CPU)\nconst ROW_TRIES = 3; // re-reads of a row still inside start-brief's claim\u2192generate\u2192record window\nconst ROW_WAIT_MS = 700;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const notif = env.scoped_jwts?.notifications;\n if (!cms || !ai) return fail(403, 'missing_scopes', 'the function needs cms:read, cms:write and ai:read');\n\n const event = env.payload?.event ?? '';\n if (event !== 'job.generation.completed' && event !== 'job.generation.failed') {\n return Response.json({ skipped: true, event }); // e.g. job.generation.processing\n }\n // a truncated payload ({ truncated: true }) carries nothing to settle from\n const raw = env.payload?.data;\n const data: Partial<JobGenerationSettledEventPayload> = raw && !('truncated' in raw) ? raw : {};\n const sessionId = data.correlation_id ?? null;\n const generationId = data.generation_id ?? null;\n if (!sessionId || !generationId) {\n return Response.json({ skipped: true, reason: 'not a funnel generation (no correlation_id)' });\n }\n const row = { base, cms, sessionId, generationId };\n\n // Find our row through the correlation id (server mode: any row). It must\n // be waiting on THIS generation: another id is a stale delivery (an earlier\n // attempt settling late) \u2014 unless the row is `queued` with no handle yet,\n // which is start-brief's claim\u2192generate\u2192record window: the handle is one\n // PATCH away, so the read is repeated before the delivery is called stale.\n let session: SessionData | null = null;\n let readStatus = 0;\n for (let i = 0; i < ROW_TRIES; i++) {\n if (i > 0) await sleep(ROW_WAIT_MS);\n const res = await fetch(`${base}/v1/cms/items/funnel_sessions/${encodeURIComponent(sessionId)}`, { headers: H(cms) });\n readStatus = res.status;\n if (res.status === 404) return Response.json({ skipped: true, reason: 'no such session' });\n if (!res.ok) continue; // a 5xx read: try again, then fall through to the blind mark below\n const s = ((await res.json()) as Item).data?.data ?? {};\n if (s.generation_id === generationId) { session = s; break; }\n if (s.result_status === 'queued' && !s.generation_id) continue; // claimed, handle not recorded yet\n return Response.json({ skipped: true, reason: 'stale generation', expected: s.generation_id ?? null });\n }\n if (!session) {\n if (readStatus >= 500) {\n // The row could not be read. Mark it failed anyway \u2014 the `if`\n // precondition lands the mark only if the row is still waiting on this\n // generation \u2014 and let the visitor retry.\n return await settleFailed(row, `session read ${readStatus}`);\n }\n return Response.json({ skipped: true, reason: 'stale generation (no handle recorded for it)' });\n }\n\n if (event === 'job.generation.failed') {\n // The provider's own words, bounded (the event caps the hint at 200 chars).\n const hint = data.error_hint ?? data.error_class ?? 'generation failed';\n const patch = await patchSession(row, { result_status: 'failed', error_hint: hint });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n if (!patch.ok) return unsettled(`session patch ${patch.status}`);\n return Response.json({ settled: 'failed', session: sessionId, error_class: data.error_class ?? null, error_hint: hint });\n }\n\n // COMPLETED \u2014 the text is in the replay buffer. `done: false` means the\n // buffer has not received its terminal frame yet \u2014 a moment behind the\n // event \u2014 so the read is repeated a few times before the brief is given\n // up (never settled empty; never a 5xx-and-hope: nothing re-delivers).\n let replay: NonNullable<Replay['data']> | null = null;\n let replayStatus = 0;\n for (let i = 0; i < REPLAY_TRIES; i++) {\n if (i > 0) await sleep(REPLAY_WAIT_MS);\n const rep = await fetch(`${base}/v1/ai/generations/${encodeURIComponent(generationId)}/stream?since=0`, { headers: H(ai) });\n replayStatus = rep.status;\n if (rep.status >= 400 && rep.status < 500) break; // not transient \u2014 a 404 generation will not appear\n if (!rep.ok) continue;\n const d = ((await rep.json()) as Replay).data ?? {};\n if (d.done) { replay = d; break; }\n }\n if (!replay) {\n return await settleFailed(row, replayStatus >= 400 ? `replay read ${replayStatus}` : 'replay buffer never reported done');\n }\n const text = (replay.frames ?? []).filter((f) => f.type === 'token').map((f) => f.delta ?? '').join('');\n\n const patch = await patchSession(row, { result_status: 'completed', result: text, error_hint: null });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n if (!patch.ok) return await settleFailed(row, `session patch ${patch.status}`);\n\n // Tell the visitor. Best-effort AFTER the result is on the row: a mail that\n // fails must not un-settle a brief that is ready. The delivery's\n // idempotency key rides the send, so a redelivered event never sends twice\n // (notifications honors Idempotency-Key).\n let delivery: number | null = null;\n if (notif && session.user) {\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { ...H(notif), ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}) },\n body: JSON.stringify({\n user_id: session.user,\n template: 'transactional',\n data: { subject: 'Your brief is ready', paragraph: 'Open the studio to read your creative brief.' },\n }),\n }).catch(() => null);\n delivery = send?.status ?? null;\n }\n return Response.json({ settled: 'completed', session: sessionId, chars: text.length, delivery });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\ninterface Row { base: string; cms: string; sessionId: string; generationId: string }\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** Not even the row could record the failure: answer 500 so the run is SEEN\n * (`functions.run.failed` \u2192 the alert digest). ACKed, never re-delivered. */\nconst unsettled = (reason: string) =>\n fail(500, 'unsettled', `${reason} \u2014 the row could not be marked failed; surfaced via functions.run.failed, not re-delivered`);\n/** Every write is guarded by `if: { generation_id }`: the row is touched only\n * while it still waits on THIS generation (409 precondition_failed = stale). */\nfunction patchSession(row: Row, data: Record<string, unknown>): Promise<Response> {\n return fetch(`${row.base}/v1/cms/items/funnel_sessions/${encodeURIComponent(row.sessionId)}`, {\n method: 'PATCH', headers: H(row.cms), body: JSON.stringify({ data, if: { generation_id: row.generationId } }),\n }).catch(() => new Response(null, { status: 599 }));\n}\n/** The recoverable outcome: mark the row failed with the reason, so start-brief\n * accepts it again and the visitor's retry is one click. */\nasync function settleFailed(row: Row, reason: string): Promise<Response> {\n const patch = await patchSession(row, { result_status: 'failed', error_hint: `settle failed: ${reason}`.slice(0, 200) });\n if (patch.ok) return Response.json({ settled: 'failed', session: row.sessionId, reason });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n return unsettled(`${reason}; mark failed \u2192 ${patch.status}`);\n}\n",
|
|
@@ -18328,14 +18794,6 @@ function positional(skip = 0) {
|
|
|
18328
18794
|
}
|
|
18329
18795
|
return out.slice(skip);
|
|
18330
18796
|
}
|
|
18331
|
-
function printEnvelope({ status, body }) {
|
|
18332
|
-
if (body.error) {
|
|
18333
|
-
console.error(`error ${status}: ${body.error.code} \u2014 ${body.error.message}`);
|
|
18334
|
-
if (body.error.hint) console.error(`hint: ${body.error.hint}`);
|
|
18335
|
-
process.exit(1);
|
|
18336
|
-
}
|
|
18337
|
-
console.log(JSON.stringify(body.data, null, 2));
|
|
18338
|
-
}
|
|
18339
18797
|
async function prompt(q, opts = {}) {
|
|
18340
18798
|
const stdin = process.stdin;
|
|
18341
18799
|
if (opts.hidden && stdin.isTTY && stdin.setRawMode) {
|
|
@@ -18397,25 +18855,21 @@ async function dashFetch(base2, method, path, body, cookie) {
|
|
|
18397
18855
|
function makeDash(base2) {
|
|
18398
18856
|
return (method, path, body, cookie) => dashFetch(base2, method, path, body, cookie);
|
|
18399
18857
|
}
|
|
18858
|
+
function linkRequest(slug, tenantId, base2) {
|
|
18859
|
+
const as = flag("as");
|
|
18860
|
+
return { slug, tenantId, edgeUrl: base2, env: flag("env") ?? envLabelFor(base2), ...as !== void 0 ? { as } : {} };
|
|
18861
|
+
}
|
|
18400
18862
|
function persistLink(slug, tenantId, apiKey, base2) {
|
|
18863
|
+
const req = linkRequest(slug, tenantId, base2);
|
|
18864
|
+
const plan = planLinkBinding(loadProject(), req);
|
|
18865
|
+
if ("error" in plan) fail(plan.error);
|
|
18866
|
+
const envWarn = envLabelWarning(req.env, base2);
|
|
18867
|
+
if (envWarn) console.error(`vxil: ${envWarn}`);
|
|
18401
18868
|
const creds = loadCredentials();
|
|
18402
18869
|
creds.keys = { ...creds.keys, [slug]: apiKey };
|
|
18403
18870
|
saveCredentials(creds);
|
|
18404
|
-
|
|
18405
|
-
|
|
18406
|
-
const envWarn = envLabelWarning(env, base2);
|
|
18407
|
-
if (envWarn) console.error(`vxil: ${envWarn}`);
|
|
18408
|
-
const existing = loadProject();
|
|
18409
|
-
if (as !== void 0) {
|
|
18410
|
-
if (!TARGET_NAME_RE.test(as)) fail(`--as '${as}': a target name is 1\u201340 chars of [a-z0-9-], starting with a letter`);
|
|
18411
|
-
if (existing?.slug === slug) fail(`'${slug}' is already this repo's PRIMARY binding \u2014 a named slot must be a different tenant`);
|
|
18412
|
-
saveProject(bindNamedSlot(existing, as, { tenant_id: tenantId, slug, edge_url: base2, env }));
|
|
18413
|
-
return as === "dev" ? "as the dev slot (--dev)" : `as target '${as}' (--target ${as})`;
|
|
18414
|
-
}
|
|
18415
|
-
const merged = mergePrimaryBinding(existing, { tenant_id: tenantId, slug, edge_url: base2, env });
|
|
18416
|
-
saveProject(merged);
|
|
18417
|
-
const kept = bindingSlots(merged).slice(1).map((s) => s.label);
|
|
18418
|
-
return kept.length ? `as the primary binding (kept: ${kept.join(", ")})` : "as the primary binding";
|
|
18871
|
+
saveProject(plan.binding);
|
|
18872
|
+
return plan.label;
|
|
18419
18873
|
}
|
|
18420
18874
|
function selector() {
|
|
18421
18875
|
return selectorFrom({ dev: hasFlag("dev"), target: flag("target") });
|
|
@@ -19468,6 +19922,12 @@ function listTemplates() {
|
|
|
19468
19922
|
}
|
|
19469
19923
|
try {
|
|
19470
19924
|
switch (cmd) {
|
|
19925
|
+
case "--version":
|
|
19926
|
+
case "-v":
|
|
19927
|
+
case "version": {
|
|
19928
|
+
console.log(VXIL_CLI_PKG_VERSION);
|
|
19929
|
+
break;
|
|
19930
|
+
}
|
|
19471
19931
|
case "init": {
|
|
19472
19932
|
initProject();
|
|
19473
19933
|
break;
|
|
@@ -19516,6 +19976,8 @@ try {
|
|
|
19516
19976
|
const slot = { tenant_id: d.tenant_id, slug: d.app_slug, edge_url: d.edge_url ?? base2, env: flag("env") ?? envLabelFor(base2) };
|
|
19517
19977
|
const merged = mergePrimaryBinding(loadProject(), slot);
|
|
19518
19978
|
saveProject(hasFlag("dev") ? { ...merged, dev: { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url } } : merged);
|
|
19979
|
+
const envNotice = quickstartEnvNotice(flag("env"), base2, d.app_slug, { dev: hasFlag("dev") });
|
|
19980
|
+
if (envNotice) console.error(`vxil: ${envNotice}`);
|
|
19519
19981
|
if (!existsSync8(resolve7(process.cwd(), "vxil.config.ts"))) initProject();
|
|
19520
19982
|
console.log(`
|
|
19521
19983
|
\u2713 tenant '${d.app_slug}' created \xB7 enabled: ${(d.enabled ?? []).join(", ")}`);
|
|
@@ -19620,9 +20082,12 @@ try {
|
|
|
19620
20082
|
const base2 = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
|
|
19621
20083
|
const directKey = flag("key");
|
|
19622
20084
|
if (directKey) {
|
|
19623
|
-
const
|
|
19624
|
-
|
|
19625
|
-
|
|
20085
|
+
const r = await linkViaStoredKey(makeApi({ apiKey: directKey, baseUrl: base2 }), slug, directKey);
|
|
20086
|
+
if ("invalid" in r) fail(`the --key was refused (${r.invalid}) \u2014 nothing was linked`);
|
|
20087
|
+
const rec = reconcileLinkTenant(flag("tenant"), r.result.tenant_id);
|
|
20088
|
+
if ("error" in rec) fail(rec.error);
|
|
20089
|
+
const bound = persistLink(slug, rec.tenantId, directKey, base2);
|
|
20090
|
+
console.log(`\u2713 linked '${slug}' ${bound} (using provided --key \xB7 tenant ${rec.tenantId})`);
|
|
19626
20091
|
break;
|
|
19627
20092
|
}
|
|
19628
20093
|
const creds = loadCredentials();
|
|
@@ -19630,13 +20095,19 @@ try {
|
|
|
19630
20095
|
if (stored && !hasFlag("mint")) {
|
|
19631
20096
|
const r = await linkViaStoredKey(makeApi({ apiKey: stored, baseUrl: base2 }), slug, stored);
|
|
19632
20097
|
if ("result" in r) {
|
|
19633
|
-
const
|
|
20098
|
+
const rec = reconcileLinkTenant(flag("tenant"), r.result.tenant_id);
|
|
20099
|
+
if ("error" in rec) fail(rec.error);
|
|
20100
|
+
const tenantId = rec.tenantId;
|
|
19634
20101
|
const bound = persistLink(slug, tenantId, stored, base2);
|
|
19635
20102
|
console.log(`\u2713 linked '${slug}' ${bound} (using the key already stored for this slug in ~/.vxil/credentials.json \u2014 pass --mint for a new one)`);
|
|
19636
20103
|
break;
|
|
19637
20104
|
}
|
|
19638
20105
|
console.error(`stored key for '${slug}' was refused (${r.invalid}) \u2014 minting a new one\u2026`);
|
|
19639
20106
|
}
|
|
20107
|
+
{
|
|
20108
|
+
const pre = planLinkBinding(loadProject(), linkRequest(slug, "(unknown)", base2));
|
|
20109
|
+
if ("error" in pre) fail(pre.error);
|
|
20110
|
+
}
|
|
19640
20111
|
const linkDashBase = dashboardBase(base2);
|
|
19641
20112
|
const dash = makeDash(linkDashBase);
|
|
19642
20113
|
const keyName = `cli-${hostname()}`.slice(0, 100);
|
|
@@ -20558,30 +21029,25 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
20558
21029
|
}
|
|
20559
21030
|
}
|
|
20560
21031
|
} else if (sub === "invoke") {
|
|
20561
|
-
|
|
20562
|
-
|
|
20563
|
-
|
|
20564
|
-
|
|
20565
|
-
|
|
20566
|
-
try {
|
|
20567
|
-
payload = JSON.parse(raw);
|
|
20568
|
-
} catch {
|
|
20569
|
-
fail("--data must be valid JSON");
|
|
20570
|
-
}
|
|
21032
|
+
let parsed;
|
|
21033
|
+
try {
|
|
21034
|
+
parsed = parseInvokeArgs(rest);
|
|
21035
|
+
} catch (e) {
|
|
21036
|
+
fail(e.message);
|
|
20571
21037
|
}
|
|
20572
|
-
|
|
21038
|
+
const { name: fnName, body: payload } = parsed;
|
|
21039
|
+
const { api, baseUrl, target } = requireApi(selector(), { write: `functions invoke ${fnName}` });
|
|
21040
|
+
if (parsed.async) {
|
|
20573
21041
|
const tenantId = target.tenantId && target.tenantId !== "(unknown)" ? target.tenantId : void 0;
|
|
20574
21042
|
if (!tenantId) fail("`--async` needs a linked tenant (run `vxil link <slug>` or `vxil quickstart`)");
|
|
20575
21043
|
printEnvelope(await api("POST", "/v1/jobs/enqueue", {
|
|
20576
|
-
job_name: `fn-queue:${
|
|
20577
|
-
target_url: `${baseUrl.replace(/\/$/, "")}/v1/internal/fn/trigger/${encodeURIComponent(
|
|
21044
|
+
job_name: `fn-queue:${fnName}`,
|
|
21045
|
+
target_url: `${baseUrl.replace(/\/$/, "")}/v1/internal/fn/trigger/${encodeURIComponent(fnName)}?tenant=${tenantId}`,
|
|
20578
21046
|
payload: { trigger: "queue", payload },
|
|
20579
|
-
idempotency_key: `fnq:${
|
|
21047
|
+
idempotency_key: `fnq:${fnName}:${Date.now()}`
|
|
20580
21048
|
}));
|
|
20581
21049
|
} else {
|
|
20582
|
-
|
|
20583
|
-
if (res.body.error) printEnvelope(res);
|
|
20584
|
-
else console.log(JSON.stringify(res.body.data ?? res.body, null, 2));
|
|
21050
|
+
emitOutput(formatFnAnswer(fnName, await api("POST", `/v1/fn/${encodeURIComponent(fnName)}`, payload)));
|
|
20585
21051
|
}
|
|
20586
21052
|
} else if (sub === "enable") {
|
|
20587
21053
|
if (!name) fail("usage: vxil functions enable <name>");
|
|
@@ -20622,7 +21088,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
20622
21088
|
else console.log(`\u2713 disabled '${name}' (functions config v${r.version ?? "?"})`);
|
|
20623
21089
|
for (const l of disableNotices(name, declared, r.version)) console.error(l);
|
|
20624
21090
|
} else if (sub === "dev") {
|
|
20625
|
-
if (!name) fail("usage: vxil functions dev <name> [--port <n>]");
|
|
21091
|
+
if (!name) fail("usage: vxil functions dev <name> [--port <n>] [--trigger <http|webhook|cms-hook|auth-hook|queue|cron>] \xB7 vxil functions dev <name> --event <file.json> [--trigger <lane>] [--run-id <id>] (no dev slot + a production primary: a non-http lane or --event asks for the slug, or --yes)");
|
|
20626
21092
|
await runFunctionsDev(name);
|
|
20627
21093
|
} else {
|
|
20628
21094
|
fail("usage: vxil functions new|deploy|list|delete|enable|disable|invoke|logs|dev <name>");
|
|
@@ -20816,18 +21282,18 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
20816
21282
|
}
|
|
20817
21283
|
let parsed;
|
|
20818
21284
|
try {
|
|
20819
|
-
parsed =
|
|
21285
|
+
parsed = parseApiArgv(rest);
|
|
20820
21286
|
} catch (e) {
|
|
20821
21287
|
fail(e.message);
|
|
20822
21288
|
}
|
|
20823
21289
|
const { method, path, body } = parsed;
|
|
20824
21290
|
const { api } = requireApi(selector(), method === "GET" ? {} : { write: `api ${method} ${path}` });
|
|
20825
|
-
|
|
21291
|
+
emitOutput(formatEnvelope(await api(method, path, body), { rawText: true }));
|
|
20826
21292
|
break;
|
|
20827
21293
|
}
|
|
20828
21294
|
default:
|
|
20829
21295
|
console.log(`vxil \u2014 a Supabase-grade code-first CLI for the whole platform
|
|
20830
|
-
init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--features a,b] [--invite <code>] [--env <label>] [--dev [--ttl <h>]] [--no-push] \xB7 login \xB7 link <slug> [--key <k>] [--as dev|<name>] [--env <label>] [--mint]
|
|
21296
|
+
--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]
|
|
20831
21297
|
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)
|
|
20832
21298
|
keys perms <key_id> [--allow <a,b> | --clear-allow] [--deny <c> | --clear-deny] \u2014 a key's MCP tool permissions (admin+)
|
|
20833
21299
|
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>
|
|
@@ -20847,7 +21313,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
20847
21313
|
migrate payments --from-provider stripe|paddle|paypal|revenuecat (--customers <f.csv> | --all) [--dry-run] \u2014 backfill subscriptions via the server sync leg (resumable)
|
|
20848
21314
|
payments simulate --scenario refund-pair|cross-platform-unlock|renewal|expiry|past-due-grace|transfer --user <id> [--tier <t>] \u2014 mock/dev projects only
|
|
20849
21315
|
env pull [--dev] [--file <f>] [--print] [--no-gitignore]
|
|
20850
|
-
functions new|deploy|list|delete|enable|disable [--yes]|invoke [--async]|logs [--tail]|dev <name> [--port <n>] \xB7 cms status|pull|reindex <collection>|drop <collection> [--yes]|bulk-rm <collection> --filter '<json>' [--dry-run] [--yes] \xB7 dev up [--ttl <h>] [--new]|down [--yes]|seed|reset
|
|
21316
|
+
functions new|deploy|list|delete|enable|disable [--yes]|invoke ['<json>' | --data '<json>'] [--async]|logs [--tail]|dev <name> [--port <n>] [--trigger <lane>] [--event <file.json>] [--yes] \xB7 cms status|pull|reindex <collection>|drop <collection> [--yes]|bulk-rm <collection> --filter '<json>' [--dry-run] [--yes] \xB7 dev up [--ttl <h>] [--new]|down [--yes]|seed|reset
|
|
20851
21317
|
files put <path> --user <id> [--content-type <t>] \xB7 files rm <object_id> [--yes] \xB7 webhooks sources add|list|rm|test \xB7 webhooks events replay|runs <event_id> \xB7 search sync run|reset <sourceKey> [--sweep|--purge] \xB7 users import <file.json|.csv>
|
|
20852
21318
|
api --help \u2014 the operator cheat-sheet: every dashboard action as one 'vxil api' line, grouped by feature
|
|
20853
21319
|
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
|
|
@@ -21386,6 +21852,37 @@ async function runFunctionsDev(name) {
|
|
|
21386
21852
|
const cfg = await loadVxilConfig();
|
|
21387
21853
|
const def = cfg.functions?.[name];
|
|
21388
21854
|
if (!def) fail(`function '${name}' not declared in vxil.config`);
|
|
21855
|
+
let bindings;
|
|
21856
|
+
try {
|
|
21857
|
+
bindings = lowerAllTriggerBindings(def, name);
|
|
21858
|
+
} catch (e) {
|
|
21859
|
+
fail(e.message);
|
|
21860
|
+
}
|
|
21861
|
+
const eventFile = flag("event");
|
|
21862
|
+
const laneFlag = flag("trigger");
|
|
21863
|
+
let lane = "http";
|
|
21864
|
+
let oneShotInput;
|
|
21865
|
+
if (eventFile !== void 0) {
|
|
21866
|
+
let json2;
|
|
21867
|
+
try {
|
|
21868
|
+
json2 = JSON.parse(readFileSync8(resolve7(process.cwd(), eventFile), "utf8"));
|
|
21869
|
+
} catch (e) {
|
|
21870
|
+
fail(`--event ${eventFile}: ${e.message.split("\n")[0]}`);
|
|
21871
|
+
}
|
|
21872
|
+
const parsedEvent = parseEventFile(json2, laneFlag);
|
|
21873
|
+
if ("error" in parsedEvent) fail(parsedEvent.error);
|
|
21874
|
+
lane = parsedEvent.lane;
|
|
21875
|
+
oneShotInput = { input: parsedEvent.input };
|
|
21876
|
+
} else if (laneFlag !== void 0) {
|
|
21877
|
+
const l = parseLane(laneFlag);
|
|
21878
|
+
if (!l) fail(`--trigger '${laneFlag}' is not a lane \u2014 use one of ${LOCAL_LANES.join(" | ")}`);
|
|
21879
|
+
lane = l;
|
|
21880
|
+
}
|
|
21881
|
+
const laneRefused = laneRefusal(lane, bindings, name);
|
|
21882
|
+
if (laneRefused) fail(laneRefused);
|
|
21883
|
+
const oneShot = oneShotInput ? planLocalDelivery({ lane, input: oneShotInput.input, bindings, fnName: name }) : void 0;
|
|
21884
|
+
if (oneShot && !oneShot.ok) fail(oneShot.refusal);
|
|
21885
|
+
const timeoutMs = normalizeFnLimits(def.limits)?.timeoutMs;
|
|
21389
21886
|
const explicit = hasFlag("dev") || flag("target") !== void 0;
|
|
21390
21887
|
const sel = explicit ? selector() : loadProject()?.dev ? { kind: "dev" } : { kind: "primary" };
|
|
21391
21888
|
const t = resolveTargetOrFail(sel);
|
|
@@ -21394,6 +21891,22 @@ async function runFunctionsDev(name) {
|
|
|
21394
21891
|
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>`);
|
|
21395
21892
|
}
|
|
21396
21893
|
announceTarget(t, `functions dev ${name}`);
|
|
21894
|
+
const prodGuard = productionLaneGuard({
|
|
21895
|
+
lane,
|
|
21896
|
+
oneShot: oneShot !== void 0,
|
|
21897
|
+
explicitSelector: explicit,
|
|
21898
|
+
production: isProductionTarget(t),
|
|
21899
|
+
tenant: t.tenantSlug ?? "(env key)",
|
|
21900
|
+
fnName: name
|
|
21901
|
+
});
|
|
21902
|
+
if (prodGuard) {
|
|
21903
|
+
await confirmTyped(
|
|
21904
|
+
prodGuard,
|
|
21905
|
+
`vxil: ${prodGuard}.
|
|
21906
|
+
Type ${t.tenantSlug ? `the tenant slug '${t.tenantSlug}'` : "'yes'"} to run it anyway: `,
|
|
21907
|
+
t.tenantSlug ?? "yes"
|
|
21908
|
+
);
|
|
21909
|
+
}
|
|
21397
21910
|
const tenantId = t.tenantId && t.tenantId !== "(unknown)" ? t.tenantId : "dev-tenant";
|
|
21398
21911
|
let Miniflare;
|
|
21399
21912
|
try {
|
|
@@ -21406,27 +21919,79 @@ async function runFunctionsDev(name) {
|
|
|
21406
21919
|
const entry = resolve7(cwd, def.entry);
|
|
21407
21920
|
const scopes = def.scopes ?? [];
|
|
21408
21921
|
const egressAllow = def.egressAllow ?? [];
|
|
21922
|
+
const note = (line) => {
|
|
21923
|
+
if (oneShot) console.error(line);
|
|
21924
|
+
else console.log(line);
|
|
21925
|
+
};
|
|
21409
21926
|
const outbound = makeDevOutbound({
|
|
21410
21927
|
fnName: name,
|
|
21411
21928
|
scopes,
|
|
21412
21929
|
egressAllow,
|
|
21413
21930
|
edgeUrl: t.baseUrl,
|
|
21414
21931
|
apiKey: t.apiKey,
|
|
21415
|
-
log: (line) =>
|
|
21932
|
+
log: (line) => note(` ${line}`)
|
|
21416
21933
|
});
|
|
21417
21934
|
const mfOptions = (source) => ({
|
|
21418
21935
|
modules: [{ type: "ESModule", path: "fn.mjs", contents: source }],
|
|
21419
|
-
|
|
21420
|
-
|
|
21936
|
+
// the deploy pipeline's runtime settings (@vxil/types FUNCTIONS_RUNTIME)
|
|
21937
|
+
compatibilityDate: FUNCTIONS_RUNTIME.compatibilityDate,
|
|
21938
|
+
compatibilityFlags: [...FUNCTIONS_RUNTIME.compatibilityFlags],
|
|
21421
21939
|
outboundService: (req) => outbound(req)
|
|
21422
21940
|
});
|
|
21423
21941
|
const first = await bundleFunction(entry, { projectRoot: cwd, minify: false });
|
|
21424
21942
|
const mf = new Miniflare(mfOptions(first.source));
|
|
21425
21943
|
const { secrets, missing } = resolveDevSecrets(def.secrets, process.env);
|
|
21426
21944
|
for (const m of missing) {
|
|
21427
|
-
|
|
21945
|
+
note(` ! secret '${m}' not resolved locally \u2014 export VXIL_FN_SECRET_${m.toUpperCase()}=\u2026 (deployed invokes read tenant_secrets)`);
|
|
21946
|
+
}
|
|
21947
|
+
const realTenantLine = ` writes: every feature call lands on '${t.tenantSlug ?? "(env key)"}' for REAL (its data, its provider accounts) \u2014 a money-lane function grants, refunds or mails for real; run it against a dev tenant (\`vxil dev up\`)`;
|
|
21948
|
+
const dispatch = async (envelope) => {
|
|
21949
|
+
const out = await mf.dispatchFetch("https://fn.vxil.internal/invoke", {
|
|
21950
|
+
method: "POST",
|
|
21951
|
+
headers: { "content-type": "application/json" },
|
|
21952
|
+
body: JSON.stringify(envelope)
|
|
21953
|
+
});
|
|
21954
|
+
return { status: out.status, text: await out.text(), contentType: out.headers.get("content-type") };
|
|
21955
|
+
};
|
|
21956
|
+
if (oneShot?.ok) {
|
|
21957
|
+
const runId = flag("run-id") ?? `dev-${Date.now().toString(36)}`;
|
|
21958
|
+
const envelope = buildEnvelope({
|
|
21959
|
+
fnName: name,
|
|
21960
|
+
scopes,
|
|
21961
|
+
payload: oneShot.payload,
|
|
21962
|
+
tenantId,
|
|
21963
|
+
requestId: runId,
|
|
21964
|
+
trigger: lane,
|
|
21965
|
+
runId,
|
|
21966
|
+
secrets
|
|
21967
|
+
});
|
|
21968
|
+
console.error(`vxil functions dev '${name}' \u2014 one ${lane} delivery \xB7 local isolate, remote edge`);
|
|
21969
|
+
console.error(` tenant: ${t.tenantSlug ?? "(env key)"} \xB7 edge ${t.baseUrl}`);
|
|
21970
|
+
console.error(realTenantLine);
|
|
21971
|
+
console.error(` idempotency_key: ${envelope.idempotency_key}${lane === "http" ? "" : " (the same --run-id replays it as a redelivery)"}`);
|
|
21972
|
+
const t0 = Date.now();
|
|
21973
|
+
let answer;
|
|
21974
|
+
try {
|
|
21975
|
+
answer = await dispatch(envelope);
|
|
21976
|
+
} catch (e) {
|
|
21977
|
+
await mf.dispose();
|
|
21978
|
+
fail(`isolate error: ${String(e.message).split("\n")[0]}`);
|
|
21979
|
+
}
|
|
21980
|
+
await mf.dispose();
|
|
21981
|
+
console.error(` \u2192 ${answer.status} (${Date.now() - t0} ms)`);
|
|
21982
|
+
for (const l of deployedVerdict(lane, oneShot.binding, timeoutMs)) console.error(` ${l}`);
|
|
21983
|
+
let body = {};
|
|
21984
|
+
let raw;
|
|
21985
|
+
try {
|
|
21986
|
+
body = JSON.parse(answer.text);
|
|
21987
|
+
} catch {
|
|
21988
|
+
raw = answer.text;
|
|
21989
|
+
}
|
|
21990
|
+
emitOutput(formatFnAnswer(name, { status: answer.status, body, ...raw !== void 0 ? { raw } : {} }));
|
|
21991
|
+
process.exit(0);
|
|
21428
21992
|
}
|
|
21429
21993
|
const port = Number(flag("port") ?? 8788);
|
|
21994
|
+
const serverStamp = Date.now().toString(36);
|
|
21430
21995
|
let seq = 0;
|
|
21431
21996
|
const server = createServer((req, res) => {
|
|
21432
21997
|
const chunks = [];
|
|
@@ -21434,37 +21999,47 @@ async function runFunctionsDev(name) {
|
|
|
21434
21999
|
req.on("end", () => {
|
|
21435
22000
|
void (async () => {
|
|
21436
22001
|
seq += 1;
|
|
21437
|
-
const requestId = `dev-${seq}`;
|
|
21438
22002
|
const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
21439
|
-
let
|
|
22003
|
+
let input = Object.fromEntries(reqUrl.searchParams);
|
|
21440
22004
|
if ((req.method ?? "GET") !== "GET") {
|
|
21441
22005
|
try {
|
|
21442
|
-
|
|
22006
|
+
input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
21443
22007
|
} catch {
|
|
21444
|
-
|
|
22008
|
+
input = {};
|
|
21445
22009
|
}
|
|
21446
22010
|
}
|
|
22011
|
+
const runHeader = req.headers["x-vxil-run-id"];
|
|
22012
|
+
const requestId = lane !== "http" && typeof runHeader === "string" && runHeader ? runHeader : `dev-${serverStamp}-${seq}`;
|
|
22013
|
+
const label = lane === "http" ? `${req.method} ${reqUrl.pathname}` : lane;
|
|
22014
|
+
const d = planLocalDelivery({ lane, input, bindings, fnName: name });
|
|
22015
|
+
if (!d.ok) {
|
|
22016
|
+
console.error(` [${requestId}] ${label} refused \u2014 ${d.refusal}`);
|
|
22017
|
+
res.writeHead(422, { "content-type": "application/json" });
|
|
22018
|
+
res.end(JSON.stringify({ error: { code: "lane_refused", message: d.refusal } }));
|
|
22019
|
+
return;
|
|
22020
|
+
}
|
|
21447
22021
|
const idem = req.headers["idempotency-key"];
|
|
21448
22022
|
const envelope = buildEnvelope({
|
|
21449
22023
|
fnName: name,
|
|
21450
22024
|
scopes,
|
|
21451
|
-
payload,
|
|
22025
|
+
payload: d.payload,
|
|
21452
22026
|
tenantId,
|
|
21453
22027
|
requestId,
|
|
22028
|
+
trigger: lane,
|
|
22029
|
+
runId: requestId,
|
|
21454
22030
|
...typeof idem === "string" ? { idempotencyKey: idem } : {},
|
|
21455
22031
|
secrets
|
|
21456
22032
|
});
|
|
21457
22033
|
const t0 = Date.now();
|
|
21458
22034
|
try {
|
|
21459
|
-
const out = await
|
|
21460
|
-
|
|
21461
|
-
|
|
21462
|
-
|
|
21463
|
-
|
|
21464
|
-
|
|
21465
|
-
|
|
21466
|
-
res.
|
|
21467
|
-
res.end(text);
|
|
22035
|
+
const out = await dispatch(envelope);
|
|
22036
|
+
const event = lane === "webhook" ? ` ${String(d.payload.event)}` : "";
|
|
22037
|
+
console.log(` [${requestId}] ${label}${event} \u2192 ${out.status} (${Date.now() - t0} ms)`);
|
|
22038
|
+
if (out.status < 200 || out.status >= 300) {
|
|
22039
|
+
for (const l of deployedVerdict(lane, d.binding, timeoutMs)) console.log(` ${l}`);
|
|
22040
|
+
}
|
|
22041
|
+
res.writeHead(out.status, { "content-type": out.contentType ?? "application/json" });
|
|
22042
|
+
res.end(out.text);
|
|
21468
22043
|
} catch (e) {
|
|
21469
22044
|
console.error(` [${requestId}] isolate error: ${String(e.message).split("\n")[0]}`);
|
|
21470
22045
|
res.writeHead(502, { "content-type": "application/json" });
|
|
@@ -21490,10 +22065,22 @@ async function runFunctionsDev(name) {
|
|
|
21490
22065
|
});
|
|
21491
22066
|
server.listen(port, () => {
|
|
21492
22067
|
const auds = functionCallbackAudiences(scopes);
|
|
22068
|
+
const sample = {
|
|
22069
|
+
http: '{"name":"world"}',
|
|
22070
|
+
webhook: '{"event":"payments.charge.succeeded","data":{}}',
|
|
22071
|
+
"cms-hook": '{"event":"cms.item.created","collection":"<slug>","item_id":"<id>"}',
|
|
22072
|
+
"auth-hook": '{"event":"auth.user.created","user_id":"<id>"}',
|
|
22073
|
+
queue: '{"your":"payload"}',
|
|
22074
|
+
cron: "{}"
|
|
22075
|
+
};
|
|
21493
22076
|
console.log(`vxil functions dev '${name}' \u2014 local isolate, remote edge (design \xA75.1-C)`);
|
|
21494
22077
|
console.log(` tenant: ${t.tenantSlug ?? "(env key)"} \xB7 edge ${t.baseUrl}`);
|
|
22078
|
+
console.log(realTenantLine);
|
|
21495
22079
|
console.log(` bundle: ${def.entry} (${Math.round(first.bytes / 100) / 10} KB) \u2014 rebuilds on save`);
|
|
21496
|
-
|
|
22080
|
+
if (lane !== "http") {
|
|
22081
|
+
console.log(` lane: ${lane} \u2014 every request is wrapped as the deployed ${lane} envelope; one the dispatcher would skip answers 422 lane_refused`);
|
|
22082
|
+
}
|
|
22083
|
+
console.log(` invoke: curl -X POST http://localhost:${port}/ -d '${sample[lane]}'`);
|
|
21497
22084
|
console.log(` scopes: [${scopes.join(", ") || "none"}] \u2192 features [${auds.join(", ") || "none"}] (clamp reproduced locally)`);
|
|
21498
22085
|
console.log(` egress: deny-by-default, same egressDecision as deployed \xB7 allowed: ${egressAllow.join(", ") || "(none beyond api.vxil.*)"}`);
|
|
21499
22086
|
console.log(" caveat: local edge calls use YOUR API key, not per-invoke scoped JWTs \u2014 the aud-level");
|