@voltro/cli 0.11.4 → 0.13.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 (99) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/dist/{apiBuild-DHHuiwXF.js → apiBuild-B6_ehbem.js} +9 -8
  3. package/dist/apiBuild-Bzpb383i.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-DYOuGlEC.js} +2903 -2647
  8. package/dist/dbCommand-ADD4mrTI.js +2 -0
  9. package/dist/dbCommand-DIOJQwsq.js +876 -0
  10. package/dist/{dev-Cvojvb_J.js → dev-Fb9BmavX.js} +1697 -2411
  11. package/dist/dev-xdijqojc.js +3 -0
  12. package/dist/frameworkTableAssembly-CYlrafoK.js +600 -0
  13. package/dist/frameworkTableAssembly-XvKO7wQt.js +2 -0
  14. package/dist/index.d.ts +19 -0
  15. package/dist/index.js +3 -3
  16. package/dist/inspect-DcZ04OME.js +2 -0
  17. package/dist/inspect-Dwx0_tUj.js +921 -0
  18. package/dist/inspectMetrics-DOiiII34.js +3408 -0
  19. package/dist/manifestBuild-D1MzJAiQ.js +2 -0
  20. package/dist/manifestBuild-P9yuCY2d.js +39 -0
  21. package/dist/seedRunner-D6eu-u5U.js +229 -0
  22. package/dist/serveCommand-CqYxzBZR.js +1113 -0
  23. package/dist/serveEntry.js +4 -4
  24. package/dist/{start-DyJE6w1R.js → start-CjG_0xhR.js} +346 -343
  25. package/dist/startEntry.js +2 -2
  26. package/dist/startup.js +1 -1
  27. package/dist/{startupRunner-DhlX9nqd.js → startupRunner-DPGFchOa.js} +19 -33
  28. package/package.json +17 -17
  29. package/templates/AGENTS.md +1 -0
  30. package/templates/agent-docs/_index.md +1 -0
  31. package/templates/agent-docs/authentication.md +36 -0
  32. package/templates/agent-docs/cli.md +254 -14
  33. package/templates/agent-docs/configuration.md +48 -0
  34. package/templates/agent-docs/data.md +39 -0
  35. package/templates/agent-docs/database/migrations.md +32 -0
  36. package/templates/agent-docs/database/transactions.md +31 -0
  37. package/templates/agent-docs/scheduling.md +35 -1
  38. package/templates/agent-docs/security.md +48 -0
  39. package/templates/agent-docs/templates/apibackends.md +90 -17
  40. package/templates/agent-docs/testing.md +24 -0
  41. package/templates/agent-docs/whats-new.md +81 -0
  42. package/templates/apps/api-ai/package.json +7 -7
  43. package/templates/apps/api-auth/package.json +8 -8
  44. package/templates/apps/api-backend/package.json +7 -7
  45. package/templates/apps/api-backend-deactivation/package.json +7 -7
  46. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +1 -1
  47. package/templates/apps/api-backend-mail/package.json +8 -8
  48. package/templates/apps/api-backend-mariadb/package.json +9 -9
  49. package/templates/apps/api-backend-storage/package.json +8 -8
  50. package/templates/apps/api-data-advanced/package.json +8 -8
  51. package/templates/apps/api-data-advanced/tests/queries.test.ts +6 -1
  52. package/templates/apps/api-durable/package.json +8 -8
  53. package/templates/apps/api-feature-flags/package.json +9 -9
  54. package/templates/apps/api-governance/app.config.ts +0 -1
  55. package/templates/apps/api-governance/package.json +8 -8
  56. package/templates/apps/api-kv/package.json +8 -8
  57. package/templates/apps/api-kv/tests/sync.test.ts +2 -2
  58. package/templates/apps/api-moderation/package.json +8 -8
  59. package/templates/apps/api-observability/package.json +8 -8
  60. package/templates/apps/api-ratelimit/package.json +8 -8
  61. package/templates/apps/api-rbac/README.md +53 -14
  62. package/templates/apps/api-rbac/app.config.ts +24 -26
  63. package/templates/apps/api-rbac/authz.ts +76 -0
  64. package/templates/apps/api-rbac/database/schema.ts +23 -6
  65. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +7 -5
  66. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +15 -8
  67. package/templates/apps/api-rbac/mutations/notes.delete.mutation.server.ts +54 -0
  68. package/templates/apps/api-rbac/mutations/notes.delete.mutation.ts +43 -0
  69. package/templates/apps/api-rbac/mutations/teams.rename.mutation.server.ts +19 -0
  70. package/templates/apps/api-rbac/mutations/teams.rename.mutation.ts +37 -0
  71. package/templates/apps/api-rbac/package.json +8 -8
  72. package/templates/apps/api-rbac/queries/notes.list.query.server.ts +12 -0
  73. package/templates/apps/api-rbac/queries/notes.list.query.ts +26 -0
  74. package/templates/apps/api-rbac/template.json +2 -2
  75. package/templates/apps/api-rbac/tests/authz.test.ts +233 -0
  76. package/templates/apps/api-rest/package.json +7 -7
  77. package/templates/apps/api-saas/package.json +11 -11
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-versioning/package.json +8 -8
  80. package/templates/apps/api-webhooks/package.json +9 -8
  81. package/templates/apps/changelog/package.json +6 -6
  82. package/templates/apps/edge-functions/package.json +2 -2
  83. package/templates/apps/frontend-admin/package.json +8 -8
  84. package/templates/apps/frontend-app/package.json +8 -8
  85. package/templates/apps/frontend-blank/package.json +7 -7
  86. package/templates/apps/frontend-contact/package.json +7 -7
  87. package/templates/apps/frontend-dashboard/package.json +7 -7
  88. package/templates/apps/frontend-docs/package.json +7 -7
  89. package/templates/apps/frontend-i18n/package.json +6 -6
  90. package/templates/apps/frontend-landing/package.json +7 -7
  91. package/templates/apps/frontend-spa/package.json +7 -7
  92. package/templates/apps/frontend-ssr/package.json +7 -7
  93. package/templates/apps/frontend-ssr-api/package.json +8 -8
  94. package/templates/apps/frontend-static-blog/package.json +6 -6
  95. package/dist/apiBuild-D9MtlD02.js +0 -2
  96. package/dist/dev-BsBsIv93.js +0 -2
  97. package/dist/inspectMetrics-D4EJ8LTi.js +0 -4255
  98. package/dist/serveCommand-ARqSs73o.js +0 -1117
  99. package/templates/apps/api-rbac/tests/notes.create.test.ts +0 -80
@@ -2600,6 +2600,38 @@ export const users = table('users', {
2600
2600
 
2601
2601
  The planner folds the diff into one `ALTER TABLE users RENAME COLUMN firstName TO givenName`, classified `safe`. After the rename is applied in every env, the marker can be removed (covered in [rename-and-drop.md](./rename-and-drop.md)).
2602
2602
 
2603
+ ## "the migration did not converge" (apply refuses to record a fingerprint)
2604
+
2605
+ ```
2606
+ applyPlan: the migration did not converge. 31 operation(s) were executed without
2607
+ error, but re-planning against the live schema still finds 31:
2608
+ - alter-column-default todos.attachments
2609
+
2610
+ No fingerprint was recorded — recording one would make the next boot report
2611
+ "schema up to date" for a schema that was never applied.
2612
+ ```
2613
+
2614
+ Every statement ran and the database accepted every one of them, and none of them
2615
+ changed anything. That is possible because DDL that changes nothing succeeds
2616
+ exactly as quietly as DDL that works — `ALTER COLUMN x TYPE text` on a column
2617
+ that is already `text` is a valid, successful no-op.
2618
+
2619
+ This message exists because the alternative is worse. Before the convergence
2620
+ check, such a plan reported `applied 31 op(s)`, recorded a fingerprint, and every
2621
+ later boot short-circuited on "schema up to date" — for a schema that had never
2622
+ been applied. One app ran that way for two releases. The apply now proves it
2623
+ worked before it records anything: the same planner, run against the database as
2624
+ it now is, must have nothing left to do.
2625
+
2626
+ **It is a framework bug, not a mistake in your schema.** The named operations
2627
+ emit DDL that does not take effect. Report the operation kinds plus the column
2628
+ types involved. In the meantime the schema is unchanged and safe — nothing was
2629
+ half-applied, and no fingerprint was written, so `voltro db plan` still shows you
2630
+ the truth.
2631
+
2632
+ If you need to move forward before a fix lands, apply the equivalent DDL by hand
2633
+ and re-run `voltro db plan` to confirm it converges.
2634
+
2603
2635
  ## "Schema fingerprint mismatch" (prod refuse)
2604
2636
 
2605
2637
  Full error:
@@ -235,6 +235,37 @@ The `where` predicate is a regular [Predicate](/docs/database/query-builder#pred
235
235
  AST — same shape `.where()` uses. Sub-queries via `inSubquery` /
236
236
  `exists` are supported.
237
237
 
238
+ ### Typed: `updateManyRow`
239
+
240
+ `updateMany` takes a string table name and an untyped patch, so a misspelled
241
+ column or a wrongly-typed value is only found by the database — or not at all,
242
+ if the column happens to exist. `updateManyRow` takes the TABLE OBJECT instead
243
+ and checks the patch against the row type:
244
+
245
+ ```ts
246
+ import { updateManyRow, eq } from '@voltro/database'
247
+
248
+ await updateManyRow(ctx.store, posts, { hidden: true }, {
249
+ where: eq('userId', bannedId),
250
+ })
251
+
252
+ await updateManyRow(ctx.store, posts, { hiddne: true }, { where: … })
253
+ // ^^^^^^^ compile error: not a column
254
+ ```
255
+
256
+ It is worth using rather than the string form, and the evidence is concrete: one
257
+ app migrating 29 `store.upsert` call sites to the typed `upsertRow` got 15 `tsc`
258
+ errors across 8 distinct defects that no test had caught — including seven
259
+ per-user mutations with no authentication check at all, which wrote
260
+ `ctx.request.subject.id` (typed `string | null`) into a NOT NULL column, so an
261
+ anonymous caller reached the database and got a raw statement failure instead of
262
+ a typed refusal.
263
+
264
+ The subtlest one is the most persuasive: a value spread from a plain object
265
+ literal widens to `string`, and a column's `.oneOf()` union rejects it even
266
+ though the value IS one of the members. Neither a reviewer nor a test would
267
+ plausibly find that; only the row type asks the question. (`as const` fixes it.)
268
+
238
269
  ### Reactivity
239
270
 
240
271
  `updateMany` emits ONE ChangeEvent per affected row, so reactive
@@ -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:
@@ -2615,9 +2615,9 @@ done
2615
2615
  <!-- source: en/templates/api-rbac.md -->
2616
2616
  ## API · RBAC
2617
2617
 
2618
- _Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, and an in-handler permission(ctx, 'notes:write') guard that fails typed ScopeError. admin:full bypasses. Config-only, zero infra; useCan() for web UI._
2618
+ _Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, declarative descriptor guards (statically checked by voltro check), resource-scoped guards, and the in-handler permission()/can() guards. admin:full bypasses. Config-only, zero infra._
2619
2619
 
2620
- Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map and an rpc interceptor resolves each caller's **roles** to **scopes**, stamped on the subject. Guard handlers with `permission(ctx, 'notes:write')`, the Effect-native guard that fails typed `ScopeError`. The `admin:full` scope is a blanket bypass. Config-only, zero infra. Template id: **`api-rbac`**.
2620
+ Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map, and an rpc interceptor resolves each caller's **roles** into **scopes** published to the framework's effective-scope seam. From there both authorization forms see them. The `admin:full` scope is a blanket bypass. Config-only, zero infra. Template id: **`api-rbac`**.
2621
2621
 
2622
2622
  ## Scaffold
2623
2623
 
@@ -2627,40 +2627,99 @@ voltro create-project acme --api=api-rbac
2627
2627
 
2628
2628
  ## Roles as config
2629
2629
 
2630
- ```ts
2631
- // app.config.ts
2632
- import { rbacPlugin } from '@voltro/plugin-rbac'
2630
+ The role map is the app's **declared scope vocabulary**, not just a lookup table: `rbacPlugin` publishes the union of it, and [`voltro check`](/docs/cli/overview) fails on any descriptor guard requiring a scope that appears nowhere in it.
2633
2631
 
2634
- const roles = {
2632
+ ```ts
2633
+ // authz.ts — split out of app.config.ts so tests build the real plugin
2634
+ export const roles = {
2635
2635
  viewer: ['notes:read'],
2636
2636
  editor: ['notes:read', 'notes:write'],
2637
- admin: ['admin:full'], // blanket bypass — passes every check
2637
+ owner: ['notes:read', 'notes:write', 'notes:delete', 'teams:rename'],
2638
+ admin: ['*'], // wildcard → admin:full blanket bypass
2638
2639
  }
2640
+ ```
2641
+
2642
+ ```ts
2643
+ // app.config.ts
2644
+ import { rbacPlugin } from '@voltro/plugin-rbac'
2645
+ import { demoRolesForTenant, roles, rolesOnTeam } from './authz'
2639
2646
 
2640
2647
  rbacPlugin({
2641
2648
  roles,
2642
2649
  // PRODUCTION: the default resolver reads subject.metadata.roles (set by your
2643
2650
  // auth strategy), or do a DB lookup. The shipped DEMO maps tenant → role so
2644
- // you can try each via the x-tenant header with zero auth setup:
2645
- resolveRoles: (subject) =>
2646
- ({ acme: ['admin'], editors: ['editor'], readers: ['viewer'] })[subject.tenantId ?? ''] ?? ['viewer'],
2651
+ // you can try each via the x-tenant header with zero auth setup.
2652
+ resolveRoles: (subject) => demoRolesForTenant(subject.tenantId),
2653
+ // PER-RESOURCE roles see "Resource-scoped guards" below.
2654
+ resolveResourceRoles: (subject, resource) => rolesOnTeam(subject.id, resource),
2647
2655
  })
2648
2656
  ```
2649
2657
 
2650
- ## Guard with `permission()`
2658
+ ## Two guard forms — prefer the declarative one
2651
2659
 
2652
2660
  ```ts
2653
- // notes.create.mutation.server.ts
2654
- import { permission } from '@voltro/plugin-rbac'
2661
+ // notes.create.mutation.ts — DECLARATIVE, on the descriptor
2662
+ export const createNote = defineMutation({
2663
+ name: 'notes.create',
2664
+ target: { table: 'notes', op: 'insert' },
2665
+ guards: [{ scope: 'notes:write' }],
2666
+ input: Schema.Struct({ title: Schema.NonEmptyString, body: Schema.String }),
2667
+ output: Schema.Struct({ id: Schema.String, title: Schema.String }),
2668
+ })
2669
+ ```
2670
+
2671
+ The framework enforces this in the dispatch spine — before the executor, and before a mutation's transaction opens — so an unauthorized call never touches the DB. Declaring `guards:` also merges `ScopeError` into the wire error union automatically, so no `error:` field is needed and the client still decodes the denial typed. Reads take guards too, re-checked on every delivery, so a revoked role stops a live subscription.
2655
2672
 
2673
+ The reason to prefer it is **static checkability**: a declarative guard lands in the capability manifest, so `voltro check` catches a scope no role grants. An in-handler call is invisible to that check.
2674
+
2675
+ ```ts
2676
+ // notes.delete.mutation.server.ts — IN-HANDLER, for what needs the loaded row
2656
2677
  export default (input, ctx) => Effect.gen(function* () {
2657
- yield* permission(ctx, 'notes:write') // fails typed ScopeError for viewer
2658
2678
  const store = yield* EffectStore
2659
- return yield* store.insert('notes', { title: input.title, body: input.body })
2679
+ const rows = yield* Effect.promise(() =>
2680
+ ctx.store.query(database.notes.where(eq('id', input.id)).limit(1).descriptor))
2681
+ const note = rows[0]
2682
+ if (note === undefined) return yield* Effect.fail(new NoteNotFound({ id: input.id }))
2683
+
2684
+ // Only decidable AFTER the row is loaded — no descriptor guard can express it.
2685
+ const hard = note['archived'] === true || can(ctx, 'notes:purge')
2686
+ if (hard) { yield* store.delete('notes', input.id); return { id: input.id, mode: 'hard' } }
2687
+ yield* store.update('notes', input.id, { archived: true })
2688
+ return { id: input.id, mode: 'soft' }
2660
2689
  })
2661
2690
  ```
2662
2691
 
2663
- Declare `error: ScopeError` (from `@voltro/protocol`). Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw), `can(ctx, scope)` (boolean).
2692
+ Rule of thumb: put in `guards:` everything decidable from the **subject** and the **input**; reach for `permission()` / `can()` only for what needs loaded data. Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw), `can(ctx, scope)` (boolean, branches instead of refusing).
2693
+
2694
+ ## Resource-scoped guards
2695
+
2696
+ `guards: [{ scope, resource }]` makes a grant mean "owner **of this team**" instead of "owner globally".
2697
+
2698
+ ```ts
2699
+ // teams.rename.mutation.ts
2700
+ guards: [{ scope: 'teams:rename', resource: (input: { teamId: string }) => input.teamId }],
2701
+ ```
2702
+
2703
+ The `resource` extractor pulls the id out of the decoded input; rbac's `resolveResourceRoles` returns the roles the caller holds on that id, compiled through the same role map. A **globally** held scope (or `admin:full`) satisfies the guard without the resolver ever being called — only the gap falls through. That fall-through is **fail-closed**: a resolver that throws or rejects denies, it does not degrade to the global answer.
2704
+
2705
+ ## Failure postures, deliberately different
2706
+
2707
+ | Resolver | On throw / reject |
2708
+ | --- | --- |
2709
+ | `resolveRoles` / `resolvePermissions` | logs, degrades to the subject's **own** scopes — never grants, but an api key with valid scopes still works |
2710
+ | `resolveResourceRoles` | **denies** (fail-closed) |
2711
+
2712
+ Both hold for a synchronous throw as well as a rejected promise.
2713
+
2714
+ ## Static check
2715
+
2716
+ ```bash
2717
+ voltro check --offline # no running app needed — usable as a CI gate
2718
+ ```
2719
+
2720
+ A guard requiring `notes:wirte` when the roles grant `notes:write` is not merely misconfigured: that procedure is permanently, silently uncallable by everyone. `voltro check` reports it as `rbac/unknown-scope` and exits non-zero.
2721
+
2722
+ The registry stays **dormant** when a custom `resolvePermissions` is configured — that resolver merges scopes from outside the role map by design, so publishing the map alone would flag correct code.
2664
2723
 
2665
2724
  ## Try it
2666
2725
 
@@ -2676,6 +2735,18 @@ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json
2676
2735
  # → { ok:false, error:{ _tag:"ScopeError", required:"notes:write" } }
2677
2736
  ```
2678
2737
 
2738
+ ## Testing authorization
2739
+
2740
+ The shipped `tests/authz.test.ts` drives the **real** plugin: `makeTestContext({ plugins: [rbacPlugin({ roles, … })] })` composes the actual interceptor chain, and `invoke` enforces the descriptor's guards with the same function the serve pipeline calls.
2741
+
2742
+ ```ts
2743
+ const ctx = makeTestContext({ subject, store: mockStore({ notes: [] }), plugins: [plugin()] })
2744
+ await expect(invoke(createNote, createNoteHandler, { title: 'x', body: '' }, ctx))
2745
+ .rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:write' })
2746
+ ```
2747
+
2748
+ Do **not** hand-set `subject.scopes` to the value you expect. That tests the guard while assuming the resolution which produces it — and the resolution is the half that actually breaks (a renamed role, a throwing resolver, a scope no role grants). Those all pass a pre-stamped test and fail a real one.
2749
+
2679
2750
  ## On the web side
2680
2751
 
2681
2752
  ```tsx
@@ -2686,7 +2757,7 @@ const canWrite = useCan('notes:write')
2686
2757
  {canWrite && <NewNoteButton />} // hide affordances the user can't use
2687
2758
  ```
2688
2759
 
2689
- The UI gate ships in [`@voltro/client`](/docs/ui/client-utilities/use-can), not in the plugin: scopes are a framework concept, so gating a button must not require an rbac dependency. `useCan` is a UI affordance only — the server `permission()` guard is the enforcement.
2760
+ The UI gate ships in [`@voltro/client`](/docs/ui/client-utilities/use-can), not in the plugin: scopes are a framework concept, so gating a button must not require an rbac dependency. `useCan` is a UI affordance only — the server guard is the enforcement.
2690
2761
 
2691
2762
  ## Persisted roles
2692
2763
 
@@ -2695,6 +2766,8 @@ Set `tables: true` to store assignments in `roles` / `userRoles` tables and reso
2695
2766
  ## Anti-patterns
2696
2767
 
2697
2768
  - **Returning scopes from `resolveRoles`.** It returns role SLUGS (`['editor']`); the `roles` map turns those into scopes. Returning scopes directly bypasses the map.
2769
+ - **Re-checking a descriptor guard inside its own executor.** Two copies of one rule, and the copy `voltro check` reads is the descriptor's.
2770
+ - **Reaching for `permission()` when `guards:` would do.** It works, but it opts that procedure out of the static check.
2698
2771
  - **Scopes ≠ entitlements.** RBAC answers "may you call this"; billing entitlements (see [`api-saas`](/docs/templates/api-saas)) answer "do you have quota left". A procedure can need both.
2699
2772
 
2700
2773
 
@@ -66,6 +66,30 @@ voltro e2e # boot api + web siblings, run tests, tear down
66
66
 
67
67
  _makeTestContext — the in-memory request ctx for handler and tool tests. The real mixin-wrapped store, mockStore seeding, subject/tenant re-scoping, and the deterministic clock / email / LLM mocks._
68
68
 
69
+ ## tsconfig `paths` aliases
70
+
71
+ `voltro test` derives Vite's `resolve.alias` from your app's tsconfig
72
+ `compilerOptions.paths`, so an app that maps `@/* → ./src/*` can test modules
73
+ that import through it without any extra config:
74
+
75
+ ```jsonc
76
+ // tsconfig.json
77
+ { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }
78
+ ```
79
+
80
+ ```ts
81
+ import { greeting } from '@/locales/en' // resolves under `voltro test`
82
+ ```
83
+
84
+ Before this, the first person to write a test for each aliased file discovered
85
+ `Cannot find package '@/locales/en' imported from src/lib/i18n.ts` — one file at
86
+ a time — and worked around it with a local `vitest.config.ts` restating what
87
+ tsconfig already said. The dev and build pipelines resolve these already (your
88
+ app runs), so the test runner disagreeing with them was a gap, not a policy.
89
+
90
+ A project-local `vitest.config.ts` still merges on top, so an app that already
91
+ wrote the workaround keeps working.
92
+
69
93
  ## `makeTestContext`
70
94
 
71
95
  `makeTestContext(options?)` returns an **`AppContext`** — the exact `ctx` a mutation / query / action / tool executor receives at runtime — with the deterministic test doubles added on top. Because it *is* an `AppContext`, you pass it straight into a handler: `await myHandler(input, makeTestContext({ … }))`. The acting subject is at `ctx.request.subject`; `ctx.store` is the **real** mixin-wrapped store backed by an in-memory data store (so tenant auto-scoping, soft-delete filtering, audit auto-fill, and the fluent `select` / `update` / `delete` builders behave exactly as in production); `ctx.cache` is a real in-memory cache whose TTLs honour `ctx.clock`.
@@ -0,0 +1,81 @@
1
+ # What's new in 0.13.0
2
+
3
+ Read this FIRST when a task touches an area you have not worked in recently.
4
+ It is the cheapest way to notice that the framework grew the thing you were
5
+ about to hand-roll — the failure this section exists for is a team building a
6
+ workaround for something that shipped two versions ago.
7
+
8
+ BREAKING entries name a codemod; run `voltro update` to apply it.
9
+
10
+ ### ⚠ BREAKING
11
+
12
+ - **@voltro/protocol, @voltro/plugin-scim, @voltro/plugin-prometheus** — SCIM was served UNAUTHENTICATED whenever its token was an empty string.
13
+
14
+ `checkBearer(headers, expected)` returned `true` when `expected` was unset or empty, documented as "no token configured = open; the caller decided not to gate this surface". Its one production caller had decided the opposite: `scimPlugin` declares `token: string`, and `scimPlugin({ token: process.env.SCIM_TOKEN ?? '' })` — the shape anyone writes — turned the gate off silently. The result was SCIM 2.0 Users and Groups readable with no credentials: a full directory dump plus the provisioning surface that can deactivate accounts. Likeliest exactly where it hurts, too: an env var set in production and missing in a preview environment.
15
+
16
+ `checkBearer` is now fail-closed by default, with the permissive behaviour available as an explicit `{ openWhenUnset: true }` — a two-argument helper cannot know its caller's intent, so it must not assume the permissive one. `@voltro/plugin-prometheus` passes it (its token is documented as optional), and `scimPlugin` now throws at construction — i.e. at boot — rather than answering the first anonymous request.
17
+ - **@voltro/database, @voltro/cli** — `voltro db apply` and boot auto-migrate could report success while applying nothing, and then record a fingerprint that made every later boot short-circuit on "schema up to date".
18
+
19
+ Reported from a live pod: `applied 31 op(s)` on every boot for two releases, with none of the 31 present in the database. Nothing was wrong with the transport, the lock or the transaction — the applier emitted statements that postgres accepted and that changed nothing. Two independent causes:
20
+
21
+ - A `ColumnSnapshot` carried no `vector` dimension / `array` element / `enum` name, so the applier's type renderers collapsed all three to `text`. A declared `vector(1536)` over a live `text` column planned an `alter-column-type` that emitted `ALTER COLUMN … TYPE text`. Valid, applied, no-op, re-planned forever. (Also meant an `add-column` for a vector, array, enum or PostGIS column created a plain `text` column.) - The default-clause renderers excluded ARRAYS, returning `null`, and the call site turned that into `SET DEFAULT NULL`. A declared `.default([])` on a `json()` column therefore never landed — thirty columns were stuck this way in the reporting schema.
22
+
23
+ Fixed: the snapshot carries the type parameters and the renderers delegate to `migrate.ts`'s canonical `sqlType`, so the applier and the CREATE-TABLE emitter cannot disagree; array defaults render (a real `text[]` literal on a native `array()` column, a jsonb literal otherwise); and a default the renderer cannot express now FAILS instead of degrading to `DEFAULT NULL`.
24
+
25
+ And the structural guard, which is the part that matters: **`applyPlan` re-plans against the live schema before it records a fingerprint, and refuses to record one if any operation remains.** DDL that changes nothing succeeds exactly as quietly as DDL that works, so the only evidence a plan applied is that the same planner has nothing left to do. `ApplyPlanCtx` gains a required `replan`; `AppliedMigration` gains `appliedOps` (what EXECUTED, not `plan.operations.length`), and the boot log quotes that.
26
+ - **@voltro/plugin-storage** — `storage.share`, `storage.revoke` and `storage.listGrants` performed no authorization at all.
27
+
28
+ Each took an object id straight off the wire and passed it to a service method that (correctly, for a trusted server-side API) checks nothing, with nothing in between. Any authenticated caller could grant themselves read or write on any object in the installation, revoke anyone else's grants, and enumerate who an object is shared with.
29
+
30
+ All three now require that the caller owns the object, or carries `admin:full`. A missing object and an unowned object report the same 403 — a 404 would let an unauthorized caller probe which ids exist. `GrantStore` gains `getById`, which `revoke` needs to resolve a grant id back to its object.
31
+
32
+ ### Added
33
+
34
+ - **@voltro/runtime, @voltro/database** — API keys carry app-owned `metadata` — the second ownership axis.
35
+
36
+ `tenantId` and `onBehalfOf` are the two relationships the framework models. Plenty of apps have a third that actually authorizes the key: a team, a project, an environment. `ApiKeyRecord` in `@voltro/protocol` has carried a `metadata` slot all along — its doc comment even names `teamId` as the example — but the SERVICE had nowhere to store it and nowhere to return it. So an app with a team axis could authenticate through the built-in strategy and still not authorize, and `apiKeys: true` was unusable for it. Reported as the one thing that stopped an otherwise complete adoption; their alternatives were a second table joined on the hot auth path, or smuggling `team:<id>` into `scopes`, where `hasScope` would then see a scope that is not a scope.
37
+
38
+ `IssueInput`, `ApiKeyRow` and `ResolvedApiKey` now carry it, stored as JSON on `_voltro_api_keys`, and it survives `rotate` — a rotated key is the same credential with a new secret, so dropping it would silently de-authorize every rotated key.
39
+
40
+ It is app data, never identity. The strategy merges it UNDER the framework's own claims: `provider` and the acting `userId` are written afterwards from `onBehalfOf` and always win, including when the answer is "none". A bag that could set `userId` would let whoever minted the key choose who the request is. Pinned end-to-end, not just at the protocol layer.
41
+
42
+ `PublicApiKey` also gains `createdBy` and `onBehalfOf`, so `service.list` can answer the two questions an admin actually asks about a shared credential. Neither is a secret — they are the accountability record, and omitting them hid them from the person responsible for the key.
43
+ - **@voltro/protocol, @voltro/cli** — A boot warning when two auth strategies claim the same bearer-token prefix.
44
+
45
+ The chain is first-match-wins, so a duplicate claim is not a harmless redundancy: whichever strategy runs first decides the Subject. An app that already has its own `sk_` keys and then sets `apiKeys: true` gets the framework strategy appended on the same prefix — resolving without the app's own team binding — and *which strategy answered* decides whether authorization works. Reported by an app that had to pin a test asserting it never enables the flag.
46
+
47
+ `AuthStrategy` gains an optional `claimsBearerPrefix`, set by `apiKeyStrategy` from its `prefix` option. Making the claim declarative is what makes the collision detectable at all — the same "only what is declared can be checked" argument the scope rules run on. Checked in `buildResolveSubject`, which both `voltro dev` and `voltro serve` call, so the two boot paths cannot drift.
48
+
49
+ A warning rather than a refusal: two strategies on one prefix can be deliberate (a migration window where old and new keys share a shape). What must not happen is that it goes unmentioned.
50
+ - **@voltro/protocol, @voltro/cli** — `auth.resolveScopes` — add scopes to an authenticated Subject from your own data, so ROLE-based authorization becomes declarable.
51
+
52
+ An app whose authorization is a database role (`requireCallerAdmin(ctx)` reading an `employees.role` column) is invisible to every static check the framework has: `voltro check`'s `rbac/unguarded-mutation` reports its writes as unguarded, and it is right to — nothing about the decision is declared. But the declarative alternative was unusable for exactly those apps: their subjects come from an external IdP's JWTs and carry no scopes, so `requireScope('employee:admin')` would lock out every real user. One app measured 1566 findings it had no way to act on.
53
+
54
+ Lifting the role into `subject.scopes` makes the SAME authorization declarable, visible in the manifest and checkable in CI. Deliberately narrow: the hook returns SCOPES, never a Subject — it cannot change `id` or `tenantId` (identity belongs to the auth strategy), and the result is unioned with the strategy's own scopes, so it can grant but never revoke. It runs per matched request, so cache the lookup yourself; the framework does not, because only the app knows how fast a role change must take effect. Wired identically in `voltro dev` and `voltro serve`.
55
+ - **@voltro/cli** — `voltro doctor` reports packages resolved at more than one version.
56
+
57
+ A consumer reported type errors inside the GENERATED `rpcGroup.generated.ts` — `Property '[TypeId]' is missing`, `typeof Never is not assignable to All`, an `Rpc<…, Stream<…>, …>` refused where `Any` was expected — and reasonably concluded the framework emits bad types, because the errors land in a file they cannot edit and did not write. That is the signature of two copies of `effect` in one install: Effect's types are nominal, so a Schema built by one copy is not the type the other expects.
58
+
59
+ It deserves its own check because the RUNTIME usually stays green — two instances only diverge where identity matters — so an app boots, serves and passes its tests while `tsc` is red, which sends people looking at the compiler instead of the dependency tree. The report names the versions, the paths, and the errors it explains. Only identity-sensitive packages count (`effect`, `@effect/*`, `@voltro/*`, react/react-dom); a duplicated string utility is wasteful, not a bug class.
60
+ - **@voltro/cli** — `voltro doctor` flags an executor that never names its own descriptor.
61
+
62
+ Descriptor/executor pairing is by FILENAME, which is right — and it means a `*.server.ts` can be a complete, correct executor with no reference at all to the contract it implements. Those are exactly the files where a hand-written input drifts from the wire.
63
+
64
+ Reported after a 426-executor migration to `ExecutorInput<typeof descriptor>`: three files were skipped by the app's own codemod for a reason no reviewer would guess — they never imported their descriptor, so there was no `typeof` to point at. In the same codebase, six executors had written `boardPurpose: string` where their descriptor declared `Schema.Literal(...)`, discarding the contract at the executor boundary. Only imports of a SIBLING module clear the finding: an executor importing nothing but `@voltro/*` and `node:*` has still not named its contract.
65
+ - **@voltro/database** — `updateManyRow(store, table, patch, { where })` — the last untyped write is now typed against its table.
66
+
67
+ `insertRow` and `upsertRow` already were; `ctx.store.updateMany(table, row, { where })` still took a string table name and an untyped row literal. Worth closing because the typed versions were measured: migrating 29 `store.upsert` call sites to `upsertRow` produced 15 `tsc` errors across 8 distinct defects that no test had caught — including seven per-user mutations with no authentication check at all (they wrote `ctx.request.subject.id`, typed `string | null`, into a NOT NULL column, so an anonymous caller reached the database and got a raw statement failure instead of a typed refusal).
68
+
69
+ ### Fixed
70
+
71
+ - **@voltro/runtime** — A `cache:` declared on a query whose handler returns a COMPUTED value was silently ignored; it now says so.
72
+
73
+ The snapshot cache wraps the store read, and a computed query has none — its handler has already run by the time the binding is built. Caching one would mean wrapping the handler invocation, which is a different feature. Until that exists, the honest failure is a loud one: silently ignoring the config is how an author ends up believing a hot query is cached while every subscriber re-runs it. The data stays correct, so nothing else would ever tell them. Warned once per query name, not per subscribe.
74
+ - **@voltro/cli** — The minted `.env.local` is handed to the workspace's owner, and an unreadable env file explains itself.
75
+
76
+ A dev container running as root with the host workspace bind-mounted wrote `apps/api/.env.local` as `root:root 0600` INTO THE SHARED WORKSPACE. On the host, everything that loads env then died with EACCES — vitest, `voltro doctor`, the editor — and the developer could not even read the file, while the next container boot recreated it. Container-with-bind-mount is the ordinary dev shape, not an edge case.
77
+
78
+ `0600` stays (the file holds a real signing key), because loosening it to `0644` would make that key readable by every account on the machine for the far more common single-user case. Ownership was the wrong variable, so that is the one corrected: the mint chowns the file to whoever owns the directory, which root can do — exactly the case that needs it — and reports loudly when it cannot. A plain EACCES while loading an env file now names the owning uid, the mode and the current uid, because that pair IS the diagnosis and none of it appears in node's message.
79
+ - **@voltro/cli** — Four tooling fixes, all from downstream reports:
80
+
81
+ - **`voltro check --offline` crashed on any app that declares a workflow.** It built workflow entries as `{ name }` behind an `as never` while `InspectWorkflowEntry` is keyed by `tag`, so the manifest's sort read `undefined` and threw — surfacing as "could not assemble the graph from source" rather than the type error underneath. The cast is what let the two shapes disagree. - **`voltro check --offline` reported plugin tables as `dangling-source`.** It collected only the app's own `*.entity.ts` tables, so a query reading `_voltro_storage_refs` was an `error` — which sets the exit code, failing the CI gate the offline mode exists for. It now uses the same `assembleFrameworkTables` the migrator does. - **`voltro test` now derives `resolve.alias` from the app's tsconfig `paths`.** An app mapping `@/* → ./src/*` could not test any module importing through it (`Cannot find package '@/locales/en'`), and the workaround was a local `vitest.config.ts` restating what tsconfig already said. - **The `raw-fetch` doctor rule follows the import graph.** Keyed on filename conventions it caught 9 of 39 outbound calls on the reporting app; the other 30 were in `lib/*.ts` helpers only server code imports. A file reachable from a server-convention file and from nothing else is server code; one a page also imports is not, and stays unflagged.
@@ -11,16 +11,16 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/ai": "0.11.4",
15
- "@voltro/cli": "0.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/env": "0.11.4",
18
- "@voltro/protocol": "0.11.4",
19
- "@voltro/runtime": "0.11.4",
14
+ "@voltro/ai": "0.13.0",
15
+ "@voltro/cli": "0.13.0",
16
+ "@voltro/database": "0.13.0",
17
+ "@voltro/env": "0.13.0",
18
+ "@voltro/protocol": "0.13.0",
19
+ "@voltro/runtime": "0.13.0",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.11.4",
23
+ "@voltro/testing": "0.13.0",
24
24
  "typescript": "^5.7.0",
25
25
  "vitest": "^3.0.0"
26
26
  }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/env": "0.11.4",
18
- "@voltro/plugin-auth": "0.11.4",
19
- "@voltro/protocol": "0.11.4",
20
- "@voltro/runtime": "0.11.4",
21
- "@voltro/sql-postgres": "0.11.4",
15
+ "@voltro/cli": "0.13.0",
16
+ "@voltro/database": "0.13.0",
17
+ "@voltro/env": "0.13.0",
18
+ "@voltro/plugin-auth": "0.13.0",
19
+ "@voltro/protocol": "0.13.0",
20
+ "@voltro/runtime": "0.13.0",
21
+ "@voltro/sql-postgres": "0.13.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.11.4",
25
+ "@voltro/testing": "0.13.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/env": "0.11.4",
18
- "@voltro/plugin-multitenancy": "0.11.4",
19
- "@voltro/protocol": "0.11.4",
20
- "@voltro/runtime": "0.11.4",
15
+ "@voltro/cli": "0.13.0",
16
+ "@voltro/database": "0.13.0",
17
+ "@voltro/env": "0.13.0",
18
+ "@voltro/plugin-multitenancy": "0.13.0",
19
+ "@voltro/protocol": "0.13.0",
20
+ "@voltro/runtime": "0.13.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.11.4",
24
+ "@voltro/testing": "0.13.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/env": "0.11.4",
18
- "@voltro/plugin-deactivation": "0.11.4",
19
- "@voltro/protocol": "0.11.4",
20
- "@voltro/runtime": "0.11.4",
15
+ "@voltro/cli": "0.13.0",
16
+ "@voltro/database": "0.13.0",
17
+ "@voltro/env": "0.13.0",
18
+ "@voltro/plugin-deactivation": "0.13.0",
19
+ "@voltro/protocol": "0.13.0",
20
+ "@voltro/runtime": "0.13.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.11.4",
24
+ "@voltro/testing": "0.13.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -15,7 +15,7 @@ import { database } from '../database/schema' // registers users / actors / tena
15
15
  import createUser from '../mutations/users.create.mutation.server'
16
16
  import deactivateUser from '../mutations/users.deactivate.mutation.server'
17
17
 
18
- const subject = { type: 'user' as const, id: 'user_1', tenantId: null }
18
+ const subject = { type: 'user' as const, id: 'user_1', tenantId: 'acme' }
19
19
 
20
20
  describe('users.create', () => {
21
21
  it('inserts a user with an auto-injected id and no deactivation timestamp', async () => {
@@ -12,18 +12,18 @@
12
12
  "dependencies": {
13
13
  "@react-email/components": "^1.0.12",
14
14
  "@react-email/render": "^1.4.0",
15
- "@voltro/cli": "0.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/env": "0.11.4",
18
- "@voltro/plugin-mail": "0.11.4",
19
- "@voltro/plugin-multitenancy": "0.11.4",
20
- "@voltro/protocol": "0.11.4",
21
- "@voltro/runtime": "0.11.4",
15
+ "@voltro/cli": "0.13.0",
16
+ "@voltro/database": "0.13.0",
17
+ "@voltro/env": "0.13.0",
18
+ "@voltro/plugin-mail": "0.13.0",
19
+ "@voltro/plugin-multitenancy": "0.13.0",
20
+ "@voltro/protocol": "0.13.0",
21
+ "@voltro/runtime": "0.13.0",
22
22
  "effect": "^3.21.2",
23
23
  "react": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.11.4",
26
+ "@voltro/testing": "0.13.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }