@voltro/cli 0.11.4 → 0.12.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/dist/{apiBuild-DHHuiwXF.js → apiBuild-GefjNI5d.js} +9 -8
  3. package/dist/apiBuild-s7swuEU5.js +2 -0
  4. package/dist/appModuleLoader-C9r9mxZt.js +19 -0
  5. package/dist/bin.js +21 -10
  6. package/dist/cliArgs-qdZSElM3.js +28 -0
  7. package/dist/{commands-BwbMqR-1.js → commands-BmNi1RgZ.js} +2502 -2543
  8. package/dist/dbCommand-Bt6BCG7g.js +2 -0
  9. package/dist/dbCommand-DNrkb58g.js +862 -0
  10. package/dist/dev-YHbHmeXt.js +3 -0
  11. package/dist/{dev-Cvojvb_J.js → dev-d0LjACxE.js} +1574 -2357
  12. package/dist/index.d.ts +19 -0
  13. package/dist/index.js +3 -3
  14. package/dist/inspect-DcZ04OME.js +2 -0
  15. package/dist/inspect-Dwx0_tUj.js +921 -0
  16. package/dist/inspectMetrics-uEfIS1vp.js +3399 -0
  17. package/dist/manifestBuild-D1MzJAiQ.js +2 -0
  18. package/dist/manifestBuild-P9yuCY2d.js +39 -0
  19. package/dist/seedRunner-DZmhoqNt.js +821 -0
  20. package/dist/serveCommand-B2-YV9TU.js +1112 -0
  21. package/dist/serveEntry.js +4 -4
  22. package/dist/{start-DyJE6w1R.js → start-DKcQd52a.js} +346 -343
  23. package/dist/startEntry.js +2 -2
  24. package/dist/startup.js +1 -1
  25. package/dist/{startupRunner-DhlX9nqd.js → startupRunner-DPGFchOa.js} +19 -33
  26. package/package.json +17 -17
  27. package/templates/AGENTS.md +1 -0
  28. package/templates/agent-docs/_index.md +1 -0
  29. package/templates/agent-docs/cli.md +180 -14
  30. package/templates/agent-docs/data.md +39 -0
  31. package/templates/agent-docs/scheduling.md +35 -1
  32. package/templates/agent-docs/security.md +48 -0
  33. package/templates/agent-docs/templates/apibackends.md +90 -17
  34. package/templates/agent-docs/whats-new.md +177 -0
  35. package/templates/apps/api-ai/package.json +7 -7
  36. package/templates/apps/api-auth/package.json +8 -8
  37. package/templates/apps/api-backend/package.json +7 -7
  38. package/templates/apps/api-backend-deactivation/package.json +7 -7
  39. package/templates/apps/api-backend-mail/package.json +8 -8
  40. package/templates/apps/api-backend-mariadb/package.json +9 -9
  41. package/templates/apps/api-backend-storage/package.json +8 -8
  42. package/templates/apps/api-data-advanced/package.json +8 -8
  43. package/templates/apps/api-durable/package.json +8 -8
  44. package/templates/apps/api-feature-flags/package.json +9 -9
  45. package/templates/apps/api-governance/package.json +8 -8
  46. package/templates/apps/api-kv/package.json +8 -8
  47. package/templates/apps/api-moderation/package.json +8 -8
  48. package/templates/apps/api-observability/package.json +8 -8
  49. package/templates/apps/api-ratelimit/package.json +8 -8
  50. package/templates/apps/api-rbac/package.json +8 -8
  51. package/templates/apps/api-rest/package.json +7 -7
  52. package/templates/apps/api-saas/package.json +11 -11
  53. package/templates/apps/api-search/package.json +8 -8
  54. package/templates/apps/api-versioning/package.json +8 -8
  55. package/templates/apps/api-webhooks/package.json +8 -8
  56. package/templates/apps/changelog/package.json +6 -6
  57. package/templates/apps/edge-functions/package.json +2 -2
  58. package/templates/apps/frontend-admin/package.json +8 -8
  59. package/templates/apps/frontend-app/package.json +8 -8
  60. package/templates/apps/frontend-blank/package.json +7 -7
  61. package/templates/apps/frontend-contact/package.json +7 -7
  62. package/templates/apps/frontend-dashboard/package.json +7 -7
  63. package/templates/apps/frontend-docs/package.json +7 -7
  64. package/templates/apps/frontend-i18n/package.json +6 -6
  65. package/templates/apps/frontend-landing/package.json +7 -7
  66. package/templates/apps/frontend-spa/package.json +7 -7
  67. package/templates/apps/frontend-ssr/package.json +7 -7
  68. package/templates/apps/frontend-ssr-api/package.json +8 -8
  69. package/templates/apps/frontend-static-blog/package.json +6 -6
  70. package/dist/apiBuild-D9MtlD02.js +0 -2
  71. package/dist/dev-BsBsIv93.js +0 -2
  72. package/dist/inspectMetrics-D4EJ8LTi.js +0 -4255
  73. package/dist/serveCommand-ARqSs73o.js +0 -1117
@@ -1,3 +1,3 @@
1
- import { xt as e } from "./inspectMetrics-D4EJ8LTi.js";
2
- import { t } from "./start-DyJE6w1R.js";
1
+ import { X as e } from "./inspectMetrics-uEfIS1vp.js";
2
+ import { t } from "./start-DKcQd52a.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/dist/startup.js CHANGED
@@ -1,2 +1,2 @@
1
- import { n as e, t } from "./startupRunner-DhlX9nqd.js";
1
+ import { n as e, t } from "./startupRunner-DPGFchOa.js";
2
2
  export { t as loadStartupFiles, e as runStartups };
@@ -1,44 +1,30 @@
1
- import { basename as e } from "node:path";
2
- import { pathToFileURL as t } from "node:url";
1
+ import { n as e } from "./appModuleLoader-C9r9mxZt.js";
2
+ import { basename as t } from "node:path";
3
3
  import { publishServerError as n } from "@voltro/protocol";
4
- //#region src/appModuleLoader.ts
5
- var r = /* @__PURE__ */ new Map(), i = /* @__PURE__ */ new Map(), a = (e) => {
6
- for (let [t, n] of Object.entries(e)) r.set(t, n);
7
- }, o = () => r.size > 0, s = async (e) => {
8
- let n = i.get(e);
9
- if (n) return n;
10
- let a = r.get(e);
11
- if (a !== void 0) {
12
- if (typeof a == "function") {
13
- let t = await a();
14
- return i.set(e, t), t;
15
- }
16
- return a;
17
- }
18
- return await import(t(e).href);
19
- }, c = (e) => e.replace(/\.startup\.tsx?$/, ""), l = async (t, n) => {
20
- let r = [];
21
- for (let i of t) {
22
- let t;
4
+ //#region src/startupRunner.ts
5
+ var r = (e) => e.replace(/\.startup\.tsx?$/, ""), i = async (n, i) => {
6
+ let a = [];
7
+ for (let o of n) {
8
+ let n;
23
9
  try {
24
- t = await s(i);
10
+ n = await e(o);
25
11
  } catch (e) {
26
- n.warn("startup: failed to import", { file: i }, e);
12
+ i.warn("startup: failed to import", { file: o }, e);
27
13
  continue;
28
14
  }
29
- let a = t.default;
30
- if (typeof a != "function") {
31
- n.warn("startup: no default-export function", { file: i });
15
+ let s = n.default;
16
+ if (typeof s != "function") {
17
+ i.warn("startup: no default-export function", { file: o });
32
18
  continue;
33
19
  }
34
- r.push({
35
- file: i,
36
- id: c(e(i)),
37
- fn: a
20
+ a.push({
21
+ file: o,
22
+ id: r(t(o)),
23
+ fn: s
38
24
  });
39
25
  }
40
- return r;
41
- }, u = async (e, t, r) => {
26
+ return a;
27
+ }, a = async (e, t, r) => {
42
28
  let i = [];
43
29
  for (let a of e) {
44
30
  let e = r.child(`startup:${a.id}`), o = {
@@ -82,4 +68,4 @@ var r = /* @__PURE__ */ new Map(), i = /* @__PURE__ */ new Map(), a = (e) => {
82
68
  } };
83
69
  };
84
70
  //#endregion
85
- export { a, s as i, u as n, o as r, l as t };
71
+ export { a as n, i as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.107.0",
63
63
  "@effect/sql": "^0.51.1",
64
64
  "@effect/workflow": "^0.18.2",
65
- "@voltro/ai": "0.11.4",
66
- "@voltro/cache": "0.11.4",
67
- "@voltro/data-transfer": "0.11.4",
68
- "@voltro/database": "0.11.4",
69
- "@voltro/env": "0.11.4",
70
- "@voltro/kv": "0.11.4",
71
- "@voltro/logger": "0.11.4",
72
- "@voltro/plugin-auth": "0.11.4",
73
- "@voltro/plugin-broadcast": "0.11.4",
74
- "@voltro/plugin-mail": "0.11.4",
75
- "@voltro/plugin-storage": "0.11.4",
76
- "@voltro/plugin-webhooks": "0.11.4",
77
- "@voltro/protocol": "0.11.4",
78
- "@voltro/runtime": "0.11.4",
79
- "@voltro/serverless": "0.11.4",
80
- "@voltro/workflow": "0.11.4",
65
+ "@voltro/ai": "0.12.0",
66
+ "@voltro/cache": "0.12.0",
67
+ "@voltro/data-transfer": "0.12.0",
68
+ "@voltro/database": "0.12.0",
69
+ "@voltro/env": "0.12.0",
70
+ "@voltro/kv": "0.12.0",
71
+ "@voltro/logger": "0.12.0",
72
+ "@voltro/plugin-auth": "0.12.0",
73
+ "@voltro/plugin-broadcast": "0.12.0",
74
+ "@voltro/plugin-mail": "0.12.0",
75
+ "@voltro/plugin-storage": "0.12.0",
76
+ "@voltro/plugin-webhooks": "0.12.0",
77
+ "@voltro/protocol": "0.12.0",
78
+ "@voltro/runtime": "0.12.0",
79
+ "@voltro/serverless": "0.12.0",
80
+ "@voltro/workflow": "0.12.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -452,6 +452,7 @@ each plugin's own README.
452
452
 
453
453
  | Topic | Open | Summary |
454
454
  |---|---|---|
455
+ | **What's new in 0.11.4** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
455
456
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
456
457
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
457
458
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,6 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
+ | **What's new in 0.11.4** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
13
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
14
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -105,8 +105,7 @@ pnpm dev # turbo adds the new app to its parallel boot
105
105
  voltro db plan # diff declared schema vs live, color-coded
106
106
  # review the plan
107
107
  voltro db apply # execute it (dev)
108
- # or, for the flagless idempotent apply (CI / ops):
109
- voltro migrate
108
+ # `voltro migrate` is an alias of `db apply` — same differ, shorter name (CI / ops)
110
109
  ```
111
110
 
112
111
  ### "I want a clean rebuild"
@@ -130,19 +129,69 @@ voltro workflows list # recent workflow runs
130
129
 
131
130
  ### "Did my edit break a binding?"
132
131
 
133
- `voltro check` runs blast-radius checks against the **running** api's live
134
- capability manifest — dangling `source` / `target` tables, unknown RBAC scopes,
135
- broken route bindings, orphan tables — at edit time instead of at runtime
136
- (needs `voltro dev` up). `--json` emits LLM-shaped diagnostics so a coding
137
- agent can fix-and-repeat; `--diff` previews the blast radius of a removal
138
- BEFORE you apply it.
132
+ `voltro check` runs blast-radius checks over your app's typed graph — dangling
133
+ `source` / `target` tables, scopes no role grants, unguarded mutations, broken
134
+ route bindings, orphan tables — at edit time instead of at runtime. `--json`
135
+ emits LLM-shaped diagnostics so a coding agent can fix-and-repeat; `--diff`
136
+ previews the blast radius of a removal BEFORE you apply it.
137
+
138
+ It prefers a running api (its manifest is ground truth, including live table
139
+ introspection). **With none reachable it assembles the same graph from source**,
140
+ so it works as a pre-commit hook or a CI gate without a second terminal —
141
+ `--offline` forces that path.
139
142
 
140
143
  ```bash
141
- voltro check # blast-radius checks vs the live manifest
144
+ voltro check # running api if there is one, else from source
145
+ voltro check --offline # never contact a server — the CI form
146
+ voltro check --url https://api.example.com # a DEPLOYED app
142
147
  voltro check --json # { ok, diagnostics: [{ rule, node, breaks, fix }] }
143
148
  voltro check --diff removeTable:todos # what a proposed removal would break, before applying
144
149
  ```
145
150
 
151
+ `rbac/unknown-scope` needs a declared scope vocabulary to compare against —
152
+ `rbacPlugin({ roles })` publishes one automatically. It catches a guard
153
+ requiring a scope no role grants, which makes that procedure permanently and
154
+ silently uncallable. (With a custom `resolvePermissions` the vocabulary isn't
155
+ exhaustive, so the rule stays quiet rather than flagging correct code.)
156
+
157
+ #### Declared vs OBSERVED — reconciled against reality
158
+
159
+ A query's `source` and a mutation's `targets` are not documentation: the
160
+ framework routes optimistic patches and decides which subscriptions a write
161
+ invalidates from them. A wrong declaration is a live, user-visible bug that
162
+ nothing type-checks — the mutation succeeds, the write lands, and the wrong list
163
+ fails to update.
164
+
165
+ `voltro dev` records what each procedure ACTUALLY touched, into
166
+ `app.graph.observed.json` (gitignored automatically). When that file is present,
167
+ `check` diffs it against the declarations:
168
+
169
+ ```
170
+ observed: 12/34 procedures exercised (35%)
171
+ mutation(orders.place) (api/orders/place.mutation.ts)
172
+ writes 'inventory' (update) but declares no target for it
173
+ subscriptions on that table are not invalidated by this mutation
174
+ fix: add { table: 'inventory', op: 'update' } to this procedure's targets
175
+ 22 unexercised — no recording, so nothing is claimed about them
176
+ ```
177
+
178
+ Three things about that output are deliberate:
179
+
180
+ - **Coverage comes first.** Three findings at 8% coverage and three at 95% are
181
+ different claims. Hiding the denominator is how a check starts overstating
182
+ what it knows.
183
+ - **`unexercised` is a third state, not a finding.** A procedure no test and no
184
+ dev session ever ran has *no observation* — which is not the same as "touches
185
+ nothing". It is counted and never reported as a problem.
186
+ - **Observed diagnostics never fail the build.** An observation is evidence
187
+ about the runs that happened, not a proof about the ones that didn't, and
188
+ `check`'s exit code gates CI. They are always warnings.
189
+
190
+ No file → the section is skipped silently. This is not derived by parsing your
191
+ handlers: a static pass over code that reaches the store through shared helpers,
192
+ behind conditionals, has a long tail of both false positives and false negatives
193
+ — and a check that is *sometimes* wrong is one people stop reading.
194
+
146
195
  The HTTP surface is reachable directly too — e.g. `curl -s localhost:4000/_voltro/inspect/rpc | jq` (there is no `/_voltro/inspect/queries` endpoint; it's `rpc` for procedures, `routes` for the web page tree, `subscriptions` for active subscribers).
147
196
 
148
197
  ## Where to read next
@@ -798,6 +847,78 @@ Docker step) and prints the exact remedy: add a `voltro build .` step before
798
847
  `voltro serve .`. Drop it into your image build right after `voltro build` to
799
848
  guarantee the artefact is present before the image ships.
800
849
 
850
+ ### The predicate-column check
851
+
852
+ `eq` / `isNull` / `inSet` are free functions, so the column name arrives as a bare
853
+ `string` and the builder cannot relate it to the table the predicate is attached
854
+ to:
855
+
856
+ ```ts
857
+ database.teamAppointments.where(isNull('deletedAt'))
858
+ // ^ teamAppointments has no softDelete() mixin,
859
+ // so no `deletedAt` column exists. tsc: OK.
860
+ ```
861
+
862
+ That type-checks — review and CI pass — and then fails at runtime as a bare SQL
863
+ error. `voltro doctor` checks every literal predicate column against the table's
864
+ declared columns and names both:
865
+
866
+ ```
867
+ ✗ predicate columns: 1 filter on a column that does not exist (214 checked)
868
+ api/appointments/rollforward.query.server.ts:31 'teamAppointments' has no column 'deletedAt'
869
+ columns: id, teamId, startsAt, createdAt, updatedAt
870
+ This type-checks today and fails at runtime as a bare SQL error.
871
+ ```
872
+
873
+ Matching is on the AST, never on text, so a column name in a comment or an
874
+ unrelated string cannot trip it. A call site whose table cannot be resolved is
875
+ skipped silently — an unresolvable receiver is usually not a table at all.
876
+
877
+ The type-level fix (binding the predicate to the row, `where(c => isNull(c.x))`)
878
+ is the right end state and is planned separately. This check is the half that
879
+ works **retroactively**: it finds the bug in code that already exists, which a
880
+ type change never will.
881
+
882
+ The detector also flags **raw `fetch()` in server files**. The SSRF guard the
883
+ framework ships lives in the `HttpClient` handlers `yield*` — so it protects
884
+ exactly the apps that already adopted it, and misses the ones that never did.
885
+ Those are usually the same apps that secured least elsewhere, which is why the
886
+ absence is worth naming out loud rather than assuming the default did its job.
887
+
888
+ Server files only (`*.server.ts`, `*.cron.ts`, `*.subscribe.ts`, …): `fetch` is
889
+ unremarkable in a browser component, and flagging it there would make the rule
890
+ noise that gets scrolled past — taking the real findings with it.
891
+
892
+ ### The `workflows.start` audit
893
+
894
+ `voltro doctor` also diffs every `workflows.start(name, payload)` call site
895
+ against the workflows the app actually registers:
896
+
897
+ ```
898
+ ✗ workflow starts: 13/14 call sites checked
899
+ api/crons/weekly.cron.ts:22 'sprint.report' payload is missing: teamId
900
+ api/crons/weekly.cron.ts:22 'sprint.report' payload has unknown field(s): scheduledAt
901
+ accepted: sinceIso, teamId
902
+ 1 UNCHECKED (not verified — not a pass):
903
+ api/crons/digest.cron.ts:8 — payload spreads a value
904
+ ```
905
+
906
+ **Why this exists even though `workflows.start` validates at runtime.** Runtime
907
+ validation fires on the next firing — which for a daily cron is hours, for a
908
+ weekly one is a week, and for a quarterly one is a quarter. And
909
+ `voltro inspect schedules --failing` cannot see a job that has *never fired*,
910
+ because its roll-up is built from recorded runs. A weekly workflow broken by a
911
+ refactor is invisible to both until it next runs.
912
+
913
+ **`UNCHECKED` is never folded into a pass.** A payload built with a spread or a
914
+ computed key can contribute any name, so its key set is unknowable here. Those
915
+ call sites are counted and listed rather than passed silently — `0 issues` must
916
+ not be readable as "all verified". The workflow **name** is checked regardless,
917
+ since a rename or a deletion is decidable whatever the payload looks like.
918
+
919
+ The required keys come from the live `payloadSchema`, the same source the runtime
920
+ validation reads, so the two cannot disagree about what a payload needs.
921
+
801
922
  ### The hand-roll detector
802
923
 
803
924
  `voltro doctor` also scans your source for shapes the framework already has a
@@ -1007,16 +1128,18 @@ package's README.
1007
1128
  <!-- source: en/cli/migrate.md -->
1008
1129
  ## Migrate
1009
1130
 
1010
- _voltro migrate — the idempotent schema apply; voltro db plan/apply for the declarative diff workflow._
1131
+ _voltro migrate — apply the declared schema through the declarative differ (an alias of voltro db apply)._
1011
1132
 
1012
- `voltro migrate` applies your declared schema (`*.entity.ts` / `*.schema.ts` / `schema.ts`) to the configured database. It's a **flagless, idempotent apply**: it walks the project for table descriptors, emits `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`, and runs the DDL in one transaction. Re-running is a no-op.
1133
+ `voltro migrate` applies your declared schema (`*.entity.ts` / `*.schema.ts` / `schema.ts`) to the configured database. It is an **alias of [`voltro db apply`](#the-declarative-workflow)**: it diffs the declared schema against the live database and emits the ALTERs, so a changed column or a new index actually lands.
1134
+
1135
+ > Before 0.11.4 this command was a create-only apply (`CREATE TABLE IF NOT EXISTS`, no diffing), which meant a column or type change reported success having applied **nothing**. If you need that bootstrap-only behaviour for a brand-new database, it is now `voltro migrate --create-only`.
1013
1136
 
1014
1137
  ```bash
1015
1138
  voltro migrate # apply the discovered schema to the configured store
1016
1139
  voltro migrate apps/api # explicit app directory (defaults to cwd)
1017
1140
  ```
1018
1141
 
1019
- There are no `--plan` / `--dry` / `--reset` / `--advisory-lock` flags on `voltro migrate` the only argument it reads is an optional path. The diff / plan / apply / drift / squash workflow lives under `voltro db` (see below).
1142
+ `voltro migrate` forwards its arguments to `voltro db apply`, so the same flags apply; `--create-only` selects the bootstrap-only emitter instead. The diff / plan / apply / drift / squash workflow lives under `voltro db` (see below).
1020
1143
 
1021
1144
  For the deep dive on the schema DSL + day-to-day patterns, see [Database / Migrations](/docs/database/migrations).
1022
1145
 
@@ -1163,6 +1286,23 @@ has never run is not "failing".
1163
1286
  Pair it with `voltro logs --level error`: a failing handler now logs at **error**
1164
1287
  level, so the two surfaces agree.
1165
1288
 
1289
+ ## Targeting a deployed app
1290
+
1291
+ Every command in this family resolves its target from the local runtime registry — the apps running on *this* machine. Pass `--url` to point one at a **deployed** app instead:
1292
+
1293
+ ```bash
1294
+ voltro inspect app --url https://api.example.com --token "$TOKEN"
1295
+ voltro logs --tail 100 --url https://api.example.com
1296
+ voltro traces --errors --url https://api.example.com
1297
+ voltro check --url https://api.example.com
1298
+ ```
1299
+
1300
+ `--token` (or `VOLTRO_INSPECT_TOKEN`) supplies the bearer; `VOLTRO_INSPECT_URL` sets a default target so you can drop the flag. Works for `inspect`, `logs`, `traces`, `workflows`, `cluster` and `check`.
1301
+
1302
+ ## Securing the local surface
1303
+
1304
+ `voltro dev` mints a per-project `VOLTRO_INSPECT_TOKEN` into `.env.local`, so the inspect surface is authenticated from the first boot — the dev server listens on every interface, and without a token anyone on the same network could read your rows, schema and logs. You don't have to wire it anywhere: the CLI picks the token up from the runtime registry (so the commands work from any directory), and the dashboard's server-side proxy supplies it for same-machine targets. Setting `VOLTRO_INSPECT_TOKEN` yourself always wins.
1305
+
1166
1306
  ## The inspect HTTP surface
1167
1307
 
1168
1308
  Every `voltro dev` / `voltro start` instance exposes a read-only introspection surface under `/_voltro/inspect/*`. The [Voltro Dashboard](/docs/observability/dashboard) consumes it to render the route sitemap, RPC list, subscription panel, workflow runs, and metrics. You can also hit the endpoints directly with `curl` (the `voltro inspect` subcommands above are the thin wrapper over exactly these).
@@ -1293,6 +1433,30 @@ Runs Vitest with the framework's preset:
1293
1433
  - `STORE=memory` by default for fast isolation.
1294
1434
  - Plays well with `@voltro/testing` helpers (mock stores).
1295
1435
 
1436
+ ### Every vitest flag is forwarded
1437
+
1438
+ Anything the command does not interpret itself goes straight to vitest — parsed
1439
+ by **vitest's own CLI parser**, not a list this wrapper maintains:
1440
+
1441
+ ```bash
1442
+ voltro test --coverage
1443
+ voltro test --reporter=junit --outputFile=reports/junit.xml
1444
+ voltro test --coverage --reporter=junit --outputFile=reports/junit.xml
1445
+ ```
1446
+
1447
+ That covers coverage numbers and a JUnit report for a merge-request widget, which
1448
+ is what most pipelines want beyond the exit code.
1449
+
1450
+ The framework keeps three decisions for itself and they win over a forwarded
1451
+ flag: the **root** (a positional that is an existing directory, which vitest
1452
+ would otherwise read as a filter), `--watch`, and `passWithNoTests` — an explicit
1453
+ filter that matches no file is an *error*, which vitest cannot decide because it
1454
+ does not know which positional was treated as a root.
1455
+
1456
+ An unrecognised flag is ignored rather than fatal, and a flag vitest cannot parse
1457
+ at all degrades to "run without the extra flags" with a warning instead of taking
1458
+ the run down.
1459
+
1296
1460
  The actual test runner is Vitest; this command is a thin wrapper that injects the framework's config. You can run vitest directly if you prefer:
1297
1461
 
1298
1462
  ```bash
@@ -1332,7 +1496,9 @@ The framework's `voltro e2e` handles the lifecycle for you: it boots the api + w
1332
1496
 
1333
1497
  ## Securing the inspect surface
1334
1498
 
1335
- The inspect endpoints expose framework internals (queries, subscription state, workflow runs). In production, lock them down with a bearer token:
1499
+ The surface is **fail-closed**: with no `VOLTRO_INSPECT_TOKEN` configured, every endpoint answers `401`. The absence of a secret is not consent — a check you were configured not to perform is a refusal, not a pass.
1500
+
1501
+ That is why `voltro dev` mints one for you (above) and why nothing mints outside dev: in production a missing secret must stay a boot-time decision rather than an invented value. So a production `voltro start` serves nothing here until you set the token yourself:
1336
1502
 
1337
1503
  ```bash
1338
1504
  VOLTRO_INSPECT_TOKEN=$(openssl rand -hex 32)
@@ -1350,7 +1516,7 @@ Or disable the surface entirely: `VOLTRO_INSPECT=off`. The dashboard then can't
1350
1516
 
1351
1517
  ## Anti-patterns
1352
1518
 
1353
- - **Inspect surface open in production.** Either disable (`VOLTRO_INSPECT=off`) or lock behind `VOLTRO_INSPECT_TOKEN`. Anyone hitting `/_voltro/inspect/*` can read rpc shapes + live subscription data.
1519
+ - **Assuming the surface is open because you didn't configure it.** It is the reverse: no token means `401`, not "everyone". If a production dashboard suddenly stops introspecting, the missing `VOLTRO_INSPECT_TOKEN` is the first thing to check not a network problem.
1354
1520
  - **`voltro test` against `STORE=postgres` by default.** Slower + flaky (test isolation harder). Use postgres only for integration tests that NEED it.
1355
1521
  - **Skipping `voltro e2e` because "it's slow".** It catches integration bugs that unit tests miss. Run it in CI on every PR; locally for changes that touch queries.
1356
1522
 
@@ -691,6 +691,14 @@ column — and required otherwise. `upsertRow`'s `conflictColumns` are constrain
691
691
  to the table's own columns, so a typo is a compile error too. The string-keyed
692
692
  `ctx.store.insert` / `upsert` still work unchanged; the typed seam is opt-in.
693
693
 
694
+ > **If you adopt it, check your tooling for the old spelling.** `insertRow`
695
+ > replaces `store.insert('<tableName>', …)`, so anything that matches on that
696
+ > string stops matching — a lint rule, a codemod, an architecture test. One team
697
+ > had a test asserting every mutation writes an audit log; it matched
698
+ > `store.insert('auditLogs'`, and migrating to `insertRow` silently blinded it.
699
+ > Green suite, gap reintroduced. Grep for the old spelling before you migrate,
700
+ > not after.
701
+
694
702
  ## Partial updates: `ctx.store.applyDefined`
695
703
 
696
704
  A partial-update mutation should set only the fields the caller actually sent — not overwrite an omitted field with `undefined`. Instead of hand-writing `if (input.x !== undefined) patch.x = input.x` per field, use `ctx.store.applyDefined(input, keys)`:
@@ -2099,6 +2107,37 @@ export default crud.list('articles', { columns: ['id', 'title', 'createdAt'] })
2099
2107
 
2100
2108
  **Trap:** an eager `include` branch joins on a foreign key, so a projection that omits that FK breaks the relation. Keep the FK in `columns` when you also pass `include`.
2101
2109
 
2110
+ ### `scope` — keep per-subject narrowing when you adopt the helper
2111
+
2112
+ `filter` builds the WHERE from the request; `scope` builds it from the **caller**:
2113
+
2114
+ ```ts
2115
+ crud.list('timeEntries', {
2116
+ filter: (input) => ({ status: input.status }), // what the caller ASKED for
2117
+ scope: (ctx) => ({ ownerId: ctx.request.subject.id }), // what it MAY SEE
2118
+ })
2119
+ ```
2120
+
2121
+ A WHERE built only from `input` can express *the rows the caller asked for*, never
2122
+ *the rows the caller may see*.
2123
+
2124
+ `scope` is merged **last**, so a request field of the same name can never widen it —
2125
+ `?ownerId=someone-else` is simply overridden. That ordering is why the two are
2126
+ separate options rather than one: only one of them is a security boundary, and kept
2127
+ apart, *"does this list declare a `scope`?"* is a question a reviewer — or a future
2128
+ boot audit — can actually ask. Folded into `filter`, it becomes *"does this filter
2129
+ happen to read ctx somewhere in its body?"*, which nothing can check. Tenant scope still applies automatically; anything
2130
+ narrower — owner, team, role — does not. So replacing a hand-written handler that
2131
+ carried such a narrowing with `crud.list` **widens the result set, silently and
2132
+ without an error**. One app lost exactly that across eight list views.
2133
+
2134
+ Use the same filter for `crud.count`, or the total contradicts the pages —
2135
+ "showing 10 of 4000" on a page holding ten rows.
2136
+
2137
+ The reason this is an option rather than a reason to leave: hand-writing the query
2138
+ to get the narrowing also forfeits `serverOnly` stripping and the page-size clamp.
2139
+ A narrowing requirement should not cost you the safety rails.
2140
+
2102
2141
  ## What they deliberately don't do — authorization
2103
2142
 
2104
2143
  A guard runs *before* the executor, so gating lives on the **descriptor**, not the handler — an executor cannot gate itself. Keep every write descriptor guarded:
@@ -208,7 +208,15 @@ timezone: 'Europe/Berlin' // 09:00 Berlin — DST handled by effect's Cron
208
208
 
209
209
  ## The handler context
210
210
 
211
- The handler receives a `ScheduleContext` — the same `app` a mutation gets, plus firing metadata:
211
+ The handler receives a `ScheduleContext` — the same `app` a mutation gets, plus firing metadata.
212
+
213
+ > **The API is identical; the subject is not.** `ctx.app.store` here is **not
214
+ > tenant-scoped** — a schedule runs as `system` with no tenant. Reads see every
215
+ > tenant's rows, and a write to a `tenant()` table fails with
216
+ > `TenantScopeViolation` unless you pass `tenantId` explicitly. See
217
+ > [below](#a-schedule-runs-as-the-system-subject--no-tenant). This sentence is
218
+ > here rather than only further down because "same shape as a mutation" is what
219
+ > sets the expectation that gets violated.
212
220
 
213
221
  ```tsx
214
222
  handler: async (ctx) => {
@@ -250,6 +258,32 @@ Writes to a `tenant()` table need the same treatment: pass `tenantId`
250
258
  explicitly, or the write fails with `TenantScopeViolation` rather than landing
251
259
  somewhere arbitrary.
252
260
 
261
+ ### `ctx.app.storeForTenant(id)` — the fan-out shortcut
262
+
263
+ Doing that by hand means every `.where('tenantId', …)` and every explicit
264
+ `tenantId:` is one forgotten call away from reading or writing across tenants.
265
+ `storeForTenant` hands you a store scoped to exactly one:
266
+
267
+ ```tsx
268
+ handler: async (ctx) => {
269
+ for (const t of await ctx.app.store.select('tenants').all()) {
270
+ const scoped = ctx.app.storeForTenant(t.id)
271
+ await scoped.insert('digests', { body: summary }) // tenantId stamped, not passed
272
+ }
273
+ }
274
+ ```
275
+
276
+ Inside a REQUEST this is almost always the wrong tool — the subject already
277
+ carries a tenant, and reaching for another one is a cross-tenant access with
278
+ extra steps. It exists because the system subject has *no* tenant to infer.
279
+
280
+ One implementation detail worth knowing, because it is counter-intuitive: the
281
+ scoped store does **not** run as a `system` subject with a tenant attached. A
282
+ system subject carries `tenantId: null` by construction and the tenant mixin
283
+ special-cases it to skip scoping entirely — on a system subject, a null tenant
284
+ means *all* tenants. So the scoped view runs as a `serviceAccount` bound to that
285
+ one tenant, keeping the schedule's scopes.
286
+
253
287
  This behaves identically under `voltro dev` and `voltro serve`. It did not
254
288
  always — before 0.10.0, dev scoped schedules to `$TENANT` (default `acme`)
255
289
  while production ran them unscoped, so the same cron read one tenant in
@@ -22,6 +22,54 @@ Please report suspected vulnerabilities **privately** — never in public issues
22
22
 
23
23
  You'll get an **acknowledgement within 3 business days**, an assessment once we've reproduced the issue, and a **coordinated disclosure** timeline agreed with you — with credit in the release notes if you'd like it. We don't run a paid bug-bounty program yet, but we genuinely value responsible disclosure.
24
24
 
25
+ ## Outbound HTTP is SSRF-guarded by default
26
+
27
+ The `HttpClient` your handlers `yield*` refuses internal targets:
28
+
29
+ - loopback, RFC-1918, CGNAT and link-local addresses — including the
30
+ `169.254.169.254` cloud-metadata endpoint
31
+ - the hostnames `localhost`, `*.internal`, `*.local`
32
+ - any non-`http(s)` scheme
33
+
34
+ **Every redirect hop is revalidated, not just the URL you passed.** A public URL
35
+ that `302`s to the metadata endpoint is the actual attack; checking only the
36
+ initial target catches none of it.
37
+
38
+ This matters because a caller-supplied URL is ordinary product surface — a
39
+ scraper, a webhook-registration form, an importer, a "test this connection"
40
+ button. Those reach `HttpClient` with whatever the user typed.
41
+
42
+ ### Allowing a target on purpose
43
+
44
+ ```ts
45
+ // app.config.ts
46
+ export default defineApiConfig({
47
+ http: { allowHosts: ['*.svc.cluster.local', 'billing.internal'] },
48
+ })
49
+ ```
50
+
51
+ An entry may be an exact host, a `*.suffix` wildcard (which does **not** match the
52
+ apex — one that did would silently widen your exception), or `host:port` when only
53
+ one port should be reachable.
54
+
55
+ There is no boolean off-switch, deliberately: *"we call one internal service"* and
56
+ *"we do not check URLs"* are different postures, and a boolean cannot tell them
57
+ apart six months later.
58
+
59
+ ### In tests, allow the stub — don't mock the guard
60
+
61
+ Use the same `allowHosts` to reach a local stub server (`['127.0.0.1:8787']`).
62
+ That keeps the test on the real guarded code path with a narrow exception. Mocking
63
+ the guard away instead means the production path is never exercised — which is
64
+ exactly how this framework's own webhook delivery once ended up behind a
65
+ `NODE_ENV` check.
66
+
67
+ ### What this does NOT cover
68
+
69
+ DNS is not resolved. A public hostname that *resolves* to a private address (DNS
70
+ rebinding) still passes. That vector needs network-layer egress control; it is
71
+ stated here rather than silently implied.
72
+
25
73
  ## Supply-chain assurance
26
74
 
27
75
  Every release passes automated supply-chain gates in CI before a single package is published: