@vxil/cli 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,12 +10,16 @@ export declare const HOOK_LIMITS: {
10
10
  readonly maxReadHooksPerCollection: 10;
11
11
  };
12
12
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
13
- * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
14
- * populated ONLY on the READ path (runReadHooks); on the write path and in
15
- * server-caller mode it is null, exactly like `before` on a create. It carries
16
- * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
17
- * ('end_user' | 'tenant'). It is the missing "caller/session context" a read
18
- * hook keys on to redact/derive per-viewer. */
13
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only.
14
+ * It carries `caller.endUserId` (the verified session sub, or null),
15
+ * `caller.principal` ('end_user' | 'tenant') and `caller.roles` (the VERIFIED
16
+ * session role claims of the signed-in session — never a body or header
17
+ * value; null in server-caller mode). READ hooks always get it
18
+ * (server mode: `{ endUserId: null, principal: 'tenant', roles: null }`).
19
+ * WRITE hooks get it in verified end-user mode only; on a
20
+ * server write it is null, exactly like `before` on a create — so a role rule
21
+ * reads `isNull(caller) || contains(caller.roles, 'manager')`. `contains` on
22
+ * an array is exact element membership. */
19
23
  export declare const HOOK_ROOT_VARS: readonly ["item", "before", "now", "caller"];
20
24
  /** The ONLY callable functions. Each is pure + deterministic + bounded. */
21
25
  export declare const HOOK_FUNCTIONS: readonly ["min", "max", "abs", "round", "floor", "ceil", "sqrt", "pow", "sign", "len", "lower", "upper", "trim", "substr", "contains", "startsWith", "endsWith", "concat", "coalesce", "ifNull", "not", "isNull", "number", "string", "bool", "daysBetween", "yearsBetween"];
@@ -80,20 +84,25 @@ export declare function parseExpr(src: string): Node;
80
84
  /** Walk the AST and reject anything outside the allow-list, plus depth bounds.
81
85
  * Returns an array of human-readable errors (empty = safe). Pure, no eval. */
82
86
  export declare function validateAst(root: Node): string[];
83
- /** The VERIFIED caller/session context for read hooks (guide ch. 7). Read-only;
84
- * present only on the read path in end-user mode. In server mode / write path
85
- * the whole object is null. */
87
+ /** The VERIFIED caller/session context for Lane-A hooks (guide ch. 7).
88
+ * Read-only. Read hooks always get it; write hooks get it in verified end-user
89
+ * mode only (a server write passes null — see HOOK_ROOT_VARS). */
86
90
  export interface HookCaller {
87
91
  /** the verified end-user session sub, or null in server-caller mode. */
88
92
  endUserId: string | null;
89
93
  /** 'end_user' when a session was verified at the edge, else 'tenant'. */
90
94
  principal: 'end_user' | 'tenant';
95
+ /** the VERIFIED session role claims of the signed-in session; `[]` for a
96
+ * session without roles, null in server-caller mode. Optional so an older
97
+ * caller object still reads
98
+ * as "no roles". */
99
+ roles?: readonly string[] | null;
91
100
  }
92
101
  export interface HookContext {
93
102
  item: Record<string, unknown>;
94
103
  before: Record<string, unknown> | null;
95
104
  now: string;
96
- /** verified caller/session context (read path only); null otherwise. */
105
+ /** verified caller/session context; null on a server write. */
97
106
  caller?: HookCaller | null;
98
107
  }
99
108
  /** A mutable step counter SHARED across many evalExpr calls (the read path's
@@ -127,7 +136,10 @@ export interface HookRunResult {
127
136
  * Mutates a CLONE: derive hooks set fields; a validate hook that returns falsy
128
137
  * throws HookRejection (→ the caller maps to a 422 and the tx rolls back).
129
138
  * Throws HookEvalError on a runtime fault (also a clean 422, never a 500). */
130
- export declare function runWriteHooks(hooks: Record<string, HookDef> | undefined, collection: string, phase: 'create' | 'update', data: Record<string, unknown>, before: Record<string, unknown> | null, now: string): HookRunResult;
139
+ export declare function runWriteHooks(hooks: Record<string, HookDef> | undefined, collection: string, phase: 'create' | 'update', data: Record<string, unknown>, before: Record<string, unknown> | null, now: string,
140
+ /** the VERIFIED end-user caller (endUserId + principal + roles) in
141
+ * end-user mode; omitted / null on a server write (expressions see null). */
142
+ caller?: HookCaller | null): HookRunResult;
131
143
  export interface ReadHookResult {
132
144
  /** Surviving rows in input order. Each is a SHALLOW COPY with a copied `data`
133
145
  * bag — the caller's rows are never mutated and nothing here is persisted. */
@@ -154,8 +166,8 @@ export interface ReadHookResult {
154
166
  * fire here, and read events never fire in runWriteHooks. */
155
167
  export declare function runReadHooks(hooks: Record<string, HookDef> | undefined, collection: string, rows: ReadonlyArray<Record<string, unknown>>, now: string,
156
168
  /** verified caller/session context (guide ch. 7) — read-only, exposed to
157
- * expressions as `caller.endUserId` / `caller.principal`. Omitted ⇒ null
158
- * (server-caller mode). */
169
+ * expressions as `caller.endUserId` / `caller.principal` / `caller.roles`.
170
+ * Omitted ⇒ null (server-caller mode). */
159
171
  caller?: HookCaller | null): ReadHookResult;
160
172
  export interface ExprRef {
161
173
  root: 'item' | 'before' | 'now' | 'caller';
@@ -35,4 +35,22 @@ interface CdcRuleLike {
35
35
  }
36
36
  /** Cross-field rule for CmsConfig.cdc (called from validateFeatureConfig). */
37
37
  export declare function validateCdcConfig(cdc: Record<string, CdcRuleLike> | undefined): string[];
38
+ /** What a payload:'full' change-data rule must know about its target
39
+ * collection's read gates. `readRoleFields`: fields with a non-empty
40
+ * `readRoles`; `endUserAccess`: the collection's end-user access mode. */
41
+ export interface CdcCollectionGates {
42
+ readRoleFields?: readonly string[];
43
+ endUserAccess?: 'readwrite' | 'read' | 'none';
44
+ }
45
+ /**
46
+ * A `payload: 'full'` change-data rule inlines the stored row into a realtime
47
+ * frame that every subscriber of the channel receives — it does not go through
48
+ * the read projection. So it must never target a collection whose reads are
49
+ * gated: a field with `readRoles` (the frame would carry the gated value), or
50
+ * an `endUserAccess` of 'read' / 'none' (the frame would bypass the
51
+ * collection's end-user gate). Pure; one problem line per offending ENABLED
52
+ * rule (an unknown collection is skipped). The safe shape is `payload: 'ids'`
53
+ * and a re-read through the cms, where every gate applies.
54
+ */
55
+ export declare function cdcFullFramesOverGatedCollections(cdc: unknown, collections: Record<string, CdcCollectionGates>): string[];
38
56
  export {};
@@ -218,6 +218,7 @@ declare const OidcProviderSchema: import("@sinclair/typebox").TObject<{
218
218
  email: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
219
219
  name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
220
220
  roles: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
221
+ rolePrefix: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
221
222
  }>>;
222
223
  allowedDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
223
224
  autoLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
@@ -275,6 +276,7 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
275
276
  email: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
276
277
  name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
277
278
  roles: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
279
+ rolePrefix: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
278
280
  }>>;
279
281
  allowedDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
280
282
  autoLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
@@ -319,6 +321,8 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
319
321
  breachedPasswords: import("@sinclair/typebox").TBoolean;
320
322
  captchaSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
321
323
  allowedRedirectOrigins: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
324
+ inviteOnly: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
325
+ allowedEmailDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
322
326
  }>>;
323
327
  }>;
324
328
  /** The security bag as persisted (present ⇒ leaf defaults applied). */
@@ -633,6 +637,7 @@ export declare const RagConfigSchema: import("@sinclair/typebox").TObject<{
633
637
  topK: import("@sinclair/typebox").TInteger;
634
638
  mode: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"hybrid">, import("@sinclair/typebox").TLiteral<"vector">, import("@sinclair/typebox").TLiteral<"keyword">]>;
635
639
  minScore: import("@sinclair/typebox").TNumber;
640
+ minSimilarity: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
636
641
  rerank: import("@sinclair/typebox").TBoolean;
637
642
  }>;
638
643
  boosts: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
package/dist/config.d.ts CHANGED
@@ -121,6 +121,17 @@ export interface CollectionDef {
121
121
  * `vxil push --allow-destructive`. Any other value fails `vxil plan` /
122
122
  * `vxil push` (it never falls back to 'readwrite'). */
123
123
  endUserAccess?: 'readwrite' | 'read' | 'none';
124
+ /** Role slugs one of which a VERIFIED end user must hold to WRITE this
125
+ * collection (create, update, $inc, delete, publish, transaction step,
126
+ * filtered delete, batch write, and an end-user delete's cascade into it) —
127
+ * otherwise `403 role_required`. Reads are unaffected. Roles are the
128
+ * session's verified role claims (its orgs roles — the
129
+ * same ones `readRoles` and `caller.roles` in hooks see). Server keys are
130
+ * never affected. Absent / `[]` = ungated. Up to 16 slugs
131
+ * (`^[a-z0-9][a-z0-9_-]{0,31}$`). Stored on the collection (model data, not
132
+ * a config leaf) and carried by BOTH push paths; widening (clearing, or
133
+ * adding a role) needs `vxil push --allow-destructive`. */
134
+ writeRoles?: string[];
124
135
  /** Per-record action buttons (guide ch. 4): `[{ key, label, fn }]`, stored on
125
136
  * the collection (model data, not a config leaf). The dashboard renders one
126
137
  * button per action on each record row; `POST /v1/cms/items/:coll/:id/actions/
package/dist/vxil.js CHANGED
@@ -3705,7 +3705,16 @@ var OidcProviderSchema = Type.Object({
3705
3705
  claims: Type.Optional(Type.Object({
3706
3706
  email: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
3707
3707
  name: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
3708
- roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 }))
3708
+ roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
3709
+ // G-38b: an optional namespace prepended to every mapped IdP role (e.g.
3710
+ // `idp-`), so an IdP group named like an app role (`finance`, `owner`)
3711
+ // arrives as `idp-finance` and cannot pass a `readRoles: ['finance']`
3712
+ // gate. Absent = the raw group names (the shipped behaviour). The prefixed
3713
+ // role still obeys the 32-char role bound (longer ones are dropped).
3714
+ // The alphabet is the cms readRoles / orgs role slug alphabet
3715
+ // (`^[a-z0-9][a-z0-9_-]*$`, cms-v1 READ_ROLE_RE), so a prefixed role can
3716
+ // itself be named in a gate — a `:`/`.`/uppercase prefix never could.
3717
+ rolePrefix: Type.Optional(Type.String({ minLength: 1, maxLength: 16, pattern: "^[a-z0-9][a-z0-9_-]{0,15}$" }))
3709
3718
  })),
3710
3719
  // when non-empty, the asserted email's domain MUST be listed (fail-closed:
3711
3720
  // no email ⇒ refused) → 403 oidc_domain_not_allowed; a listed domain also
@@ -3895,6 +3904,26 @@ var AuthConfigSchema = Type.Object({
3895
3904
  allowedRedirectOrigins: Type.Optional(Type.Array(
3896
3905
  Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }),
3897
3906
  { maxItems: 32 }
3907
+ )),
3908
+ // G-3 sign-up gating (2026-10-05). Both Optional with NO default, so an
3909
+ // existing manifest folds byte-identically and the gate is opt-in. When
3910
+ // EITHER is set, every self-service account-create path (password sign-up,
3911
+ // magic-link / OTP pre-register, the guest → email claim, social sign-in
3912
+ // with google/github/apple/facebook) is checked, fail-closed:
3913
+ // • inviteOnly — a new account only for an address the tenant already
3914
+ // put in its user registry server-side (POST /v1/users, an import);
3915
+ // • allowedEmailDomains — otherwise, only for an address at one of these
3916
+ // domains (exact, lowercase; a provider must assert the address
3917
+ // VERIFIED).
3918
+ // With either set, password sign-up is refused (it proves nothing about
3919
+ // the address — a stranger could squat an invitee's address), guest
3920
+ // sign-in is refused (a guest is nobody's invitee), and an existing
3921
+ // account is never affected. The generic OIDC issuer keeps its own gate
3922
+ // (providers.oidc.allowedDomains + IdP assignment). auth-v1 signupGate.ts.
3923
+ inviteOnly: Type.Optional(Type.Boolean()),
3924
+ allowedEmailDomains: Type.Optional(Type.Array(
3925
+ Type.String({ minLength: 3, maxLength: 253, pattern: "^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$" }),
3926
+ { maxItems: 32 }
3898
3927
  ))
3899
3928
  }))
3900
3929
  });
@@ -4494,6 +4523,12 @@ var RagConfigSchema = Type.Object({
4494
4523
  ),
4495
4524
  // drop retrieved chunks below this score BEFORE budgeting (0 = keep all).
4496
4525
  minScore: Type.Number({ default: 0 }),
4526
+ // drop retrieved chunks whose cosine `similarity` to the query is
4527
+ // below this floor (-1..1). Unlike minScore (a floor on the RRF rank
4528
+ // value, ~0.016-0.033 at the top) this is the relevance threshold a RAG
4529
+ // app means. Absent = no floor; a hit with no measured similarity (no
4530
+ // query vector: keyword mode) is never dropped by it.
4531
+ minSimilarity: Type.Optional(Type.Number({ minimum: -1, maximum: 1 })),
4497
4532
  // forward rerank:true to vector-search's post-RRF BYO rerank pass (it owns
4498
4533
  // the provider + key); inert until that pass exists/is configured there.
4499
4534
  rerank: Type.Boolean({ default: false })
@@ -5030,9 +5065,9 @@ function lowerAllTriggerBindings(def, name) {
5030
5065
  return bindings;
5031
5066
  }
5032
5067
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
5033
- var VXIL_CONFIG_PKG_VERSION = "0.12.0";
5034
- var VXIL_SDK_PKG_VERSION = "0.18.0";
5035
- var VXIL_CLI_PKG_VERSION = "0.18.0";
5068
+ var VXIL_CONFIG_PKG_VERSION = "0.13.0";
5069
+ var VXIL_SDK_PKG_VERSION = "0.19.0";
5070
+ var VXIL_CLI_PKG_VERSION = "0.19.0";
5036
5071
  function ensureScaffoldPackageJson(cwd, opts = {}) {
5037
5072
  const file = resolve(cwd, "package.json");
5038
5073
  const wanted = {
@@ -7617,6 +7652,15 @@ var KNOWN_SCOPES = [
7617
7652
  // so pre-existing keys keep working; mint this when a key should see usage
7618
7653
  // and nothing else.
7619
7654
  "usage:read",
7655
+ // Narrow READ-ONLY scope for the audit stream (G-15, 2026-10-05): GET
7656
+ // /v1/audit and GET /v1/audit/export accept it alongside 'features:read'
7657
+ // (any-of), so an ops/admin function or a dedicated key reads the audit log
7658
+ // WITHOUT holding features:read — which is also the door of
7659
+ // GET /v1/export/:feature (a whole-project export credential). Both routes
7660
+ // stay server-only; a public / thin-client key can never carry it
7661
+ // (PUBLIC_KEY_DENY_SCOPES), and a function receives it on its confined
7662
+ // control-plane callback token (CONTROL_PLANE_FUNCTION_SCOPES).
7663
+ "audit:read",
7620
7664
  "admin"
7621
7665
  ];
7622
7666
  var FUNCTION_SCOPE_AUDIENCE = {
@@ -7645,7 +7689,14 @@ var FUNCTION_SCOPE_AUDIENCE = {
7645
7689
  // CONTROL_PLANE_FUNCTION_SCOPES (scopesForFunctionAudience) so the same token
7646
7690
  // can never satisfy any other scope-gated control-plane route.
7647
7691
  users: "control-plane",
7648
- usage: "control-plane"
7692
+ usage: "control-plane",
7693
+ // G-15: the audit stream (/v1/audit, /v1/audit/export) is a control-plane
7694
+ // route too; `audit:read` joins the confined claim below
7695
+ audit: "control-plane",
7696
+ // webhooks-in (the inbound receiver's READ routes); the token is confined to
7697
+ // WEBHOOKS_FUNCTION_SCOPES, and webhooks:write stays in SCOPES_NOT_FOR_FUNCTIONS
7698
+ // (which wins over the prefix — see functionScopeAudience).
7699
+ webhooks: "webhooks"
7649
7700
  };
7650
7701
  var FUNCTION_SCOPE_PREFIX_ALIASES = {
7651
7702
  "rate-limits": "ratelimits",
@@ -7653,7 +7704,20 @@ var FUNCTION_SCOPE_PREFIX_ALIASES = {
7653
7704
  vector: "vector-search",
7654
7705
  feeds: "activity-feed"
7655
7706
  };
7656
- var CONTROL_PLANE_FUNCTION_SCOPES = ["users:read", "users:write", "usage:read"];
7707
+ var SCOPES_NOT_FOR_FUNCTIONS = {
7708
+ admin: "never grantable to a function (DENY_FUNCTION_SCOPES strips it at deploy)",
7709
+ "secrets:write": "never grantable to a function (self-integrity: DENY_FUNCTION_SCOPES strips it at deploy)",
7710
+ "features:write": "never grantable to a function (self-integrity: DENY_FUNCTION_SCOPES strips it at deploy)",
7711
+ "functions:write": "never grantable to a function (self-integrity: DENY_FUNCTION_SCOPES strips it at deploy)",
7712
+ "features:read": "GET /v1/features is privilege-independent (any authenticated principal) and no other route needs it from a function \u2014 the control-plane callback token is confined to users:*/usage:read",
7713
+ "functions:invoke": "there is no function\u2192function lane by design (no aud=functions token is ever minted) \u2014 chain work through a cms/webhook/queue trigger instead",
7714
+ "functions:read": "GET /v1/functions is privilege-independent and the manifest is deploy-time data \u2014 read your own config from vxil.config.ts, not at invoke",
7715
+ "webhooks:write": "webhook subscriptions are deploy-managed trigger wiring (auto-wired from bindings) \u2014 a function must not rewrite its own triggers (self-integrity, same class as functions:write)",
7716
+ "copilot:read": "copilot is an agent surface that acts on your features through its own typed tool routes; a function-driven copilot turn is not a supported callback lane \u2014 invoke copilot from your app, not from a function",
7717
+ "copilot:write": "copilot is an agent surface that acts on your features through its own typed tool routes; a function-driven copilot turn is not a supported callback lane \u2014 invoke copilot from your app, not from a function"
7718
+ };
7719
+ var CONTROL_PLANE_FUNCTION_SCOPES = ["users:read", "users:write", "usage:read", "audit:read"];
7720
+ var WEBHOOKS_FUNCTION_SCOPES = ["webhooks:read"];
7657
7721
  function canonicalFunctionScope(scope) {
7658
7722
  const i = scope.indexOf(":");
7659
7723
  if (i <= 0) return scope;
@@ -7663,6 +7727,7 @@ function canonicalFunctionScope(scope) {
7663
7727
  }
7664
7728
  function functionScopeAudience(scope) {
7665
7729
  const c = canonicalFunctionScope(scope);
7730
+ if (c in SCOPES_NOT_FOR_FUNCTIONS) return null;
7666
7731
  const i = c.indexOf(":");
7667
7732
  if (i <= 0) return null;
7668
7733
  return FUNCTION_SCOPE_AUDIENCE[c.slice(0, i)] ?? null;
@@ -7678,8 +7743,30 @@ function functionCallbackAudiences(scopes) {
7678
7743
  function scopesForFunctionAudience(aud, scopes) {
7679
7744
  const canon = [...new Set(scopes.map(canonicalFunctionScope))];
7680
7745
  if (aud === "control-plane") return canon.filter((s) => CONTROL_PLANE_FUNCTION_SCOPES.includes(s));
7746
+ if (aud === "webhooks") return canon.filter((s) => WEBHOOKS_FUNCTION_SCOPES.includes(s));
7681
7747
  return canon;
7682
7748
  }
7749
+ var PUBLIC_KEY_DENY_SCOPES = /* @__PURE__ */ new Set([
7750
+ "admin",
7751
+ "*",
7752
+ "users:read",
7753
+ "users:write",
7754
+ "features:read",
7755
+ "features:write",
7756
+ "secrets:write",
7757
+ // G-15: the tenant-wide audit stream has no end-user owner dimension
7758
+ "audit:read",
7759
+ "functions:read",
7760
+ "functions:write",
7761
+ "jobs:read",
7762
+ "jobs:write",
7763
+ "webhooks:read",
7764
+ "webhooks:write",
7765
+ "ratelimits:write",
7766
+ "payments:write",
7767
+ "notifications:send",
7768
+ "auth:write"
7769
+ ]);
7683
7770
  var TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
7684
7771
  var FUNCTION_SOURCE_MAX_BYTES = 512e3;
7685
7772
  var FN_PLATFORM_DEFAULT_CPU_MS = 3e4;
@@ -8727,6 +8814,15 @@ function indexArmNote(rows) {
8727
8814
  if (rows === null) return "arms inline or through the re-index push runs (live row count unavailable)";
8728
8815
  return rows <= EQ_INDEX_INLINE_MAX_ROWS ? `arms inline (${rows} rows)` : `needs reindex (${rows} rows; push runs it)`;
8729
8816
  }
8817
+ function writeRolesOf(v) {
8818
+ if (!Array.isArray(v)) return [];
8819
+ return [...new Set(v.filter((r) => typeof r === "string"))].sort();
8820
+ }
8821
+ function writeRolesWidens(declared, remote) {
8822
+ if (remote.length === 0) return false;
8823
+ if (declared.length === 0) return true;
8824
+ return declared.some((r) => !remote.includes(r));
8825
+ }
8730
8826
  function endUserAccessOf(v) {
8731
8827
  return v === "read" || v === "none" ? v : "readwrite";
8732
8828
  }
@@ -8828,6 +8924,7 @@ async function readRemote2(api) {
8828
8924
  ownerField: c.owner_field ?? null,
8829
8925
  public: c.public === true,
8830
8926
  endUserAccess: endUserAccessOf(c.end_user_access),
8927
+ writeRoles: writeRolesOf(c.write_roles),
8831
8928
  actions: normalizeActions(c.actions),
8832
8929
  fields: Array.isArray(c.fields) ? c.fields : []
8833
8930
  });
@@ -8881,6 +8978,10 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8881
8978
  if (v !== void 0 && v !== "readwrite" && v !== "read" && v !== "none") {
8882
8979
  throw new Error(`cms.collections.${name}.endUserAccess must be 'readwrite' | 'read' | 'none' (got ${JSON.stringify(v)})`);
8883
8980
  }
8981
+ const wr = def.writeRoles;
8982
+ if (wr !== void 0 && (!Array.isArray(wr) || wr.some((r) => typeof r !== "string"))) {
8983
+ throw new Error(`cms.collections.${name}.writeRoles must be an array of role slugs (got ${JSON.stringify(wr)})`);
8984
+ }
8884
8985
  }
8885
8986
  const read = await readRemote2(api);
8886
8987
  if (read.unavailable) {
@@ -9043,6 +9144,24 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9043
9144
  });
9044
9145
  }
9045
9146
  }
9147
+ const declaredRoles = writeRolesOf(def.writeRoles);
9148
+ if (stableStringify(declaredRoles) !== stableStringify(rc.writeRoles)) {
9149
+ const widens = writeRolesWidens(declaredRoles, rc.writeRoles);
9150
+ const fmt = (r) => r.length ? `[${r.join(", ")}]` : "none (ungated)";
9151
+ if (!widens || allowDestructive) {
9152
+ collChanges.push({
9153
+ collection: name,
9154
+ kind: "set-write-roles",
9155
+ detail: `${name} writeRoles \u2192 ${fmt(declaredRoles)} (was ${fmt(rc.writeRoles)})` + (widens ? " \u2014 WIDENS who may write (full-config reconciliation)" : " (narrows end-user writes)")
9156
+ });
9157
+ } else {
9158
+ collChanges.push({
9159
+ collection: name,
9160
+ kind: "warn",
9161
+ detail: `${name}: writeRoles are ${fmt(rc.writeRoles)} remotely but ${fmt(declaredRoles)} in config \u2014 that WIDENS which end users may write, so it is NOT applied. Declare the remote roles to keep them, or re-run with --allow-destructive to widen.`
9162
+ });
9163
+ }
9164
+ }
9046
9165
  const declaredActions = normalizeActions(def.actions);
9047
9166
  if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
9048
9167
  collChanges.push({
@@ -9116,6 +9235,8 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9116
9235
  if (def.public === true) body.public = true;
9117
9236
  const access = endUserAccessOf(def.endUserAccess);
9118
9237
  if (access !== "readwrite") body.end_user_access = access;
9238
+ const roles = writeRolesOf(def.writeRoles);
9239
+ if (roles.length > 0) body.write_roles = roles;
9119
9240
  if (def.actions && def.actions.length > 0) body.actions = normalizeActions(def.actions);
9120
9241
  const res = await api("POST", "/v1/cms/collections", body);
9121
9242
  if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
@@ -9163,6 +9284,14 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9163
9284
  throw new Error(`cms: set endUserAccess on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
9164
9285
  }
9165
9286
  applied++;
9287
+ } else if (c.kind === "set-write-roles") {
9288
+ const target = writeRolesOf(collections[c.collection].writeRoles);
9289
+ const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { write_roles: target });
9290
+ if (res.status !== 200 && res.status !== 201) {
9291
+ const e = res.body.error ?? {};
9292
+ throw new Error(`cms: set writeRoles on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
9293
+ }
9294
+ applied++;
9166
9295
  } else if (c.kind === "set-actions") {
9167
9296
  const target = normalizeActions(collections[c.collection].actions);
9168
9297
  const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { actions: target });
@@ -9228,7 +9357,7 @@ function formatCmsChanges(changes) {
9228
9357
  if (c.kind === "destructive") return ` ! ${c.detail}`;
9229
9358
  if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
9230
9359
  if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
9231
- if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access") return ` ~ ${c.detail}`;
9360
+ if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles") return ` ~ ${c.detail}`;
9232
9361
  return ` + ${c.detail}`;
9233
9362
  }).join("\n");
9234
9363
  }
@@ -9407,7 +9536,7 @@ var API_CHEATSHEET = [
9407
9536
  // realtime
9408
9537
  { feature: "realtime", what: "publish an event to a channel", cmd: `vxil api POST /v1/realtime/channels/<channel>/publish '{"event":"ping","data":{}}'` },
9409
9538
  // webhooks
9410
- { feature: "webhooks", what: "inbound sources add / list / rm / test", cmd: "vxil webhooks sources add --provider stripe --name prod [--forward-url https://\u2026] \xB7 sources list \xB7 sources rm <id> [--yes] \xB7 sources test <id>" },
9539
+ { feature: "webhooks", what: "inbound sources add / list / rm / test", cmd: "vxil webhooks sources add --provider stripe --name prod [--forward-url https://\u2026 | --target-function <fn>] [--provider hmac --verify <json>] \xB7 sources list \xB7 sources rm <id> [--yes] \xB7 sources test <id>" },
9411
9540
  { feature: "webhooks", what: "replay an inbound event / see its delivery runs", cmd: "vxil webhooks events replay <event_id> \xB7 vxil webhooks events runs <event_id>" },
9412
9541
  { feature: "webhooks", what: "declare outbound subscriptions in vxil.config (converged by push)", cmd: "features.webhooks.subscriptions[] in vxil.config.ts \u2192 vxil push" },
9413
9542
  { feature: "webhooks", what: "add an outbound subscription by hand (undeclared \u2014 `vxil diff --strict-api-state` reports it)", cmd: `vxil api POST /v1/webhooks/subscriptions '{"target_url":"https://\u2026","event_prefixes":["cms."]}'` },
@@ -10789,7 +10918,8 @@ async function filesRm(api, objectId) {
10789
10918
  if (!ok2xx(r)) throw new Error(`could not delete '${objectId}' (${apiErrText(r)})`);
10790
10919
  return { object_id: objectId, deleted: true };
10791
10920
  }
10792
- var WEBHOOK_PROVIDERS = ["stripe", "paddle", "github", "slack", "revenuecat", "generic"];
10921
+ var WEBHOOK_PROVIDERS = ["stripe", "paddle", "github", "slack", "revenuecat", "generic", "hmac"];
10922
+ var TARGET_FUNCTION_RE = /^[a-z][a-z0-9-]{0,47}$/;
10793
10923
  function parseSourceAdd(f) {
10794
10924
  const provider = (f.provider ?? "").trim();
10795
10925
  if (!WEBHOOK_PROVIDERS.includes(provider)) {
@@ -10800,14 +10930,48 @@ function parseSourceAdd(f) {
10800
10930
  if (name.length > 200) return { error: `--name is at most 200 characters (got ${name.length})` };
10801
10931
  const fwd = f.forwardUrl?.trim();
10802
10932
  if (fwd !== void 0 && fwd !== "" && !/^https:\/\//i.test(fwd)) return { error: "--forward-url must be an https:// URL" };
10803
- return { body: { provider, name, ...fwd ? { forward_url: fwd } : {} } };
10933
+ const fn = f.targetFunction?.trim();
10934
+ if (fn !== void 0 && fn !== "") {
10935
+ if (!TARGET_FUNCTION_RE.test(fn)) return { error: `--target-function must be a function name matching ${TARGET_FUNCTION_RE.source}` };
10936
+ if (fwd) return { error: "set --forward-url OR --target-function, not both (one source, one destination)" };
10937
+ }
10938
+ let verify;
10939
+ const rawVerify = f.verify?.trim();
10940
+ if (provider === "hmac") {
10941
+ if (!rawVerify) {
10942
+ return { error: `--provider hmac needs --verify '<json>', e.g. --verify '{"header":"X-Shopify-Hmac-Sha256","encoding":"base64"}'` };
10943
+ }
10944
+ try {
10945
+ const v = JSON.parse(rawVerify);
10946
+ if (!v || typeof v !== "object" || Array.isArray(v)) return { error: "--verify must be a JSON object" };
10947
+ verify = v;
10948
+ } catch {
10949
+ return { error: "--verify must be valid JSON (a single-quoted object)" };
10950
+ }
10951
+ } else if (rawVerify) {
10952
+ return { error: "--verify is accepted only with --provider hmac" };
10953
+ }
10954
+ return {
10955
+ body: {
10956
+ provider,
10957
+ name,
10958
+ ...fwd ? { forward_url: fwd } : {},
10959
+ ...fn ? { target_function: fn } : {},
10960
+ ...verify ? { verify } : {}
10961
+ }
10962
+ };
10804
10963
  }
10805
10964
  async function sourcesAdd(api, body) {
10806
10965
  const r = await api("POST", "/v1/webhooks/sources", body);
10807
10966
  if (!ok2xx(r)) throw new Error(`could not register the source (${apiErrText(r)})`);
10808
10967
  const d = dataOf(r);
10809
10968
  if (!d.source_id || !d.receiver_url_path) throw new Error("the source route returned no source_id/receiver_url_path");
10810
- return { source_id: d.source_id, receiver_url_path: d.receiver_url_path, provider: d.provider ?? body.provider };
10969
+ return {
10970
+ source_id: d.source_id,
10971
+ receiver_url_path: d.receiver_url_path,
10972
+ provider: d.provider ?? body.provider,
10973
+ ...body.target_function ? { target_function: d.target_function ?? body.target_function } : {}
10974
+ };
10811
10975
  }
10812
10976
  function sourceAddStdout(s, edgeBase, json2) {
10813
10977
  if (json2) return JSON.stringify({ source_id: s.source_id, provider: s.provider, receiver_url_path: s.receiver_url_path, receiver_url: `${edgeBase.replace(/\/$/, "")}${s.receiver_url_path}` });
@@ -10816,9 +10980,14 @@ function sourceAddStdout(s, edgeBase, json2) {
10816
10980
  function sourceAddNotices(s) {
10817
10981
  return [
10818
10982
  `\u2713 registered ${s.provider} source ${s.source_id} \u2014 the receiver URL above is shown ONCE; paste it into the provider's webhook settings`,
10819
- `a signing secret goes in \`vxil secrets set webhooks/source_${s.source_id}\` (a 'generic' source needs none); \`vxil listen --source ${s.source_id} --forward-to <url>\` forwards its events locally`
10983
+ `a signing secret goes in \`vxil secrets set webhooks/source_${s.source_id.toLowerCase()}\` (a 'generic' source needs none); \`vxil listen --source ${s.source_id} --forward-to <url>\` forwards its events locally`,
10984
+ ...s.target_function ? [`verified events are delivered to function ${s.target_function}: it needs a bare webhook binding (triggers: [{ kind: 'webhook' }]); a cron-only function is woken as a cron tick WITHOUT the event, and a function with neither binding skips it`] : []
10820
10985
  ];
10821
10986
  }
10987
+ function sourceDestination(s) {
10988
+ if (s.target_function) return `fn:${s.target_function}`;
10989
+ return s.forward_url || null;
10990
+ }
10822
10991
  async function sourcesList(api) {
10823
10992
  const r = await api("GET", "/v1/webhooks/sources");
10824
10993
  if (!ok2xx(r)) throw new Error(`could not list the sources (${apiErrText(r)})`);
@@ -11107,10 +11276,20 @@ function boundedData(data2, maxBytes, onTruncate) {
11107
11276
  const bytes = new TextEncoder().encode(text).length;
11108
11277
  if (bytes > maxBytes) {
11109
11278
  onTruncate?.(bytes);
11110
- return { truncated: true };
11279
+ return truncatedStub(data2);
11111
11280
  }
11112
11281
  return data2;
11113
11282
  }
11283
+ function truncatedStub(data2) {
11284
+ const stub = { truncated: true };
11285
+ if (data2 && typeof data2 === "object" && !Array.isArray(data2)) {
11286
+ for (const k of ["vxil_event_id", "source_id"]) {
11287
+ const v = data2[k];
11288
+ if (typeof v === "string" && /^[A-Za-z0-9_]{1,64}$/.test(v)) stub[k] = v;
11289
+ }
11290
+ }
11291
+ return stub;
11292
+ }
11114
11293
  function webhookPayload(inner, maxBytes = WEBHOOK_PAYLOAD_MAX_BYTES) {
11115
11294
  return {
11116
11295
  event: inner.webhook_event,
@@ -11361,6 +11540,7 @@ function controlPlaneFunctionScope(method, pathname) {
11361
11540
  const read = m === "GET" || m === "HEAD";
11362
11541
  if (pathname === "/v1/users" || pathname.startsWith("/v1/users/")) return read ? "users:read" : "users:write";
11363
11542
  if (pathname === "/v1/usage") return read ? "usage:read" : null;
11543
+ if (pathname === "/v1/audit" || pathname === "/v1/audit/export") return read ? "audit:read" : null;
11364
11544
  return null;
11365
11545
  }
11366
11546
  function json(body, status, headers = {}) {
@@ -11774,6 +11954,16 @@ export type VxilFilterJson = string | number | boolean | { $eq?: string | number
11774
11954
  }
11775
11955
 
11776
11956
  // ../../workers/mcp-v1/src/tools.ts
11957
+ function auditExportQuery(a) {
11958
+ const qs = new URLSearchParams();
11959
+ for (const k of ["subject", "since", "until", "after_id"]) {
11960
+ const v = a[k];
11961
+ if (typeof v === "string" && v) qs.set(k, v);
11962
+ }
11963
+ const n = Number(a.limit);
11964
+ qs.set("limit", String(Number.isFinite(n) ? Math.min(Math.max(Math.trunc(n), 1), 1e3) : 500));
11965
+ return qs.toString();
11966
+ }
11777
11967
  var TOOLS = [
11778
11968
  {
11779
11969
  name: "users_upsert",
@@ -12278,7 +12468,7 @@ var TOOLS = [
12278
12468
  {
12279
12469
  name: "jobs_replay_run",
12280
12470
  feature: "jobs",
12281
- description: "Replay a finished plain run (dead / failed / cancelled / succeeded): a NEW run with the same job, target and payload is queued (replayed_from names the original). The target receives it like any delivery \u2014 dedupe on run_id if it must not act twice. A generation run is not replayable (409 not_replayable): enqueue the generation again.",
12471
+ description: "Replay a finished plain run (dead / failed / cancelled / succeeded): a NEW run with the same job, target and payload is queued (replayed_from names the original). The target receives it like any delivery \u2014 dedupe on run_id if it must not act twice. A generation run is not replayable (409 not_replayable): enqueue the generation again. A redacted or erased run is not replayable either (409 run_redacted): its payload is gone.",
12282
12472
  inputSchema: {
12283
12473
  type: "object",
12284
12474
  properties: { run_id: { type: "string" } },
@@ -12287,6 +12477,20 @@ var TOOLS = [
12287
12477
  method: "POST",
12288
12478
  path: (a) => `/v1/jobs/runs/${encodeURIComponent(String(a.run_id))}/replay`
12289
12479
  },
12480
+ {
12481
+ name: "jobs_redact_runs",
12482
+ feature: "jobs",
12483
+ description: "Redact FINISHED runs that hold personal data (a privacy request for runs nothing links to a user, e.g. a plain queue run or an async function call): payload becomes { erased: true }; result, progress, callback body and error text are cleared; a generation's provider request body/headers are dropped and its credit hold's user id is pseudonymized (unless its settle is still owed). The run row stays (state, timings, job_name). Send EXACTLY ONE of run_ids (\u2264500; open runs are left alone and counted in skipped_open) or user_id (the generation runs whose credit hold names that user \u2014 one page of \u2264500 per call; done:false \u2192 call again). Idempotent; irreversible; a redacted run no longer replays. concurrency_key, debounce_key, idempotency_key and target_url are NOT cleared \u2014 keep user ids out of them. Answers { redacted, skipped_open, done }.",
12484
+ inputSchema: {
12485
+ type: "object",
12486
+ properties: {
12487
+ run_ids: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 500, description: "Finished runs to redact." },
12488
+ user_id: { type: "string", description: "Redact the finished generation runs whose credit hold names this end-user." }
12489
+ }
12490
+ },
12491
+ method: "POST",
12492
+ path: "/v1/jobs/runs/redact"
12493
+ },
12290
12494
  {
12291
12495
  name: "jobs_emit_event",
12292
12496
  feature: "jobs",
@@ -12645,7 +12849,7 @@ var TOOLS = [
12645
12849
  {
12646
12850
  name: "cms_list_collections",
12647
12851
  feature: "cms",
12648
- description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only); server keys are never affected.",
12852
+ description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago.",
12649
12853
  inputSchema: { type: "object", properties: {} },
12650
12854
  method: "GET",
12651
12855
  path: "/v1/cms/collections"
@@ -12653,13 +12857,23 @@ var TOOLS = [
12653
12857
  {
12654
12858
  name: "cms_create_item",
12655
12859
  feature: "cms",
12656
- description: "Create a content item in a collection. data must match the collection field defs (call cms_list_collections first). status draft|published.",
12860
+ description: "Create a content item in a collection. data must match the collection field defs (call cms_list_collections first). status draft|published. Upsert: pass onConflict {field, patch} where field is a unique field present in data \u2014 if a live item already holds that value, patch is merged into it instead (200, audited and hooked as an update; null clears a field), else the item is created (201).",
12657
12861
  inputSchema: {
12658
12862
  type: "object",
12659
12863
  properties: {
12660
12864
  collection: { type: "string" },
12661
12865
  data: { type: "object", additionalProperties: true },
12662
- status: { type: "string", enum: ["draft", "published"], default: "draft" }
12866
+ status: { type: "string", enum: ["draft", "published"], default: "draft" },
12867
+ onConflict: {
12868
+ type: "object",
12869
+ description: "Upsert by a declared unique field: { field: <unique field in data>, patch: { \u2026merge-patch applied to the existing item } }.",
12870
+ properties: {
12871
+ field: { type: "string" },
12872
+ patch: { type: "object", additionalProperties: true }
12873
+ },
12874
+ required: ["field", "patch"],
12875
+ additionalProperties: false
12876
+ }
12663
12877
  },
12664
12878
  required: ["collection", "data"]
12665
12879
  },
@@ -12669,7 +12883,7 @@ var TOOLS = [
12669
12883
  {
12670
12884
  name: "cms_query_items",
12671
12885
  feature: "cms",
12672
- description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). Equality ($eq / bare value / $in) is index-served on slot-indexed and `indexed` fields; on other fields it is a bounded scan (same results). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). Paging: pass the returned next_cursor back as cursor WITH THE SAME filter and sort (works for the default order and for any slot/timestamp sort; not for a dotted join sort). $expand inlines relation/file fields. count=true returns {count} instead of a page (filter only \u2014 no sort/limit/cursor/$expand).',
12886
+ description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in ($ne counts an absent/null field as not equal) $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). Equality ($eq / bare value / $in) is index-served on slot-indexed and `indexed` fields; on other fields it is a bounded scan (same results). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). Paging: pass the returned next_cursor back as cursor WITH THE SAME filter and sort (works for the default order and for any slot/timestamp sort; not for a dotted join sort). $expand inlines relation/file fields. count=true returns {count} instead of a page (filter only \u2014 no sort/limit/cursor/$expand).',
12673
12887
  inputSchema: {
12674
12888
  type: "object",
12675
12889
  properties: {
@@ -13140,6 +13354,46 @@ var TOOLS = [
13140
13354
  method: "DELETE",
13141
13355
  path: (a) => `/v1/functions/${encodeURIComponent(String(a.name))}`
13142
13356
  },
13357
+ {
13358
+ name: "functions_list",
13359
+ feature: "functions",
13360
+ description: "The project's deployed functions: name, endpoint (/v1/fn/<name>), trigger bindings, scopes, limits and the runtime each runs under, plus `enabled` (false while the project's functions are paused as a whole \u2014 invokes then answer 404) and `cpuMsCeiling`. Read-only; any authenticated key may read it. Use it before functions_invoke or functions_logs to get the exact names.",
13361
+ inputSchema: { type: "object", properties: {} },
13362
+ method: "GET",
13363
+ path: "/v1/functions"
13364
+ },
13365
+ {
13366
+ name: "functions_invoke",
13367
+ feature: "functions",
13368
+ description: "Invoke ONE deployed function synchronously (POST /v1/fn/<name>) and return its own response verbatim (not a {data, meta} envelope). Every argument other than `name` is sent as the JSON request body, which the function reads as its payload. At-least-once semantics are the function's own: send an `idempotency_key` argument only if the function reads one from its payload. 404 when the name is not deployed; 403 without functions:invoke. Runs tenant code \u2014 it is metered like any invoke.",
13369
+ inputSchema: {
13370
+ type: "object",
13371
+ properties: {
13372
+ name: { type: "string", description: "The deployed function name (lowercase, e.g. on-payment)." }
13373
+ },
13374
+ required: ["name"],
13375
+ additionalProperties: true
13376
+ },
13377
+ method: "POST",
13378
+ path: (a) => `/v1/fn/${encodeURIComponent(String(a.name))}`,
13379
+ pathArgs: ["name"]
13380
+ },
13381
+ {
13382
+ name: "functions_logs",
13383
+ feature: "functions",
13384
+ description: "Log records of ONE deployed function, one per invocation (ts, outcome, wall_ms, cpu_ms, console lines, exceptions), oldest first, with `next_since` \u2014 pass it back as `since` to read only newer records (a tail). Without `since`: the most recent records of the last 24 hours. Kept 14 days. Needs functions:read.",
13385
+ inputSchema: {
13386
+ type: "object",
13387
+ properties: {
13388
+ name: { type: "string", description: "The deployed function name." },
13389
+ since: { type: "string", description: "ISO timestamp: only lines after it (the previous call's next_since)." }
13390
+ },
13391
+ required: ["name"]
13392
+ },
13393
+ method: "GET",
13394
+ path: (a) => `/v1/functions/${encodeURIComponent(String(a.name))}/logs`,
13395
+ queryArgs: ["since"]
13396
+ },
13143
13397
  {
13144
13398
  name: "webhooks_test_subscription",
13145
13399
  feature: "webhooks",
@@ -13155,7 +13409,7 @@ var TOOLS = [
13155
13409
  {
13156
13410
  name: "webhooks_test_source",
13157
13411
  feature: "webhooks",
13158
- description: "Send a sample inbound envelope to a source's forward_url through the real delivery path (jobs signing + SSRF guard). 200 \u2192 inline outcome; 202 \u2192 still in flight; 409 no_forward_url when the source has no forward target.",
13412
+ description: "Send a sample inbound envelope to a source's destination (its forward_url, or its target_function) through the real delivery path (jobs signing + SSRF guard). 200 \u2192 inline outcome; 202 \u2192 still in flight; 409 no_forward_url when the source has neither.",
13159
13413
  inputSchema: {
13160
13414
  type: "object",
13161
13415
  properties: { source_id: { type: "string" } },
@@ -13184,6 +13438,25 @@ var TOOLS = [
13184
13438
  method: "GET",
13185
13439
  path: "/v1/webhooks/events/catalog"
13186
13440
  },
13441
+ {
13442
+ name: "webhooks_list_events",
13443
+ feature: "webhooks",
13444
+ description: "Received INBOUND webhook events (what providers POSTed to your sources): event id, source, provider, provider event id, type, payload, whether the signature verified, and the forward status. Newest first, paged with `cursor` (next_cursor); or pass `after` (an event id \u2014 your watermark) to read OLDEST first from just after it, storing `next_after` (has_more says another page waits; the after drain answers only events at least 10 s old, so a stored watermark never skips a late-committing event). `event_id` reads exactly one event. Filter by source_id and status. Needs webhooks:read.",
13445
+ inputSchema: {
13446
+ type: "object",
13447
+ properties: {
13448
+ source_id: { type: "string" },
13449
+ status: { type: "string", enum: ["received", "forwarding", "forwarded"] },
13450
+ cursor: { type: "string", description: "next_cursor of the previous (newest-first) page." },
13451
+ after: { type: "string", description: "An event id: answer the events after it, oldest first. Exclusive with cursor." },
13452
+ event_id: { type: "string", description: "Exactly this event." },
13453
+ limit: { type: "number", default: 50, description: "1..200" }
13454
+ }
13455
+ },
13456
+ method: "GET",
13457
+ path: "/v1/webhooks/events",
13458
+ queryArgs: ["source_id", "status", "cursor", "after", "event_id", "limit"]
13459
+ },
13187
13460
  {
13188
13461
  name: "feeds_add_activity",
13189
13462
  feature: "activity-feed",
@@ -13452,7 +13725,8 @@ var TOOLS = [
13452
13725
  provider: { type: "string" },
13453
13726
  model: { type: "string" },
13454
13727
  input: { type: "object", additionalProperties: true, description: "Extra template vars beyond {{query}}/{{context}}." },
13455
- user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." }
13728
+ user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." },
13729
+ min_similarity: { type: "number", minimum: -1, maximum: 1, description: "Ground only on chunks whose cosine similarity to the query is at least this (-1..1). Overrides rag config retrieval.minSimilarity." }
13456
13730
  },
13457
13731
  required: ["query"]
13458
13732
  },
@@ -13462,7 +13736,7 @@ var TOOLS = [
13462
13736
  {
13463
13737
  name: "rag_search",
13464
13738
  feature: "rag",
13465
- description: "Retrieval-only grounding preview: the exact chunks rag_answer would ground on, with rerank + metadata boosts applied \u2014 no generation, no token spend. Use it to inspect/tune retrieval (boost weights, rerank, min_score) before paying for an answer.",
13739
+ description: "Retrieval-only grounding preview: the exact chunks rag_answer would ground on, with rerank + metadata boosts applied \u2014 no generation, no token spend. Use it to inspect/tune retrieval (boost weights, rerank, min_similarity) before paying for an answer.",
13466
13740
  inputSchema: {
13467
13741
  type: "object",
13468
13742
  properties: {
@@ -13473,7 +13747,8 @@ var TOOLS = [
13473
13747
  filter: { type: "object", additionalProperties: true, description: "Metadata equality filter applied to retrieval." },
13474
13748
  rerank: { type: "boolean", description: "Forward rerank:true to vector-search's BYO rerank pass (inert until configured there)." },
13475
13749
  boosts: { type: "object", additionalProperties: true, description: "Per-metadata-field boost specs: { field: { kind:'recency', halfLifeDays?, weight? } | { kind:'value', weights:{\u2026} } }." },
13476
- min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score. score is a rank value (about 0.016-0.033 at the top), so a floor above ~0.033 drops every hit; judge relevance by each hit's similarity instead." }
13750
+ min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score. score is a rank value (about 0.016-0.033 at the top), so a floor above ~0.033 drops every hit; use min_similarity for a relevance threshold." },
13751
+ min_similarity: { type: "number", minimum: -1, maximum: 1, description: "Drop hits whose cosine similarity to the query is below this (-1..1), e.g. 0.5. Overrides rag config retrieval.minSimilarity; a hit with no measured similarity (keyword mode) is kept." }
13477
13752
  },
13478
13753
  required: ["query"]
13479
13754
  },
@@ -13594,6 +13869,41 @@ var TOOLS = [
13594
13869
  method: "GET",
13595
13870
  path: "/v1/usage"
13596
13871
  },
13872
+ {
13873
+ name: "audit_query",
13874
+ description: "The project's audit trail, newest first: every write and lifecycle event (id, event, actor, surface, request_id, payload, created_at), paged with `cursor` (next_cursor). `subject` narrows to the events about ONE thing: a user id (payload.user_id / payload.id), a cms record (payload.item_id \u2014 the record's history), a verified end user (payload.end_user_id \u2014 what that person changed through a thin-client key or a function acting for them, and their payments events), or a principal (actor). Read-only; server keys only (refused in end-user mode). Needs audit:read or features:read.",
13875
+ inputSchema: {
13876
+ type: "object",
13877
+ properties: {
13878
+ subject: { type: "string", description: "A user id, cms item id, end-user id or actor to filter by." },
13879
+ since: { type: "string", description: "ISO timestamp lower bound." },
13880
+ cursor: { type: "string", description: "next_cursor of the previous page." },
13881
+ limit: { type: "number", default: 50, description: "1..200" }
13882
+ }
13883
+ },
13884
+ method: "GET",
13885
+ path: "/v1/audit",
13886
+ queryArgs: ["subject", "since", "cursor", "limit"]
13887
+ },
13888
+ {
13889
+ name: "audit_export",
13890
+ description: "Export the audit trail OLDEST first as NDJSON (one event per line) for a window \u2014 the evidence/compliance read. Resume with `after_id` = the id of the last line. Same `subject` filter as audit_query. At most 1000 rows per call from this tool (default 500) so the answer fits an agent's context; page with after_id. Read-only; server keys only. Needs audit:read or features:read.",
13891
+ inputSchema: {
13892
+ type: "object",
13893
+ properties: {
13894
+ subject: { type: "string", description: "A user id, cms item id, end-user id or actor to filter by." },
13895
+ since: { type: "string", description: "ISO timestamp lower bound (inclusive)." },
13896
+ until: { type: "string", description: "ISO timestamp upper bound (exclusive)." },
13897
+ after_id: { type: "string", description: "Continue after this audit id (the last line of the previous call)." },
13898
+ limit: { type: "number", default: 500, description: "1..1000" }
13899
+ }
13900
+ },
13901
+ method: "GET",
13902
+ // The REST route allows 10 000 rows; an MCP answer is model context, so
13903
+ // the tool clamps the page (auditExportQuery builds the whole query; the
13904
+ // path stays one literal so the trinity-drift scanner reads the route).
13905
+ path: (a) => `/v1/audit/export?${auditExportQuery(a)}`
13906
+ },
13597
13907
  {
13598
13908
  name: "copilot_send_message",
13599
13909
  feature: "copilot",
@@ -14289,6 +14599,22 @@ function parseKeyEnv(raw) {
14289
14599
  if (raw === "live" || raw === "test") return { env: raw };
14290
14600
  return { error: `--env must be 'live' or 'test' (got '${raw}') \u2014 this is the key's env prefix, not the project's environment label` };
14291
14601
  }
14602
+ function parseKeyClass(rawClass, endUserRequired) {
14603
+ if (rawClass !== void 0 && rawClass !== "server" && rawClass !== "public") {
14604
+ return { error: `--class must be 'server' or 'public' (got '${rawClass}') \u2014 public = a thin-client key for a browser / mobile app (end_user_required)` };
14605
+ }
14606
+ if (rawClass === "server" && endUserRequired) {
14607
+ return { error: "--end-user-required makes a public (thin-client) key \u2014 drop it, or use --class public" };
14608
+ }
14609
+ return { keyClass: endUserRequired ? "public" : rawClass ?? "server" };
14610
+ }
14611
+ function checkPublicKeyScopes(scopes) {
14612
+ const denied = scopes.filter((s) => PUBLIC_KEY_DENY_SCOPES.has(s));
14613
+ if (!denied.length) return null;
14614
+ return {
14615
+ error: `a public (thin-client) key cannot carry server-only scope${denied.length > 1 ? "s" : ""}: ${denied.join(", ")} \u2014 keep the public key to end-user data scopes (e.g. cms:read,cms:write,files:write,realtime:write,auth:signin) and mint a separate server key for your backend`
14616
+ };
14617
+ }
14292
14618
  function parseKeyName(raw) {
14293
14619
  const name = (raw ?? "").trim();
14294
14620
  if (!name) return { error: "--name is required \u2014 a label for this key, e.g. --name web-worker" };
@@ -14299,7 +14625,7 @@ async function mintKeyViaSession(dash, cookie, tenantId, req) {
14299
14625
  const r = await dash(
14300
14626
  "POST",
14301
14627
  `/dashboard/tenants/${tenantId}/api-keys`,
14302
- { name: req.name, scopes: req.scopes, env: req.env },
14628
+ { name: req.name, scopes: req.scopes, env: req.env, ...req.keyClass === "public" ? { end_user_required: true } : {} },
14303
14629
  cookie
14304
14630
  );
14305
14631
  if (r.status === 401) return { unauthorized: true };
@@ -14315,7 +14641,10 @@ async function mintKeyViaSession(dash, cookie, tenantId, req) {
14315
14641
  scopes: d.scopes ?? req.scopes,
14316
14642
  env: d.env ?? req.env,
14317
14643
  name: req.name,
14318
- ...d.prefix ? { prefix: d.prefix } : {}
14644
+ ...d.prefix ? { prefix: d.prefix } : {},
14645
+ // report ONLY what the server says it minted: a stale server that
14646
+ // ignored the flag (and so omits the field) must not read as public
14647
+ ...d.end_user_required === true ? { end_user_required: true } : {}
14319
14648
  }
14320
14649
  };
14321
14650
  }
@@ -14386,7 +14715,7 @@ function revokeGuard(args) {
14386
14715
  return { ok: true, row: row2, isCurrent };
14387
14716
  }
14388
14717
  function mintStdout(r, json2) {
14389
- return json2 ? JSON.stringify({ api_key: r.api_key, key_id: r.key_id, scopes: r.scopes }) : r.api_key;
14718
+ return json2 ? JSON.stringify({ api_key: r.api_key, key_id: r.key_id, scopes: r.scopes, ...r.end_user_required ? { end_user_required: true } : {} }) : r.api_key;
14390
14719
  }
14391
14720
  function mintNotices(r, json2, opts = {}) {
14392
14721
  const storage = opts.stored ? "It was stored as this project's CLI key (~/.vxil/credentials.json), replacing the previous one." : "It was NOT stored on this machine (pass --store to make it this project's CLI key).";
@@ -14398,7 +14727,8 @@ function mintNotices(r, json2, opts = {}) {
14398
14727
  ];
14399
14728
  }
14400
14729
  return [
14401
- `\u2713 minted '${r.name}' (${r.key_id}, env ${r.env}) with scopes [${r.scopes.join(", ")}]`,
14730
+ `\u2713 minted '${r.name}' (${r.key_id}, env ${r.env}${r.end_user_required ? ", public / end_user_required" : ""}) with scopes [${r.scopes.join(", ")}]`,
14731
+ ...r.end_user_required ? ["A public key belongs in your browser / mobile app: every request must carry the signed-in user's session (X-Vxil-End-User), and it acts only as that user."] : [],
14402
14732
  `This key is shown ONCE and is not retrievable again \u2014 copy it now. ${storage} Revoke with \`vxil keys revoke ${r.key_id}\`.`,
14403
14733
  ...storeWarn
14404
14734
  ];
@@ -14904,7 +15234,7 @@ var VERB_USAGE = {
14904
15234
  projects: "usage: vxil projects [list] [--json] \xB7 projects create <slug> [--name <display>] [--workload production|staging|development] [--json] \xB7 projects workload <slug> production|staging|development [--json] \xB7 projects rm <slug> [--yes] [--json]",
14905
15235
  billing: "usage: vxil billing [status] [--json] \xB7 vxil billing upgrade --tier <free|developer|team|business> [--yes] [--json] (upgrade also downgrades: --tier free)",
14906
15236
  files: "usage: vxil files put <path> --user <user_id> [--content-type <type>] [--json] \xB7 vxil files rm <object_id> [--yes] [--json]",
14907
- webhooks: "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic> --name <n> [--forward-url <https>] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>",
15237
+ webhooks: "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic|hmac> --name <n> [--forward-url <https> | --target-function <fn>] [--verify <json> (hmac)] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>",
14908
15238
  search: "usage: vxil search sync run <sourceKey> [--sweep] [--json] \xB7 vxil search sync reset <sourceKey> [--purge] [--yes] [--json]",
14909
15239
  listen: "usage: vxil listen --forward-to <url> [--dev] [--source <id>] [--events <prefix,\u2026>] [--replay-last <n>] [--interval <s>] [--show-runs] [--raw] \u2014 forward inbound webhook events to a local server",
14910
15240
  mcp: "usage: vxil mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <api_key>] [--scopes a,b] [--name <server>]",
@@ -18054,12 +18384,13 @@ async function runListen(deps, opts) {
18054
18384
  if (sres.body.error || sres.status >= 400) throw envelopeError(sres, "list webhook sources");
18055
18385
  let tenantId = sres.body.meta?.tenant_id;
18056
18386
  const sources = sres.body.data?.sources ?? [];
18387
+ const destOf = new Map(sources.map((s) => [s.source_id, s.target_function ? `fn:${s.target_function}` : s.forward_url || null]));
18057
18388
  if (sources.length === 0) {
18058
18389
  deps.err("no webhook sources yet \u2014 create one (dashboard \u2192 Webhooks, or POST /v1/webhooks/sources) and point the provider at its receiver URL; waiting for events anyway\u2026");
18059
18390
  } else {
18060
18391
  deps.err(`sources (${sources.length}):`);
18061
18392
  for (const s of sources) {
18062
- deps.err(` ${s.source_id} ${s.provider} ${s.name}${s.forward_url ? "" : " (no forward_url \u2014 this listener is the only delivery)"}`);
18393
+ deps.err(` ${s.source_id} ${s.provider} ${s.name}${destOf.get(s.source_id)?.startsWith("fn:") ? ` \u2192 ${destOf.get(s.source_id)}` : ""}${destOf.get(s.source_id) ? "" : " (no forward_url \u2014 this listener is the only delivery)"}`);
18063
18394
  }
18064
18395
  }
18065
18396
  let signingSecret;
@@ -18105,7 +18436,8 @@ async function runListen(deps, opts) {
18105
18436
  } else {
18106
18437
  const runs = rr.body.data?.runs ?? [];
18107
18438
  if (runs.length === 0) {
18108
- deps.err(" runs: none (the source has no forward_url \u2014 this listener is the only delivery)");
18439
+ const dest = ev.source_id ? destOf.get(ev.source_id) : void 0;
18440
+ deps.err(dest ? ` runs: none yet (the source delivers to ${dest})` : " runs: none (the source has no forward_url \u2014 this listener is the only delivery)");
18109
18441
  } else {
18110
18442
  for (const r of runs) {
18111
18443
  deps.err(` run ${r.run_id}: ${r.state} (attempt ${r.attempt_number ?? 0}/${r.max_attempts ?? "?"}${r.dead_lettered ? ", DLQ" : ""})`);
@@ -22191,12 +22523,15 @@ try {
22191
22523
  }
22192
22524
  case "keys": {
22193
22525
  const [sub, arg] = positional(0);
22194
- const KEYS_USAGE = "usage: vxil keys mint --name <name> --scopes <a,b,c> [--env live|test] [--store] [--json] \xB7 vxil keys list [--json] \xB7 vxil keys revoke <key_id> [--yes] \xB7 vxil keys perms <key_id> [--allow <a,b> | --clear-allow] [--deny <c> | --clear-deny] [--json]";
22526
+ const KEYS_USAGE = "usage: vxil keys mint --name <name> --scopes <a,b,c> [--env live|test] [--class server|public] [--end-user-required] [--store] [--json] \xB7 vxil keys list [--json] \xB7 vxil keys revoke <key_id> [--yes] \xB7 vxil keys perms <key_id> [--allow <a,b> | --clear-allow] [--deny <c> | --clear-deny] [--json]";
22195
22527
  if (sub === "help" || hasFlag("help")) {
22196
22528
  console.log(`${KEYS_USAGE}
22197
22529
  mint a NARROW scoped key for a server on the linked project \u2014 scopes are validated
22198
22530
  locally (admin refused); the key prints ONCE on stdout (--json \u2192 { api_key, key_id,
22199
- scopes }) and is stored as this machine's CLI key only under --store
22531
+ scopes }) and is stored as this machine's CLI key only under --store.
22532
+ --class public (or --end-user-required) mints the thin-client key for a browser /
22533
+ mobile app \u2014 the dashboard's "Public" class: it only works with a signed-in
22534
+ user's session, server-only scopes are refused, and it cannot be --store'd
22200
22535
  list names, ids, scopes, env, created, last-used \u2014 never a secret; * marks the CLI's key
22201
22536
  revoke type the key id to confirm (--yes for scripts); refuses the CLI's own key unless --yes
22202
22537
  perms the key's MCP tool permissions: with no flag, shows them; --allow / --deny set a
@@ -22226,7 +22561,14 @@ try {
22226
22561
  if ("error" in scopes) fail(scopes.error);
22227
22562
  const env = parseKeyEnv(flag("env"));
22228
22563
  if ("error" in env) fail(env.error);
22229
- return { name: name.name, scopes: scopes.scopes, env: env.env };
22564
+ const cls = parseKeyClass(flag("class"), hasFlag("end-user-required"));
22565
+ if ("error" in cls) fail(cls.error);
22566
+ if (cls.keyClass === "public") {
22567
+ const bad = checkPublicKeyScopes(scopes.scopes);
22568
+ if (bad) fail(bad.error);
22569
+ if (hasFlag("store")) fail("--store refused for a public key \u2014 it cannot drive the CLI (every request needs an end-user session); copy it into your app instead");
22570
+ }
22571
+ return { name: name.name, scopes: scopes.scopes, env: env.env, keyClass: cls.keyClass };
22230
22572
  })() : void 0;
22231
22573
  const t = resolveTargetOrFail(selector());
22232
22574
  if (!t.tenantSlug) fail("no linked project \u2014 run `vxil link <slug>` (or `vxil quickstart`) first; keys are minted on the bound project");
@@ -22640,10 +22982,16 @@ try {
22640
22982
  }
22641
22983
  case "webhooks": {
22642
22984
  const [sub, verb, arg] = positional(0);
22643
- const WEBHOOKS_USAGE = "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic> --name <n> [--forward-url <https>] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>";
22985
+ const WEBHOOKS_USAGE = "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic|hmac> --name <n> [--forward-url <https> | --target-function <fn>] [--verify <json> (hmac)] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>";
22644
22986
  if (sub === "sources") {
22645
22987
  if (verb === "add") {
22646
- const p = parseSourceAdd({ provider: flag("provider"), name: flag("name"), forwardUrl: flag("forward-url") });
22988
+ const p = parseSourceAdd({
22989
+ provider: flag("provider"),
22990
+ name: flag("name"),
22991
+ forwardUrl: flag("forward-url"),
22992
+ targetFunction: flag("target-function"),
22993
+ verify: flag("verify")
22994
+ });
22647
22995
  if ("error" in p) fail(p.error);
22648
22996
  const { api, baseUrl } = requireApi(selector(), { write: `webhooks sources add ${p.body.name}` });
22649
22997
  const s = await sourcesAdd(api, p.body);
@@ -22656,7 +23004,10 @@ try {
22656
23004
  const rows = await sourcesList(api);
22657
23005
  if (jsonOut) console.log(JSON.stringify({ sources: rows }, null, 2));
22658
23006
  else if (!rows.length) console.log("(no inbound sources \u2014 `vxil webhooks sources add --provider \u2026 --name \u2026`)");
22659
- else for (const r of rows) console.log(`${r.source_id} ${String(r.provider).padEnd(10)} ${r.name}`);
23007
+ else for (const r of rows) {
23008
+ const dest = sourceDestination(r);
23009
+ console.log(`${r.source_id} ${String(r.provider).padEnd(10)} ${r.name}${dest ? ` \u2192 ${dest}` : ""}`);
23010
+ }
22660
23011
  break;
22661
23012
  }
22662
23013
  if (verb === "rm") {
@@ -23398,7 +23749,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
23398
23749
  default:
23399
23750
  console.log(`vxil \u2014 a Supabase-grade code-first CLI for the whole platform
23400
23751
  --version \xB7 init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--features a,b] [--invite <code>] [--env <label>] [--dev [--ttl <h>]] [--no-push] \xB7 login \xB7 link <slug> [--key <k> [--tenant <id>]] [--as dev|<name>] [--env <label>] [--mint]
23401
- keys mint --name <n> --scopes <a,b,c> [--env live|test] [--store] \xB7 keys list \xB7 keys revoke <key_id> [--yes] \u2014 a NARROW scoped key for a server (shown once; needs a vxil login session)
23752
+ keys mint --name <n> --scopes <a,b,c> [--env live|test] [--class server|public] [--store] \xB7 keys list \xB7 keys revoke <key_id> [--yes] \u2014 a NARROW scoped key for a server (shown once; needs a vxil login session)
23402
23753
  keys perms <key_id> [--allow <a,b> | --clear-allow] [--deny <c> | --clear-deny] \u2014 a key's MCP tool permissions (admin+)
23403
23754
  account [show] \xB7 account set [--locale <code>] [--display-name <n>] \xB7 account password \xB7 logout \xB7 invites [list] \xB7 invites generate [--count <n>] \xB7 invites accept <token|url>
23404
23755
  members [list] \xB7 members invite <email> --role <r> \xB7 members add <email> --role <r> \xB7 members rm <user_id|email> [--yes] \xB7 members uninvite <email|id> [--yes] \u2014 the bound project's team (session)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "miniflare": "^4.20260611.0",
49
- "@vxil/config": "0.12.0",
49
+ "@vxil/config": "0.13.0",
50
+ "@vxil/feature-configs": "0.11.0",
50
51
  "@vxil/runtime": "0.0.1",
51
- "@vxil/types": "0.0.1",
52
- "@vxil/feature-configs": "0.10.0"
52
+ "@vxil/types": "0.0.1"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "pnpm --filter @vxil/feature-configs run build && pnpm --filter @vxil/config run build && esbuild bin/vxil.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/vxil.js --banner:js='#!/usr/bin/env node' --external:esbuild --external:miniflare && esbuild src/config-entry.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/config.js && pnpm exec tsx scripts/build-config-dts.ts",