@vxil/cli 0.33.0 → 0.35.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.
@@ -0,0 +1,16 @@
1
+ /** ALERT-CHAT-DEST: the secret-name grammar of `webhooks.alerts.chatWebhook.secret`
2
+ * (the secret store's own SECRET_NAME_RE, optionally `secret:`-prefixed like
3
+ * every other *Ref). */
4
+ export declare const CHAT_WEBHOOK_SECRET_PATTERN = "^(secret:)?[a-z0-9_]{1,64}$";
5
+ /** ALERT-CHAT-DEST: where a failure-alert chat webhook may point. EXACT hosts,
6
+ * plus two Microsoft Teams Workflows suffixes (each tenant gets its own
7
+ * sub-domain there). Nothing else: the digest cron POSTs from vxil's own
8
+ * network, so an open URL would be an SSRF / exfiltration door. */
9
+ export declare const CHAT_WEBHOOK_HOSTS: readonly string[];
10
+ export declare const CHAT_WEBHOOK_HOST_SUFFIXES: readonly string[];
11
+ /** ALERT-CHAT-DEST: null when `raw` is a chat webhook URL the digest may POST
12
+ * to, else a short reason. https only, default port, no credentials in the
13
+ * authority, host on the allowlist. PURE — shared by the digest cron (the
14
+ * enforcing check) and the dashboard (an early hint before the value is
15
+ * stored write-only). */
16
+ export declare function chatWebhookUrlError(raw: string): string | null;
@@ -30,7 +30,7 @@ export declare const CmsRuleParamsSchema: import("@sinclair/typebox").TObject<{
30
30
  }>;
31
31
  export declare const CmsRuleEntrySchema: import("@sinclair/typebox").TObject<{
32
32
  app: import("@sinclair/typebox").TString;
33
- kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"stamp_actor" | "create_by_role" | "owner_only_edit" | "role_gate" | "archive_by_role" | "state_flow" | "no_self_decision" | "capacity" | "no_overlap">[]>;
33
+ kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"owner_only_edit" | "archive_by_role" | "state_flow" | "stamp_actor" | "create_by_role" | "role_gate" | "no_self_decision" | "capacity" | "no_overlap">[]>;
34
34
  collection: import("@sinclair/typebox").TString;
35
35
  params: import("@sinclair/typebox").TObject<{
36
36
  field: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
@@ -224,4 +224,24 @@ export declare function highlightTokens(src: string): Array<{
224
224
  value: string;
225
225
  kind: HlKind;
226
226
  }>;
227
+ /** = control-plane `appHookOwnership.APP_HOOK_RE` (the App builder ownership
228
+ * marker `app_` + an app id + `_`; parity pinned by the control-plane
229
+ * appgenDoors test). Only a name carrying a real App builder app id. */
230
+ export declare const APP_OWNED_HOOK_RE: RegExp;
231
+ /** The App builder rule kinds whose DELETE is not free for an end user
232
+ * (= the non-'free' keys of control-plane `appgenDoors.DELETE_SEMANTICS`;
233
+ * parity pinned by the control-plane appgenDoors test). appgenRules names a
234
+ * rule's hooks `<stem><collection>_<kind>[…]`. */
235
+ export declare const APP_DELETE_GUARDED_RULE_KINDS: readonly ["owner_only_edit", "archive_by_role", "state_flow"];
236
+ /** The collections that carry an enabled App builder hook of a
237
+ * delete-guarded rule kind. An END-USER cascade (cascade / set_null) into one
238
+ * of them is refused by cms runCascade — the platform twin of the app proxy's
239
+ * D5 parent-delete refusal (a guard on the child list cannot be bypassed by
240
+ * deleting its parent). Structural: the hook NAME the App builder compiler
241
+ * writes, never user text. A rule that compiles no write check (archive_by_role
242
+ * on the proxy lane) carries the always-true `<list>_archive_by_role_delete_guard`
243
+ * marker (control-plane appgenRules.withDeleteGuardMarkers), so this set equals
244
+ * the lists the proxy's appgenDoors.doorsOf guards (parity pinned in the
245
+ * control-plane appgenDoors test). Pure. */
246
+ export declare function appDeleteGuardedCollections(hooks: Record<string, HookDef> | undefined): ReadonlySet<string>;
227
247
  export {};
@@ -1,5 +1,6 @@
1
1
  import { type Static, type TSchema } from '@sinclair/typebox';
2
2
  import { ACTIVITY_FEED_BUILTIN_GROUPS } from './_vxil-feature-configs-feedGroups.js';
3
+ export { CHAT_WEBHOOK_HOSTS, CHAT_WEBHOOK_HOST_SUFFIXES, CHAT_WEBHOOK_SECRET_PATTERN, chatWebhookUrlError } from './_vxil-feature-configs-chatWebhook.js';
3
4
  export { AI_THINKING_LEVELS, geminiThinkingLevelError, minimalThinkingLevel, type GeminiThinkingLevel } from './_vxil-feature-configs-geminiThinking.js';
4
5
  export * from './_vxil-feature-configs-hooks.js';
5
6
  export * from './_vxil-feature-configs-readmodels.js';
@@ -440,6 +441,9 @@ export declare const WebhooksConfigSchema: import("@sinclair/typebox").TObject<{
440
441
  enabled: import("@sinclair/typebox").TBoolean;
441
442
  minLevel: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"warn">, import("@sinclair/typebox").TLiteral<"error">]>;
442
443
  digestMinutes: import("@sinclair/typebox").TInteger;
444
+ chatWebhook: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
445
+ secret: import("@sinclair/typebox").TString;
446
+ }>>;
443
447
  }>>;
444
448
  subscriptions: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
445
449
  target_url: import("@sinclair/typebox").TString;
@@ -511,7 +515,7 @@ export declare const CmsConfigSchema: import("@sinclair/typebox").TObject<{
511
515
  }>>>;
512
516
  rules: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
513
517
  app: import("@sinclair/typebox").TString;
514
- kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"stamp_actor" | "create_by_role" | "owner_only_edit" | "role_gate" | "archive_by_role" | "state_flow" | "no_self_decision" | "capacity" | "no_overlap">[]>;
518
+ kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"owner_only_edit" | "archive_by_role" | "state_flow" | "stamp_actor" | "create_by_role" | "role_gate" | "no_self_decision" | "capacity" | "no_overlap">[]>;
515
519
  collection: import("@sinclair/typebox").TString;
516
520
  params: import("@sinclair/typebox").TObject<{
517
521
  field: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
package/dist/config.d.ts CHANGED
@@ -136,6 +136,15 @@ export interface CollectionDef {
136
136
  * a config leaf) and carried by BOTH push paths; widening (clearing, or
137
137
  * adding a role) needs `vxil push --allow-destructive`. */
138
138
  writeRoles?: string[];
139
+ /** W39-3: role slugs whose VERIFIED holders MANAGE this owned collection —
140
+ * they read every row (get / list / count) and may update another member's
141
+ * row through the single-item PATCH; the collection's hooks still decide
142
+ * which fields (every other door stays owner-scoped). Needs `ownerField`
143
+ * and an end-user-writable `endUserAccess`. Absent / `[]` = none. Up to 16
144
+ * slugs. Carried by BOTH push paths; adding a role widens reads and needs
145
+ * `vxil push --allow-destructive`. The App builder sets `['app_manager']`
146
+ * on its members apps' own-only lists while the app names managers. */
147
+ managerRoles?: string[];
139
148
  /** Retention in days (guide ch. 4): items created more than this many days
140
149
  * ago are soft-deleted by the nightly sweep (recoverable for 30 days). An
141
150
  * integer 1..3650, or `null` to keep items until deleted. ABSENT = not
package/dist/vxil-main.js CHANGED
@@ -5199,6 +5199,15 @@ var init_feedGroups = __esm({
5199
5199
  }
5200
5200
  });
5201
5201
 
5202
+ // ../feature-configs/src/chatWebhook.ts
5203
+ var CHAT_WEBHOOK_SECRET_PATTERN;
5204
+ var init_chatWebhook = __esm({
5205
+ "../feature-configs/src/chatWebhook.ts"() {
5206
+ "use strict";
5207
+ CHAT_WEBHOOK_SECRET_PATTERN = "^(secret:)?[a-z0-9_]{1,64}$";
5208
+ }
5209
+ });
5210
+
5202
5211
  // ../feature-configs/src/canonicalJson.ts
5203
5212
  function canonicalJson(v) {
5204
5213
  if (Array.isArray(v)) return `[${v.map((x) => canonicalJson(x) ?? "null").join(",")}]`;
@@ -5324,6 +5333,8 @@ var init_src = __esm({
5324
5333
  init_publicAssets();
5325
5334
  init_geminiThinking();
5326
5335
  init_feedGroups();
5336
+ init_chatWebhook();
5337
+ init_chatWebhook();
5327
5338
  init_cmsRules();
5328
5339
  init_geminiThinking();
5329
5340
  init_hooks();
@@ -6074,7 +6085,20 @@ var init_src = __esm({
6074
6085
  alerts: Type.Optional(Type.Object({
6075
6086
  enabled: Type.Boolean({ default: false }),
6076
6087
  minLevel: Type.Union([Type.Literal("warn"), Type.Literal("error")], { default: "error" }),
6077
- digestMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 1440 })
6088
+ digestMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 1440 }),
6089
+ // ALERT-CHAT-DEST (w41, founder decision D 2026-10-30): a chat webhook
6090
+ // (Google Chat / Slack / Teams Workflows) that receives the SAME digest as
6091
+ // a `{ text }` POST, beside the owner e-mail. The URL embeds the channel's
6092
+ // key/token, so it is NEVER a config value: `secret` NAMES a tenant secret
6093
+ // stored under feature 'webhooks' (PUT /v1/secrets/webhooks/<name>,
6094
+ // envelope-encrypted under KEK_WEBHOOKS), resolved only by the digest cron.
6095
+ // The host is checked against CHAT_WEBHOOK_HOSTS at send time (the value
6096
+ // is write-only, so it cannot be checked here). Optional, NO default —
6097
+ // existing manifests fold byte-identically. Inside the alerts bag, so the
6098
+ // leaf count is unchanged.
6099
+ chatWebhook: Type.Optional(Type.Object({
6100
+ secret: Type.String({ pattern: CHAT_WEBHOOK_SECRET_PATTERN })
6101
+ }))
6078
6102
  })),
6079
6103
  // DECLARED API STATE (2026-09-23): the tenant's OUTBOUND
6080
6104
  // subscriptions as config. Keyed by `target_url` — the only stable identity a
@@ -7246,7 +7270,7 @@ function formatRulesSummary(s, needsAck = false) {
7246
7270
  if (!s.added.length && !s.removed.length) return " rules (server summary): no rule added or removed";
7247
7271
  return [
7248
7272
  " rules (server summary):",
7249
- ...s.added.map((l) => ` + ${oneLine(l.sentence)} ${enforcedTag(l)}`),
7273
+ ...s.added.flatMap((l) => [` + ${oneLine(l.sentence)} ${enforcedTag(l)}`, ...unwrittenLine(l)]),
7250
7274
  ...s.removed.map((l) => ` - ${oneLine(l.sentence)} ${enforcedTag(l)}${needsAck ? NEEDS_ACK : ""}`)
7251
7275
  ].join("\n");
7252
7276
  }
@@ -7308,7 +7332,7 @@ ${tail}`;
7308
7332
  return `${head}export default defineConfig(${marked.slice(0, at)}${block}${marked.slice(at + needle.length)});
7309
7333
  ${tail}`;
7310
7334
  }
7311
- var isObj, isLine, oneLine, enforcedTag, NEEDS_ACK, RULES_MARK;
7335
+ var isObj, isLine, oneLine, enforcedTag, NEEDS_ACK, unwrittenLine, RULES_MARK;
7312
7336
  var init_cmsRulesView = __esm({
7313
7337
  "src/cmsRulesView.ts"() {
7314
7338
  "use strict";
@@ -7319,6 +7343,10 @@ var init_cmsRulesView = __esm({
7319
7343
  oneLine = (s) => s.replace(/[\r\n\u2028\u2029]+/g, " ").trim();
7320
7344
  enforcedTag = (l) => l.enforced_by === "server" ? "[enforced_by: server]" : `[enforced_by: app \u2014 only writes through ${oneLine(l.app)}]`;
7321
7345
  NEEDS_ACK = " (needs --allow-rule-removal)";
7346
+ unwrittenLine = (l) => {
7347
+ const keys = Array.isArray(l.unwritten_keys) ? l.unwritten_keys.filter((k) => typeof k === "string").map(oneLine) : [];
7348
+ return keys.length ? [` ! ${oneLine(l.app)}'s page does not send ${keys.join(", ")}: the app refuses those creates until it does (fix the page in App builder)`] : [];
7349
+ };
7322
7350
  RULES_MARK = "\0vxil-pull-rules\0";
7323
7351
  }
7324
7352
  });
@@ -7601,7 +7629,7 @@ var init_src3 = __esm({
7601
7629
  // Dedicated write scope for the BYO-secret path. PUT /v1/secrets/:feature/:name
7602
7630
  // requires THIS (or 'admin') as of 2026-07-17 ('features:write' is no
7603
7631
  // longer secret-write-equivalent). GET/DELETE /v1/secrets still also accept
7604
- // 'features:write'.
7632
+ // 'features:write'; GET also accepts 'features:read' (names + ref only, 2026-10-30).
7605
7633
  "secrets:write",
7606
7634
  "users:read",
7607
7635
  "users:write",
@@ -7776,6 +7804,7 @@ var init_src3 = __esm({
7776
7804
  var functions_exports = {};
7777
7805
  __export(functions_exports, {
7778
7806
  BUNDLE_HEADROOM_WARN_RATIO: () => BUNDLE_HEADROOM_WARN_RATIO,
7807
+ DEPLOY_ENTITLEMENT_NOT_PROBED: () => DEPLOY_ENTITLEMENT_NOT_PROBED,
7779
7808
  FN_LIMIT_BOUNDS: () => FN_LIMIT_BOUNDS,
7780
7809
  MAX_FN_BINDINGS: () => MAX_FN_BINDINGS,
7781
7810
  assertBundleContained: () => assertBundleContained,
@@ -7787,6 +7816,7 @@ __export(functions_exports, {
7787
7816
  describeBinding: () => describeBinding,
7788
7817
  describeRuntime: () => describeRuntime,
7789
7818
  diffBindings: () => diffBindings,
7819
+ entitlementNotProbed: () => entitlementNotProbed,
7790
7820
  formatBundleTooLarge: () => formatBundleTooLarge,
7791
7821
  formatFnChanges: () => formatFnChanges,
7792
7822
  formatFunctionsPreflight: () => formatFunctionsPreflight,
@@ -8292,6 +8322,9 @@ async function probeFunctionsEntitlement(api, name) {
8292
8322
  }
8293
8323
  return { entitled: true };
8294
8324
  }
8325
+ function entitlementNotProbed(e) {
8326
+ return !e.entitled && e.status === 403 && e.code === "insufficient_scope";
8327
+ }
8295
8328
  function billingPageFor(tenantId, baseUrl) {
8296
8329
  const tid = tenantId && tenantId !== "(unknown)" ? tenantId : "<tenant>";
8297
8330
  const path = `/dashboard/${tid}/billing`;
@@ -8303,6 +8336,10 @@ function billingPageFor(tenantId, baseUrl) {
8303
8336
  }
8304
8337
  function formatFunctionsPreflight(e, opts) {
8305
8338
  const n = `${opts.declared} declared function(s)`;
8339
+ if (entitlementNotProbed(e)) {
8340
+ if (opts.verb === "plan") return DEPLOY_ENTITLEMENT_NOT_PROBED.trim();
8341
+ return `functions: the pushing key lacks features:write (insufficient_scope${e.message ? ` \u2014 ${e.message}` : ""}); ${n} would be refused. Push with a key that holds features:write, or run \`vxil push --skip-functions\` to push everything else.`;
8342
+ }
8306
8343
  if (e.code === "sandbox_restricted" || e.status === 403) {
8307
8344
  return `functions: this project cannot deploy functions (${e.code ?? e.status}${e.message ? ` \u2014 ${e.message}` : ""}); ${n} would be refused.${e.hint ? ` ${e.hint}` : ""}` + (opts.verb === "push" ? " Run `vxil push --skip-functions` to push everything else." : "");
8308
8345
  }
@@ -8330,7 +8367,7 @@ function formatLogLines(lines) {
8330
8367
  }
8331
8368
  return out.length ? out.join("\n") : " (no log lines)";
8332
8369
  }
8333
- var WIRING_WARNING, WORKSPACE_ROOTS, BUNDLE_HEADROOM_WARN_RATIO;
8370
+ var WIRING_WARNING, WORKSPACE_ROOTS, BUNDLE_HEADROOM_WARN_RATIO, DEPLOY_ENTITLEMENT_NOT_PROBED;
8334
8371
  var init_functions = __esm({
8335
8372
  "src/functions.ts"() {
8336
8373
  "use strict";
@@ -8341,6 +8378,7 @@ var init_functions = __esm({
8341
8378
  WIRING_WARNING = /trigger subscription|cron schedule/i;
8342
8379
  WORKSPACE_ROOTS = /* @__PURE__ */ new Map();
8343
8380
  BUNDLE_HEADROOM_WARN_RATIO = 0.8;
8381
+ DEPLOY_ENTITLEMENT_NOT_PROBED = " ! not probed: deploy entitlement (needs features:write)";
8344
8382
  }
8345
8383
  });
8346
8384
 
@@ -8731,7 +8769,7 @@ function applyEnvelope(r, what) {
8731
8769
  }
8732
8770
  return r.body.data;
8733
8771
  }
8734
- async function serverApply({ api, bundle, resume, allowDestructive, allowRuleRemoval, onPlan }) {
8772
+ async function serverApply({ api, bundle, resume, allowDestructive, allowRuleRemoval, onPlan, poll }) {
8735
8773
  if (resume) {
8736
8774
  return applyEnvelope(await api("POST", "/v1/apply", { apply_id: resume, mode: "apply" }), "apply resume");
8737
8775
  }
@@ -8746,10 +8784,44 @@ async function serverApply({ api, bundle, resume, allowDestructive, allowRuleRem
8746
8784
  "apply plan"
8747
8785
  );
8748
8786
  onPlan?.(plan);
8749
- return applyEnvelope(
8750
- await api("POST", "/v1/apply", { apply_id: plan.apply_id, mode: "apply" }),
8751
- `apply execute (resume with: vxil push --server --resume ${plan.apply_id})`
8752
- );
8787
+ let executed;
8788
+ try {
8789
+ executed = await api("POST", "/v1/apply", { apply_id: plan.apply_id, mode: "apply" });
8790
+ } catch (e) {
8791
+ return pollApplyAfterTransportError({ api, applyId: plan.apply_id, cause: e, ...poll ?? {} });
8792
+ }
8793
+ return applyEnvelope(executed, `apply execute (resume with: vxil push --server --resume ${plan.apply_id})`);
8794
+ }
8795
+ async function pollApplyAfterTransportError({ api, applyId, cause, intervalMs = APPLY_POLL_INTERVAL_MS, maxMs = APPLY_POLL_MAX_MS, sleep, onWait }) {
8796
+ const wait = sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
8797
+ const why = cause?.message ?? String(cause);
8798
+ const resumeHint = `resume with: vxil push --server --resume ${applyId}`;
8799
+ onWait?.(` \u2026 the apply answer was lost in transport (${why}) \u2014 reading apply ${applyId} every ${intervalMs / 1e3} s (up to ${maxMs / 1e3} s)`);
8800
+ let last = null;
8801
+ const maxReads = Math.max(1, Math.floor(maxMs / intervalMs));
8802
+ for (let n = 1; ; n++) {
8803
+ let res = null;
8804
+ try {
8805
+ res = await api("GET", `/v1/apply/${encodeURIComponent(applyId)}`);
8806
+ } catch {
8807
+ }
8808
+ if (res && res.status >= 400 && res.status < 500 && res.status !== 429) {
8809
+ try {
8810
+ applyEnvelope(res, `apply ${applyId} status read (${resumeHint})`);
8811
+ } catch (e) {
8812
+ throw new ApplyUnreadError(applyId, e.message);
8813
+ }
8814
+ }
8815
+ const readable = res && res.status < 400 && !res.body?.error && typeof res.body?.data?.status === "string";
8816
+ if (res && readable) {
8817
+ last = applyEnvelope(res, `apply ${applyId} status read (${resumeHint})`);
8818
+ if (last.status !== "running" && last.status !== "planned") return { ...last, polled: true };
8819
+ }
8820
+ if (n >= maxReads) break;
8821
+ await wait(intervalMs);
8822
+ }
8823
+ if (!last) throw new ApplyUnreadError(applyId, `apply execute: ${why}; apply ${applyId} could not be read for ${maxMs / 1e3} s \u2014 check it with \`vxil api GET /v1/apply/${applyId}\`, then ${resumeHint} if it is still running`);
8824
+ return { ...last, polled: true };
8753
8825
  }
8754
8826
  function cmsEnableManifest(declared) {
8755
8827
  return declared.limits !== void 0 ? { ...CMS_ENABLE_MANIFEST, limits: declared.limits } : { ...CMS_ENABLE_MANIFEST };
@@ -8933,7 +9005,7 @@ function readUnavailable(route, res) {
8933
9005
  function formatNotCompared(u, hint) {
8934
9006
  return ` ! not compared: ${u.route} \u2192 ${u.code ?? u.status}${u.message ? ` \u2014 ${u.message}` : ""}${hint ? ` (${hint})` : ""}`;
8935
9007
  }
8936
- var MAX_FN_BINDINGS, FN_LIMIT_BOUNDS, CONFIG_FILENAMES, VXIL_CONFIG_PKG_VERSION, VXIL_SDK_PKG_VERSION, VXIL_CLI_PKG_VERSION, SCAFFOLD_NODE_TYPES_RANGE, SCAFFOLD_TSCONFIG, CONFIG_IMPORT_SPECIFIERS, QUICKSTART_DEFAULT_FEATURES, APP_HOOK_RE, ACTIVE_ORG_MIN_SDK, loadedConfigApiVersion, STEP_GLYPH, PUSH_READ_CONCURRENCY, CMS_ENABLE_MANIFEST, CMS_PROPAGATION_ATTEMPTS, CMS_PROPAGATION_DELAY_MS;
9008
+ var MAX_FN_BINDINGS, FN_LIMIT_BOUNDS, CONFIG_FILENAMES, VXIL_CONFIG_PKG_VERSION, VXIL_SDK_PKG_VERSION, VXIL_CLI_PKG_VERSION, SCAFFOLD_NODE_TYPES_RANGE, SCAFFOLD_TSCONFIG, CONFIG_IMPORT_SPECIFIERS, QUICKSTART_DEFAULT_FEATURES, APP_HOOK_RE, ACTIVE_ORG_MIN_SDK, loadedConfigApiVersion, STEP_GLYPH, ApplyUnreadError, APPLY_POLL_INTERVAL_MS, APPLY_POLL_MAX_MS, PUSH_READ_CONCURRENCY, CMS_ENABLE_MANIFEST, CMS_PROPAGATION_ATTEMPTS, CMS_PROPAGATION_DELAY_MS;
8937
9009
  var init_lib = __esm({
8938
9010
  "src/lib.ts"() {
8939
9011
  "use strict";
@@ -8946,9 +9018,9 @@ var init_lib = __esm({
8946
9018
  timeoutMs: { min: 1e3, max: 12e4 }
8947
9019
  };
8948
9020
  CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
8949
- VXIL_CONFIG_PKG_VERSION = "0.25.0";
8950
- VXIL_SDK_PKG_VERSION = "0.32.0";
8951
- VXIL_CLI_PKG_VERSION = "0.33.0";
9021
+ VXIL_CONFIG_PKG_VERSION = "0.26.0";
9022
+ VXIL_SDK_PKG_VERSION = "0.34.0";
9023
+ VXIL_CLI_PKG_VERSION = "0.35.0";
8952
9024
  SCAFFOLD_NODE_TYPES_RANGE = "^24.0.0";
8953
9025
  SCAFFOLD_TSCONFIG = {
8954
9026
  compilerOptions: {
@@ -8973,6 +9045,16 @@ var init_lib = __esm({
8973
9045
  rolled_back: "\u21A9",
8974
9046
  kept: "\u2248"
8975
9047
  };
9048
+ ApplyUnreadError = class extends Error {
9049
+ constructor(applyId, message) {
9050
+ super(message);
9051
+ this.applyId = applyId;
9052
+ this.name = "ApplyUnreadError";
9053
+ }
9054
+ applyId;
9055
+ };
9056
+ APPLY_POLL_INTERVAL_MS = 5e3;
9057
+ APPLY_POLL_MAX_MS = 12e4;
8976
9058
  PUSH_READ_CONCURRENCY = 5;
8977
9059
  CMS_ENABLE_MANIFEST = { enabled: true };
8978
9060
  CMS_PROPAGATION_ATTEMPTS = 4;
@@ -11160,6 +11242,10 @@ async function aiTemplateContentSha256(t) {
11160
11242
  }));
11161
11243
  }
11162
11244
 
11245
+ // ../runtime/src/innerJwt.ts
11246
+ var APP_MANAGER_ROLE = "app_manager";
11247
+ var enc = new TextEncoder();
11248
+
11163
11249
  // ../runtime/src/egress.ts
11164
11250
  function isPrivateIPv4(host) {
11165
11251
  const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
@@ -11748,6 +11834,12 @@ function writeRolesWidens(declared, remote) {
11748
11834
  if (declared.length === 0) return true;
11749
11835
  return declared.some((r) => !remote.includes(r));
11750
11836
  }
11837
+ function appBuilderManagersKept(declared, remote) {
11838
+ return !declared && remote.length === 1 && remote[0] === APP_MANAGER_ROLE;
11839
+ }
11840
+ function managerRolesWidens(declared, remote) {
11841
+ return declared.some((r) => !remote.includes(r));
11842
+ }
11751
11843
  function orgFieldWidens(declared, remote) {
11752
11844
  return remote !== null && declared !== remote;
11753
11845
  }
@@ -11852,6 +11944,7 @@ function remoteCollectionOf(c) {
11852
11944
  writeRoles: writeRolesOf(c.write_roles),
11853
11945
  orgField: typeof c.org_field === "string" && c.org_field.length > 0 ? c.org_field : null,
11854
11946
  orgReadRoles: writeRolesOf(c.org_read_roles),
11947
+ managerRoles: writeRolesOf(c.manager_roles),
11855
11948
  retainDays: typeof c.retain_days === "number" ? c.retain_days : null,
11856
11949
  deletedRetainDays: typeof c.deleted_retain_days === "number" ? c.deleted_retain_days : null,
11857
11950
  onOwnerErase: c.on_owner_erase === "delete" ? "delete" : "keep",
@@ -11871,6 +11964,7 @@ function pulledCollectionBlock(c) {
11871
11964
  ...rc.writeRoles.length ? { writeRoles: rc.writeRoles } : {},
11872
11965
  ...rc.orgField ? { orgField: rc.orgField } : {},
11873
11966
  ...rc.orgReadRoles.length ? { orgReadRoles: rc.orgReadRoles } : {},
11967
+ ...rc.managerRoles.length ? { managerRoles: rc.managerRoles } : {},
11874
11968
  ...rc.retainDays !== null ? { retainDays: rc.retainDays } : {},
11875
11969
  ...rc.deletedRetainDays !== null ? { deletedRetainDays: rc.deletedRetainDays } : {},
11876
11970
  ...rc.onOwnerErase === "delete" ? { onOwnerErase: "delete" } : {},
@@ -11938,6 +12032,10 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
11938
12032
  if (wr !== void 0 && (!Array.isArray(wr) || wr.some((r) => typeof r !== "string"))) {
11939
12033
  throw new Error(`cms.collections.${name}.writeRoles must be an array of role slugs (got ${JSON.stringify(wr)})`);
11940
12034
  }
12035
+ const mr = def.managerRoles;
12036
+ if (mr !== void 0 && (!Array.isArray(mr) || mr.some((r) => typeof r !== "string"))) {
12037
+ throw new Error(`cms.collections.${name}.managerRoles must be an array of role slugs (got ${JSON.stringify(mr)})`);
12038
+ }
11941
12039
  const wsErrs = workspaceCollectionErrors(name, def);
11942
12040
  if (wsErrs.length) throw new Error(wsErrs[0]);
11943
12041
  const rd = def.retainDays;
@@ -12132,6 +12230,24 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
12132
12230
  });
12133
12231
  }
12134
12232
  }
12233
+ const declaredMgr = writeRolesOf(def.managerRoles);
12234
+ if (!appBuilderManagersKept(def.managerRoles !== void 0, rc.managerRoles) && stableStringify(declaredMgr) !== stableStringify(rc.managerRoles)) {
12235
+ const widens = managerRolesWidens(declaredMgr, rc.managerRoles);
12236
+ const fmt = (r) => r.length ? `[${r.join(", ")}]` : "none";
12237
+ if (!widens || allowDestructive) {
12238
+ collChanges.push({
12239
+ collection: name,
12240
+ kind: "set-manager-roles",
12241
+ detail: `${name} managerRoles \u2192 ${fmt(declaredMgr)} (was ${fmt(rc.managerRoles)})` + (widens ? " \u2014 WIDENS who reads every row (full-config reconciliation)" : " (narrows end-user reads)")
12242
+ });
12243
+ } else {
12244
+ collChanges.push({
12245
+ collection: name,
12246
+ kind: "warn",
12247
+ detail: `${name}: managerRoles are ${fmt(rc.managerRoles)} remotely but ${fmt(declaredMgr)} in config \u2014 that WIDENS which end users read every row, so it is NOT applied. Declare the remote roles to keep them, or re-run with --allow-destructive to widen.`
12248
+ });
12249
+ }
12250
+ }
12135
12251
  const declaredOrg = def.orgField ?? null;
12136
12252
  const declaredOrgRoles = writeRolesOf(def.orgReadRoles);
12137
12253
  const orgSteps = [];
@@ -12291,6 +12407,8 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
12291
12407
  if (access !== "readwrite") body.end_user_access = access;
12292
12408
  const roles = writeRolesOf(def.writeRoles);
12293
12409
  if (roles.length > 0) body.write_roles = roles;
12410
+ const mgrRoles = writeRolesOf(def.managerRoles);
12411
+ if (mgrRoles.length > 0) body.manager_roles = mgrRoles;
12294
12412
  if (def.orgField) body.org_field = def.orgField;
12295
12413
  const orgRoles = writeRolesOf(def.orgReadRoles);
12296
12414
  if (orgRoles.length > 0) body.org_read_roles = orgRoles;
@@ -12357,6 +12475,14 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
12357
12475
  throw new Error(`cms: set writeRoles on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
12358
12476
  }
12359
12477
  applied++;
12478
+ } else if (c.kind === "set-manager-roles") {
12479
+ const target = writeRolesOf(collections[c.collection].managerRoles);
12480
+ const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { manager_roles: target });
12481
+ if (res.status !== 200 && res.status !== 201) {
12482
+ const e = res.body.error ?? {};
12483
+ throw new Error(`cms: set managerRoles on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
12484
+ }
12485
+ applied++;
12360
12486
  } else if (c.kind === "set-org-field" || c.kind === "set-org-read-roles") {
12361
12487
  const def = collections[c.collection];
12362
12488
  const remoteRc = remote.get(c.collection);
@@ -12453,7 +12579,7 @@ function formatCmsChanges(changes) {
12453
12579
  if (c.kind === "destructive") return ` ! ${c.detail}`;
12454
12580
  if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
12455
12581
  if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
12456
- if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles" || c.kind === "set-retain-days" || c.kind === "set-deleted-retain-days" || c.kind === "set-on-owner-erase") return ` ~ ${c.detail}`;
12582
+ if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles" || c.kind === "set-manager-roles" || c.kind === "set-retain-days" || c.kind === "set-deleted-retain-days" || c.kind === "set-on-owner-erase") return ` ~ ${c.detail}`;
12457
12583
  return ` + ${c.detail}`;
12458
12584
  }).join("\n");
12459
12585
  }
@@ -12805,6 +12931,10 @@ function storedSessionFor(creds, dashBase) {
12805
12931
  if (host !== dashboardOrigin(dashBase)) return { session: null, reason: "other-host", host };
12806
12932
  return { session: creds.session, host };
12807
12933
  }
12934
+ function sessionExpiryFrom(setCookie, now) {
12935
+ const m = /;\s*max-age=(\d+)/i.exec(setCookie ?? "");
12936
+ return m ? new Date(now.getTime() + Number(m[1]) * 1e3).toISOString() : void 0;
12937
+ }
12808
12938
  function sessionNotForTarget(s, target) {
12809
12939
  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`;
12810
12940
  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`;
@@ -16328,7 +16458,7 @@ var TOOLS = [
16328
16458
  {
16329
16459
  name: "cms_list_collections",
16330
16460
  feature: "cms",
16331
- description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only), 'none' (end-user reads and writes are 403 server_only) or 'read_all' (a verified end user reads every row, past owner_field, and writes only their own), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago. deleted_retain_days (null = 30) is how many days a deleted item stays restorable before the nightly cleanup purges it. on_owner_erase 'delete' (null = keep) means a GDPR erase of a user hard-deletes the items they own here. org_field (null = none) makes the collection workspace-scoped: a verified end user reads and writes only rows of the session's active org (no active org \u2192 403 org_required) and write_roles match the active-org role; org_read_roles (null = any member) gates reads by that role.",
16461
+ description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only), 'none' (end-user reads and writes are 403 server_only) or 'read_all' (a verified end user reads every row, past owner_field, and writes only their own), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); manager_roles: the roles whose verified holders manage an owned collection (get / list / count read every row, the single-item update may change another member's row under the collection's hooks; null = none); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago. deleted_retain_days (null = 30) is how many days a deleted item stays restorable before the nightly cleanup purges it. on_owner_erase 'delete' (null = keep) means a GDPR erase of a user hard-deletes the items they own here. org_field (null = none) makes the collection workspace-scoped: a verified end user reads and writes only rows of the session's active org (no active org \u2192 403 org_required) and write_roles match the active-org role; org_read_roles (null = any member) gates reads by that role.",
16332
16462
  inputSchema: { type: "object", properties: {} },
16333
16463
  method: "GET",
16334
16464
  path: "/v1/cms/collections"
@@ -17428,7 +17558,7 @@ var TOOLS = [
17428
17558
  // Substrate tool (no `feature` — always-on like users_upsert): usage is a
17429
17559
  // platform property, not a feature you enable.
17430
17560
  name: "usage_current",
17431
- description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only), `files` (live private objects and bytes vs the storage `limit` uploads are held to \u2014 422 quota_exceeded at it) and `functions` (today's UTC call count vs the plan's `limit_per_day`, and on a Free staging/development project `cpu_ms_today` vs its `cpu_ms_limit_per_day` CPU budget \u2014 429 cpu_ms_per_day past it; a snapshot that can trail by a minute or two, null when there is no snapshot for today), and `daily_caps` \u2014 each daily lane of the plan (api_calls, App builder units, functions CPU, realtime, bandwidth, image transforms, and db_bytes \u2014 the database size measured nightly, over which cms creates and updates answer 413 db_size_limit) with `used` (null where not readable yet), `cap`, `resets_at` (null for db_bytes) and `state` ('warning' at 80 %, 'reached' at the cap), for the pool the caps count in (a Free account's projects share one). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers have no monthly cap and are never billed for overage. Needs usage:read or features:read.",
17561
+ description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only), `files` (live private objects and bytes vs the storage `limit` uploads are held to \u2014 422 quota_exceeded at it) and `functions` (today's UTC call count vs the plan's `limit_per_day`, and `cpu_ms_today` (CPU-ms charged today, settled to measured CPU plus a small per-call dispatch overhead) vs `cpu_ms_limit_per_day`, the enforced daily CPU budget on every plan (Free production 150,000, a Free staging or development project 500,000, a paid project its plan row; null on Enterprise) \u2014 429 cpu_ms_per_day past it; a snapshot that can trail by a minute or two, null when there is no snapshot for today), and `daily_caps` \u2014 each daily lane of the plan (api_calls, App builder units, functions CPU, realtime, bandwidth, image transforms, and db_bytes \u2014 the database size measured nightly, over which cms creates and updates answer 413 db_size_limit) with `used` (null where not readable yet), `cap`, `resets_at` (null for db_bytes) and `state` ('warning' at 80 %, 'reached' at the cap), for the pool the caps count in (a Free account's projects share one). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers have no monthly cap and are never billed for overage. Needs usage:read or features:read.",
17432
17562
  inputSchema: { type: "object", properties: {} },
17433
17563
  method: "GET",
17434
17564
  path: "/v1/usage"
@@ -17486,7 +17616,7 @@ var TOOLS = [
17486
17616
  },
17487
17617
  {
17488
17618
  name: "config_put",
17489
- description: "Replace ONE feature's configuration with `body` (the whole manifest, as config_get returns it). DRY RUN BY DEFAULT: without `dry_run: false` nothing is written and the answer is the validated manifest and summary the write would produce, so preview first, then repeat with dry_run false. A real write needs `if_match` (the version from config_get) once the feature has a version; a stale one answers 409 config_version_conflict, so read again and retry. The answer is the server's own summary (new version, and for rate-limits, webhooks and ai the declared rows planned or applied) \u2014 nothing undeclared is deleted. Hooks named app_* belong to App builder apps: they are kept as stored, and a write that declares or changes one is refused (422 reserved_hook_name). cms `rules` (App builder rules): a body without it keeps them; adding one needs no ack, but removing or changing one is refused (422 rules_widening, naming the rules) unless allow_rule_removal is true \u2014 when you did not mean to remove them, read config_get again (your body is older than the app's rules). functions accepts only a logDrain change, with dry_run false. Needs features:write.",
17619
+ description: "Replace ONE feature's configuration with `body` (the whole manifest, as config_get returns it). DRY RUN BY DEFAULT: without `dry_run: false` nothing is written and the answer is the validated manifest and summary the write would produce, so preview first, then repeat with dry_run false. A real write needs `if_match` (the version from config_get) once the feature has a version; a stale one answers 409 config_version_conflict, so read again and retry. The answer is the server's own summary (new version, and for rate-limits, webhooks and ai the declared rows planned or applied) \u2014 nothing undeclared is deleted. Hooks named app_* belong to App builder apps: they are kept as stored, and a write that declares or changes one is refused (422 reserved_hook_name). cms `rules` (App builder rules): a body without it keeps them; adding one needs no ack, but removing or changing one is refused (422 rules_widening, naming the rules) unless allow_rule_removal is true \u2014 when you did not mean to remove them, read config_get again (your body is older than the app's rules). functions accepts only a logDrain change, with dry_run false. webhooks `alerts` is the failure-alert digest ({ enabled, minLevel, digestMinutes }); `alerts.chatWebhook: { secret: \"<name>\" }` also posts each digest as { text } to a Google Chat, Slack or Teams Workflows webhook whose URL is stored as the webhooks secret <name> (PUT /v1/secrets/webhooks/<name>; the URL is never a config value, and only chat.googleapis.com, hooks.slack.com, *.webhook.office.com and *.logic.azure.com are posted to). A dry run (the default) needs features:read; a write (dry_run false) needs features:write.",
17490
17620
  inputSchema: {
17491
17621
  type: "object",
17492
17622
  properties: {
@@ -17907,6 +18037,10 @@ function collectSecretRefs(cfg) {
17907
18037
  if (typeof drainRef === "string" && drainRef.replace(/^secret:/, "")) {
17908
18038
  add({ name: drainRef.replace(/^secret:/, ""), feature: "functions", via: "features.functions.logDrain.secretRef" });
17909
18039
  }
18040
+ const chatRef = cfg.features?.webhooks?.alerts?.chatWebhook?.secret;
18041
+ if (typeof chatRef === "string" && chatRef.replace(/^secret:/, "")) {
18042
+ add({ name: chatRef.replace(/^secret:/, ""), feature: "webhooks", via: "features.webhooks.alerts.chatWebhook.secret" });
18043
+ }
17910
18044
  for (const [fn, def] of Object.entries(cfg.functions ?? {})) {
17911
18045
  for (const ref of normalizeSecretRefs(def.secrets)) {
17912
18046
  const bare = ref.slice("secret:".length);
@@ -17965,6 +18099,10 @@ function unstoredSecretVerdict(opts) {
17965
18099
  if (opts.ignored) return "ignored";
17966
18100
  return opts.strict ? "drift" : "informational";
17967
18101
  }
18102
+ var SECRETS_NOT_COMPARED_LINE = " ! not compared: secrets (needs features:read or secrets:write)";
18103
+ function secretsListRefusal(opts) {
18104
+ return opts.status === 403 && opts.code === "insufficient_scope" && !opts.strict ? "skip" : "error";
18105
+ }
17968
18106
  function platformOnlyFnVerdict(opts) {
17969
18107
  if (opts.ignored) return "ignored";
17970
18108
  return opts.gate && !opts.strict ? "informational" : "drift";
@@ -18265,6 +18403,18 @@ function devSlotExpiryCheck(dev, now) {
18265
18403
  detail: `'${dev.slug}' expires ${dev.expires_at} (in ${hours}h)` + (hours < 72 ? " \u2014 run `vxil dev up` to extend" : "")
18266
18404
  };
18267
18405
  }
18406
+ function dashboardSessionCheck(creds, now) {
18407
+ const name = "dashboard session";
18408
+ if (!creds.session) return { name, ok: true, detail: "no session \u2014 `vxil login` signs in (only session verbs such as `vxil keys` need one)" };
18409
+ const host = creds.session_host ? ` \xB7 bound to ${creds.session_host}` : " \xB7 no recorded host (run `vxil login` again to bind it)";
18410
+ const who = creds.session_account ?? "(account not recorded by the CLI that signed in \u2014 `vxil account show` names it)";
18411
+ const exp = creds.session_expires_at ? new Date(creds.session_expires_at) : null;
18412
+ if (exp && !Number.isNaN(exp.getTime()) && exp.getTime() <= now.getTime()) {
18413
+ return { name, ok: true, warn: true, detail: `${who} \xB7 expired ${exp.toISOString().slice(0, 16).replace("T", " ")} UTC${host} \u2014 \`vxil login\` again` };
18414
+ }
18415
+ const when2 = exp && !Number.isNaN(exp.getTime()) ? `expires ${exp.toISOString().slice(0, 16).replace("T", " ")} UTC` : "expiry not recorded";
18416
+ return { name, ok: true, detail: `${who} \xB7 ${when2}${host}` };
18417
+ }
18268
18418
 
18269
18419
  // src/keys.ts
18270
18420
  init_src3();
@@ -18561,9 +18711,9 @@ function renderInvites(l) {
18561
18711
 
18562
18712
  // src/configDrafts.ts
18563
18713
  init_lib();
18564
- var enc = encodeURIComponent;
18714
+ var enc2 = encodeURIComponent;
18565
18715
  async function showDraft(api, feature) {
18566
- const r = await api("GET", `/v1/config/${enc(feature)}/draft`);
18716
+ const r = await api("GET", `/v1/config/${enc2(feature)}/draft`);
18567
18717
  if (r.status === 404) return { none: true };
18568
18718
  if (r.status !== 200) throw new Error(`could not read the ${feature} draft (${apiErrText(r)})`);
18569
18719
  const d = r.body.data ?? {};
@@ -18574,7 +18724,7 @@ async function showDraft(api, feature) {
18574
18724
  ...d.applied_by ? { applied_by: d.applied_by } : {},
18575
18725
  ...d.created_at ? { created_at: d.created_at } : {}
18576
18726
  };
18577
- const cur = await api("GET", `/v1/config/${enc(feature)}`);
18727
+ const cur = await api("GET", `/v1/config/${enc2(feature)}`);
18578
18728
  if (cur.status !== 200 && cur.status !== 404) {
18579
18729
  throw new Error(`could not read the active ${feature} config to compare (${apiErrText(cur)})`);
18580
18730
  }
@@ -18583,12 +18733,12 @@ async function showDraft(api, feature) {
18583
18733
  return { draft, activeVersion, changes: diffManifest(active, draft.manifest) };
18584
18734
  }
18585
18735
  async function saveDraft(api, feature, manifest) {
18586
- const r = await api("PUT", `/v1/config/${enc(feature)}/draft`, manifest);
18736
+ const r = await api("PUT", `/v1/config/${enc2(feature)}/draft`, manifest);
18587
18737
  if (r.status !== 200) throw new Error(`draft not saved (${apiErrText(r)})`);
18588
18738
  return { feature, manifest: r.body.data?.manifest ?? manifest };
18589
18739
  }
18590
18740
  async function publishDraft(api, feature) {
18591
- const r = await api("POST", `/v1/config/${enc(feature)}/draft/publish`);
18741
+ const r = await api("POST", `/v1/config/${enc2(feature)}/draft/publish`);
18592
18742
  if (r.status === 404) throw new Error(`no ${feature} draft to publish \u2014 \`vxil config draft save ${feature}\` stages one`);
18593
18743
  if (r.status !== 200) throw new Error(`draft not published (${apiErrText(r)})`);
18594
18744
  const d = r.body.data ?? {};
@@ -18599,7 +18749,7 @@ async function publishDraft(api, feature) {
18599
18749
  };
18600
18750
  }
18601
18751
  async function discardDraft(api, feature) {
18602
- const r = await api("DELETE", `/v1/config/${enc(feature)}/draft`);
18752
+ const r = await api("DELETE", `/v1/config/${enc2(feature)}/draft`);
18603
18753
  if (r.status === 404) return { discarded: false };
18604
18754
  if (r.status !== 200) throw new Error(`draft not discarded (${apiErrText(r)})`);
18605
18755
  return { discarded: true };
@@ -18611,7 +18761,7 @@ function parseVersion(raw) {
18611
18761
  return { version: Number(raw) };
18612
18762
  }
18613
18763
  async function duplicateToDraft(api, feature, version) {
18614
- const r = await api("POST", `/v1/config/${enc(feature)}/versions/${version}/duplicate`);
18764
+ const r = await api("POST", `/v1/config/${enc2(feature)}/versions/${version}/duplicate`);
18615
18765
  if (r.status === 404) throw new Error(`${feature} has no published version ${version} \u2014 \`vxil versions ${feature}\` lists them`);
18616
18766
  if (r.status !== 200) throw new Error(`could not copy version ${version} into a draft (${apiErrText(r)})`);
18617
18767
  return {
@@ -19404,7 +19554,9 @@ function serverApplyErrorJson(input) {
19404
19554
  return {
19405
19555
  ok: false,
19406
19556
  exit_code: input.exitCode,
19407
- apply_id: null,
19557
+ // a run that planned but lost the execute answer
19558
+ // (and could not read the journal back) still names its apply
19559
+ apply_id: input.applyId ?? null,
19408
19560
  status: null,
19409
19561
  steps: [],
19410
19562
  trigger_wiring: null,
@@ -20653,7 +20805,7 @@ async function loadCmsTable({
20653
20805
  let patched = 0;
20654
20806
  let unhashed = 0;
20655
20807
  let unhashedSame = 0;
20656
- const enc2 = encodeURIComponent(collection);
20808
+ const enc3 = encodeURIComponent(collection);
20657
20809
  const start = cursors[key] ?? void 0;
20658
20810
  const tsCols = keepTimestamps ? sourceTimestampColumns(mapping) : {};
20659
20811
  if (keepTimestamps && tsCols.created === void 0) {
@@ -20707,7 +20859,7 @@ async function loadCmsTable({
20707
20859
  patched++;
20708
20860
  continue;
20709
20861
  }
20710
- const cur = await deps.api("GET", `/v1/cms/items/${enc2}/${encodeURIComponent(existing)}`);
20862
+ const cur = await deps.api("GET", `/v1/cms/items/${enc3}/${encodeURIComponent(existing)}`);
20711
20863
  const stored = cur.status === 200 ? cur.body.data?.data : void 0;
20712
20864
  if (holdsContent(stored, item, storageTargets)) {
20713
20865
  hashes[sourceId] = h;
@@ -20723,7 +20875,7 @@ async function loadCmsTable({
20723
20875
  patched++;
20724
20876
  continue;
20725
20877
  }
20726
- const res2 = await deps.api("PATCH", `/v1/cms/items/${enc2}/${encodeURIComponent(existing)}`, { data: item.full });
20878
+ const res2 = await deps.api("PATCH", `/v1/cms/items/${enc3}/${encodeURIComponent(existing)}`, { data: item.full });
20727
20879
  if (res2.status === 200) {
20728
20880
  hashes[sourceId] = h;
20729
20881
  noteStorage(sourceId, row2);
@@ -20744,7 +20896,7 @@ async function loadCmsTable({
20744
20896
  }
20745
20897
  if (deps.dryRun) continue;
20746
20898
  const srcTs = keepTimestamps ? sourceTimestampsOf(row2, tsCols) : void 0;
20747
- const res = await deps.api("POST", `/v1/cms/items/${enc2}`, {
20899
+ const res = await deps.api("POST", `/v1/cms/items/${enc3}`, {
20748
20900
  data: item.create,
20749
20901
  status: itemStatus,
20750
20902
  ...srcTs ? { source_timestamps: srcTs } : {}
@@ -20766,7 +20918,7 @@ async function loadCmsTable({
20766
20918
  const createdVal = dedupeField ? item.create[dedupeField.target] : void 0;
20767
20919
  if (res.status === 409 && res.body.error?.code === "unique_violation" && dedupeField && createdVal !== void 0) {
20768
20920
  const filter = encodeURIComponent(JSON.stringify({ [dedupeField.target]: createdVal }));
20769
- const q = await deps.api("GET", `/v1/cms/items/${enc2}?filter=${filter}&limit=1`);
20921
+ const q = await deps.api("GET", `/v1/cms/items/${enc3}?filter=${filter}&limit=1`);
20770
20922
  const items = q.body.data?.items ?? [];
20771
20923
  if (q.status === 200 && items.length > 0 && typeof items[0].item_id === "string") {
20772
20924
  idMap[sourceId] = items[0].item_id;
@@ -21308,7 +21460,7 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, filesOr
21308
21460
  const itmMap = state.idMap[table] ?? {};
21309
21461
  const linked = hashesFor(state, rk);
21310
21462
  const pk = sourcePkColumn(mapping);
21311
- const enc2 = encodeURIComponent(mapping.collection);
21463
+ const enc3 = encodeURIComponent(mapping.collection);
21312
21464
  let misses = 0;
21313
21465
  let privateUrls = 0;
21314
21466
  const columns = [pk, ...storageFields.map((f) => f.source)];
@@ -21354,7 +21506,7 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, filesOr
21354
21506
  }
21355
21507
  continue;
21356
21508
  }
21357
- const res = await deps.api("PATCH", `/v1/cms/items/${enc2}/${encodeURIComponent(itm)}`, { data: patch });
21509
+ const res = await deps.api("PATCH", `/v1/cms/items/${enc3}/${encodeURIComponent(itm)}`, { data: patch });
21358
21510
  if (res.status !== 200) {
21359
21511
  result.errors.push(apiErrorLine(`files relink ${table} row '${sourceId}' (item ${itm})`, res));
21360
21512
  if (result.errors.length >= ROW_FAILURE_CAP) {
@@ -21617,7 +21769,7 @@ async function loadVectors({ deps, plan, target, adapter, state, save, delta })
21617
21769
  const pk = sourcePkColumn(mapping);
21618
21770
  const idMap = state.idMap[target.table] ?? {};
21619
21771
  const ingested = hashesFor(state, key);
21620
- const enc2 = encodeURIComponent(target.collection);
21772
+ const enc3 = encodeURIComponent(target.collection);
21621
21773
  let noText = 0;
21622
21774
  let noItem = 0;
21623
21775
  const opts = {
@@ -21648,7 +21800,7 @@ async function loadVectors({ deps, plan, target, adapter, state, save, delta })
21648
21800
  } else if (doc.why === "no-text") noText++;
21649
21801
  result.skipped++;
21650
21802
  if (ingested[sourceId] !== void 0 && !deps.dryRun) {
21651
- const del = await deps.api("DELETE", `/v1/search/${enc2}/documents/${encodeURIComponent(docId)}`);
21803
+ const del = await deps.api("DELETE", `/v1/search/${enc3}/documents/${encodeURIComponent(docId)}`);
21652
21804
  if (del.status === 200 || del.status === 404) delete ingested[sourceId];
21653
21805
  }
21654
21806
  if (result.errors.length >= ROW_FAILURE_CAP) break;
@@ -21660,7 +21812,7 @@ async function loadVectors({ deps, plan, target, adapter, state, save, delta })
21660
21812
  continue;
21661
21813
  }
21662
21814
  if (deps.dryRun) continue;
21663
- const res = await deps.api("POST", `/v1/search/${enc2}/documents`, {
21815
+ const res = await deps.api("POST", `/v1/search/${enc3}/documents`, {
21664
21816
  doc_id: docId,
21665
21817
  chunks: [{ content: text, vector }],
21666
21818
  metadata: {}
@@ -26143,10 +26295,15 @@ function noKeyMessage(t) {
26143
26295
  if (t.slot === "named") return `no API key stored for target '${t.slotName}' (tenant '${t.tenantSlug}') \u2014 re-run \`vxil link ${t.tenantSlug} --as ${t.slotName}\``;
26144
26296
  return "no API key \u2014 run `vxil quickstart` or `vxil link <slug>`, or set VXIL_API_KEY";
26145
26297
  }
26146
- function storeSession(cookie, dashBase) {
26298
+ function storeSession(cookie, dashBase, account, setCookie) {
26147
26299
  const creds = loadCredentials();
26148
26300
  creds.session = cookie;
26149
26301
  creds.session_host = dashboardOrigin(dashBase);
26302
+ const expires = sessionExpiryFrom(setCookie, /* @__PURE__ */ new Date());
26303
+ if (account) creds.session_account = account;
26304
+ else delete creds.session_account;
26305
+ if (expires) creds.session_expires_at = expires;
26306
+ else delete creds.session_expires_at;
26150
26307
  saveCredentials(creds);
26151
26308
  }
26152
26309
  function sessionForTarget(dashBase) {
@@ -26183,7 +26340,7 @@ async function interactiveLogin(dash, dashBase) {
26183
26340
  const cookie = r.cookie?.split(";")[0];
26184
26341
  if (!cookie) fail("login succeeded but returned no session cookie");
26185
26342
  if (mustPrompt) {
26186
- storeSession(cookie, dashBase);
26343
+ storeSession(cookie, dashBase, e, r.cookie);
26187
26344
  console.error(`\u2713 signed in \u2014 session stored in ~/.vxil/credentials.json (for ${dashboardOrigin(dashBase)})`);
26188
26345
  }
26189
26346
  return cookie;
@@ -26594,7 +26751,9 @@ async function runApply(apply, sel = "prod", opts = {}) {
26594
26751
  const skipFunctions = hasFlag("skip-functions");
26595
26752
  if (declaredFns.length && !skipFunctions && !gate) {
26596
26753
  const ent = await probeFunctionsEntitlement(api, declaredFns[0]);
26597
- if (!ent.entitled) {
26754
+ if (!apply && entitlementNotProbed(ent)) {
26755
+ console.error(DEPLOY_ENTITLEMENT_NOT_PROBED);
26756
+ } else if (!ent.entitled) {
26598
26757
  const msg = formatFunctionsPreflight(ent, {
26599
26758
  declared: declaredFns.length,
26600
26759
  baseUrl: target.baseUrl,
@@ -26877,9 +27036,13 @@ functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared func
26877
27036
  const res = await api("GET", "/v1/secrets");
26878
27037
  console.log("\nsecrets (names only \u2014 values are never read or compared):");
26879
27038
  if (res.status !== 200) {
27039
+ if (secretsListRefusal({ status: res.status, ...res.body.error?.code ? { code: res.body.error.code } : {}, strict: strictSecrets }) === "skip") {
27040
+ console.log(SECRETS_NOT_COMPARED_LINE);
27041
+ return;
27042
+ }
26880
27043
  notCompared(
26881
27044
  { route: "GET /v1/secrets", status: res.status, ...res.body.error?.code ? { code: res.body.error.code } : {}, ...res.body.error?.message ? { message: res.body.error.message } : {} },
26882
- "needs features:write, secrets:write or admin"
27045
+ "needs features:read, secrets:write or admin"
26883
27046
  );
26884
27047
  return;
26885
27048
  }
@@ -27122,9 +27285,21 @@ ${formatTriggerWiring(w).join("\n")}`);
27122
27285
  say(formatApplySteps(plan.steps));
27123
27286
  for (const w of plan.warnings ?? []) say(` ! ${w}`);
27124
27287
  say("\napplying server-side\u2026");
27288
+ },
27289
+ // a transport error on execute reads the journal back
27290
+ poll: { onWait: say }
27291
+ }).catch((e) => {
27292
+ if (e instanceof ApplyUnreadError && jsonOut) {
27293
+ jsonFailDoc = (error, exitCode) => JSON.stringify(serverApplyErrorJson({ error, exitCode, applyId: e.applyId }));
27125
27294
  }
27295
+ throw e;
27126
27296
  });
27127
- for (let attempt = 1; done.status === "running" && onlyPropagationDeferred(done.steps) && attempt <= CMS_PROPAGATION_ATTEMPTS; attempt++) {
27297
+ if (done.polled && (done.status === "running" || done.status === "planned")) {
27298
+ report(done, false);
27299
+ const problems2 = { hard: [], strict: [] };
27300
+ return finish(done, problems2, 1, `apply ${done.apply_id} is still ${done.status} after the execute answer was lost in transport \u2014 it was NOT resumed automatically; check it with \`vxil api GET /v1/apply/${done.apply_id}\`, then resume with: vxil push --server --resume ${done.apply_id}`);
27301
+ }
27302
+ for (let attempt = 1; !done.polled && done.status === "running" && onlyPropagationDeferred(done.steps) && attempt <= CMS_PROPAGATION_ATTEMPTS; attempt++) {
27128
27303
  if (attempt === 1) report(done, false);
27129
27304
  say(` \u2026 deferred: the feature config is still propagating \u2014 waiting ${CMS_PROPAGATION_DELAY_MS / 1e3} s, then resuming the same apply (${attempt}/${CMS_PROPAGATION_ATTEMPTS})`);
27130
27305
  await new Promise((r) => setTimeout(r, CMS_PROPAGATION_DELAY_MS));
@@ -27384,6 +27559,7 @@ async function runDoctor() {
27384
27559
  detail: t.apiKey ? `${t.slot === "named" ? `target '${t.slotName}'` : t.slot === "dev" ? "dev slot" : t.slot === "env" ? "VXIL_API_KEY (no project binding)" : "primary"} \u2192 tenant '${t.tenantSlug ?? "(unknown slug)"}' \xB7 ${t.envLabel} \xB7 ${t.baseUrl} \xB7 key from ${t.keySource}` : noKeyMessage(t)
27385
27560
  });
27386
27561
  }
27562
+ checks.push(dashboardSessionCheck(loadCredentials(), /* @__PURE__ */ new Date()));
27387
27563
  const proj = loadProject();
27388
27564
  if (proj) {
27389
27565
  const slots = bindingSlots(proj).map((s) => `${s.label}=${s.slug}`);
@@ -27443,7 +27619,7 @@ async function runDoctor() {
27443
27619
  checks.push(...secretsChecks(collectSecretRefs(cfg), sres.body.data?.secrets ?? []));
27444
27620
  } else {
27445
27621
  const e = sres.body.error ?? {};
27446
- checks.push({ name: "secrets", ok: true, warn: true, detail: `not compared: GET /v1/secrets \u2192 ${e.code ?? sres.status} (names-only read; needs features:write, secrets:write or admin)` });
27622
+ checks.push({ name: "secrets", ok: true, warn: true, detail: `not compared: GET /v1/secrets \u2192 ${e.code ?? sres.status} (names-only read; needs features:read, secrets:write or admin)` });
27447
27623
  }
27448
27624
  }
27449
27625
  if (cfg?.functions && Object.keys(cfg.functions).length) {
@@ -27810,7 +27986,7 @@ try {
27810
27986
  if (r.status !== 200) fail(`login failed: ${r.status} ${JSON.stringify(r.json).slice(0, 160)}`);
27811
27987
  const cookie = r.cookie?.split(";")[0];
27812
27988
  if (!cookie) fail("login succeeded but returned no session cookie");
27813
- storeSession(cookie, dash);
27989
+ storeSession(cookie, dash, email, r.cookie);
27814
27990
  console.log(`\u2713 logged in \u2014 session stored in ~/.vxil/credentials.json (bound to ${dashboardOrigin(dash)}; it is only ever sent there)`);
27815
27991
  break;
27816
27992
  }
@@ -28087,6 +28263,8 @@ try {
28087
28263
  }
28088
28264
  delete creds.session;
28089
28265
  delete creds.session_host;
28266
+ delete creds.session_account;
28267
+ delete creds.session_expires_at;
28090
28268
  saveCredentials(creds);
28091
28269
  if (!host) {
28092
28270
  console.error("vxil: the stored session had no recorded dashboard host (written by an older CLI), so it was not sent anywhere to be revoked \u2014 it was removed from this machine and expires on its own");
@@ -28721,7 +28899,7 @@ ${plan.rationale}`);
28721
28899
  const rows = res.body.data?.secrets ?? [];
28722
28900
  if (!rows.length) console.log("(no secrets stored)");
28723
28901
  for (const r of rows) {
28724
- console.log(` ${r.feature}/${r.secret_name} (ref secret:${r.secret_name} \xB7 rotated ${r.rotated_at})`);
28902
+ console.log(` ${r.feature}/${r.secret_name} (ref secret:${r.secret_name}${r.rotated_at ? ` \xB7 rotated ${r.rotated_at}` : ""})`);
28725
28903
  }
28726
28904
  }
28727
28905
  } else if (sub === "rm") {
@@ -29593,7 +29771,7 @@ async function runSecretsMatrix() {
29593
29771
  }
29594
29772
  const secrets = res.body.data?.secrets ?? [];
29595
29773
  for (const r of secrets) {
29596
- rows.push({ slug: s.slug, slot: s.label, feature: r.feature, secret_name: r.secret_name, rotated_at: r.rotated_at, ...r.kek_version !== void 0 ? { kek_version: r.kek_version } : {} });
29774
+ rows.push({ slug: s.slug, slot: s.label, feature: r.feature, secret_name: r.secret_name, rotated_at: r.rotated_at ?? "-", ...r.kek_version !== void 0 ? { kek_version: r.kek_version } : {} });
29597
29775
  }
29598
29776
  tenants.push({ slug: s.slug, slot: s.label, edge, status: "ok", count: secrets.length });
29599
29777
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "private": false,
5
5
  "description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
6
6
  "license": "MIT",
@@ -45,8 +45,8 @@
45
45
  }
46
46
  },
47
47
  "devDependencies": {
48
- "@vxil/config": "0.25.0",
49
- "@vxil/feature-configs": "0.23.0",
48
+ "@vxil/config": "0.26.0",
49
+ "@vxil/feature-configs": "0.23.1",
50
50
  "@vxil/runtime": "0.0.1",
51
51
  "@vxil/types": "0.0.1",
52
52
  "miniflare": "^4.20260730.0"