@voltro/cli 0.12.0 → 0.14.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 (189) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/dist/{apiBuild-GefjNI5d.js → apiBuild-370ITNRk.js} +3 -3
  3. package/dist/apiBuild-DatWP5hh.js +2 -0
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-BmNi1RgZ.js → commands-D-teL_TM.js} +3756 -2539
  6. package/dist/dbCommand-B3CeMU3u.js +2 -0
  7. package/dist/{dbCommand-DNrkb58g.js → dbCommand-Dmi-vHfA.js} +72 -57
  8. package/dist/{dev-d0LjACxE.js → dev-CKyW_LAv.js} +1511 -1331
  9. package/dist/dev-CPKbckaN.js +3 -0
  10. package/dist/fileConventions-COg0ZytD.js +9 -0
  11. package/dist/frameworkTableAssembly-DCLhpHsF.js +601 -0
  12. package/dist/frameworkTableAssembly-DYzb2elg.js +2 -0
  13. package/dist/index.js +1 -1
  14. package/dist/{inspectMetrics-uEfIS1vp.js → inspectMetrics-CyVMeofr.js} +962 -943
  15. package/dist/seedRunner-D6eu-u5U.js +229 -0
  16. package/dist/serveCommand-CU3Sw4ZM.js +1127 -0
  17. package/dist/serveEntry.js +3 -3
  18. package/dist/{start-DKcQd52a.js → start-D8ATvTzo.js} +246 -246
  19. package/dist/startEntry.js +2 -2
  20. package/package.json +17 -17
  21. package/templates/AGENTS.core.md +77 -3
  22. package/templates/AGENTS.md +78 -4
  23. package/templates/agent-docs/_index.md +1 -1
  24. package/templates/agent-docs/_manifest.json +2 -2
  25. package/templates/agent-docs/authentication.md +39 -0
  26. package/templates/agent-docs/cli.md +114 -6
  27. package/templates/agent-docs/configuration.md +48 -0
  28. package/templates/agent-docs/data.md +193 -1
  29. package/templates/agent-docs/database/hosting.md +2 -2
  30. package/templates/agent-docs/database/migrations.md +88 -3
  31. package/templates/agent-docs/database/overview.md +11 -0
  32. package/templates/agent-docs/database/seedsdialects.md +53 -1
  33. package/templates/agent-docs/database/transactions.md +31 -0
  34. package/templates/agent-docs/internationalization.md +3 -3
  35. package/templates/agent-docs/introduction.md +133 -2
  36. package/templates/agent-docs/plugins.md +2 -1
  37. package/templates/agent-docs/reference.md +4 -4
  38. package/templates/agent-docs/routing.md +48 -43
  39. package/templates/agent-docs/schema-driven-ui.md +12 -2
  40. package/templates/agent-docs/templates/apibackends.md +1 -8
  41. package/templates/agent-docs/templates/appshells.md +24 -24
  42. package/templates/agent-docs/testing.md +40 -1
  43. package/templates/agent-docs/whats-new.md +51 -131
  44. package/templates/apps/api-ai/database/schema.ts +0 -2
  45. package/templates/apps/api-ai/package.json +7 -7
  46. package/templates/apps/api-auth/package.json +8 -8
  47. package/templates/apps/api-backend/database/schema.ts +0 -3
  48. package/templates/apps/api-backend/package.json +7 -7
  49. package/templates/apps/api-backend-deactivation/database/schema.ts +0 -1
  50. package/templates/apps/api-backend-deactivation/package.json +7 -7
  51. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +1 -1
  52. package/templates/apps/api-backend-mail/database/schema.ts +0 -3
  53. package/templates/apps/api-backend-mail/package.json +8 -8
  54. package/templates/apps/api-backend-mariadb/database/schema.ts +0 -3
  55. package/templates/apps/api-backend-mariadb/package.json +9 -9
  56. package/templates/apps/api-backend-storage/database/schema.ts +0 -3
  57. package/templates/apps/api-backend-storage/package.json +8 -8
  58. package/templates/apps/api-data-advanced/database/authors.entity.ts +0 -2
  59. package/templates/apps/api-data-advanced/database/books.entity.ts +0 -1
  60. package/templates/apps/api-data-advanced/package.json +8 -8
  61. package/templates/apps/api-data-advanced/tests/queries.test.ts +6 -1
  62. package/templates/apps/api-durable/database/schema.ts +0 -3
  63. package/templates/apps/api-durable/package.json +8 -8
  64. package/templates/apps/api-feature-flags/database/schema.ts +0 -1
  65. package/templates/apps/api-feature-flags/package.json +9 -9
  66. package/templates/apps/api-governance/app.config.ts +0 -1
  67. package/templates/apps/api-governance/database/schema.ts +0 -1
  68. package/templates/apps/api-governance/package.json +8 -8
  69. package/templates/apps/api-kv/database/schema.ts +0 -3
  70. package/templates/apps/api-kv/package.json +8 -8
  71. package/templates/apps/api-kv/tests/sync.test.ts +2 -2
  72. package/templates/apps/api-moderation/database/schema.ts +0 -2
  73. package/templates/apps/api-moderation/package.json +8 -8
  74. package/templates/apps/api-observability/database/schema.ts +0 -1
  75. package/templates/apps/api-observability/package.json +8 -8
  76. package/templates/apps/api-ratelimit/database/schema.ts +0 -1
  77. package/templates/apps/api-ratelimit/package.json +8 -8
  78. package/templates/apps/api-rbac/README.md +53 -14
  79. package/templates/apps/api-rbac/app.config.ts +24 -26
  80. package/templates/apps/api-rbac/authz.ts +76 -0
  81. package/templates/apps/api-rbac/database/schema.ts +22 -7
  82. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +7 -5
  83. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +15 -8
  84. package/templates/apps/api-rbac/mutations/notes.delete.mutation.server.ts +54 -0
  85. package/templates/apps/api-rbac/mutations/notes.delete.mutation.ts +43 -0
  86. package/templates/apps/api-rbac/mutations/teams.rename.mutation.server.ts +19 -0
  87. package/templates/apps/api-rbac/mutations/teams.rename.mutation.ts +37 -0
  88. package/templates/apps/api-rbac/package.json +8 -8
  89. package/templates/apps/api-rbac/queries/notes.list.query.server.ts +12 -0
  90. package/templates/apps/api-rbac/queries/notes.list.query.ts +26 -0
  91. package/templates/apps/api-rbac/template.json +2 -2
  92. package/templates/apps/api-rbac/tests/authz.test.ts +233 -0
  93. package/templates/apps/api-rest/database/schema.ts +0 -3
  94. package/templates/apps/api-rest/package.json +7 -7
  95. package/templates/apps/api-saas/database/schema.ts +0 -1
  96. package/templates/apps/api-saas/package.json +11 -11
  97. package/templates/apps/api-search/database/schema.ts +0 -1
  98. package/templates/apps/api-search/package.json +8 -8
  99. package/templates/apps/api-versioning/database/schema.ts +0 -1
  100. package/templates/apps/api-versioning/package.json +8 -8
  101. package/templates/apps/api-webhooks/database/schema.ts +0 -1
  102. package/templates/apps/api-webhooks/package.json +9 -8
  103. package/templates/apps/changelog/package.json +6 -6
  104. package/templates/apps/changelog/src/pages/[locale]/{[slug].tsx → [slug].page.tsx} +2 -2
  105. package/templates/apps/changelog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  106. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
  107. package/templates/apps/changelog/src/pages/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  108. package/templates/apps/changelog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  109. package/templates/apps/edge-functions/package.json +2 -2
  110. package/templates/apps/frontend-admin/package.json +8 -8
  111. package/templates/apps/frontend-admin/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  112. package/templates/apps/frontend-admin/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  113. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
  114. package/templates/apps/frontend-admin/src/pages/admin/{index.test.tsx → index.page.test.tsx} +1 -1
  115. package/templates/apps/frontend-app/package.json +8 -8
  116. package/templates/apps/frontend-app/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  117. package/templates/apps/frontend-app/src/pages/{schema-ui.test.tsx → schema-ui.page.test.tsx} +1 -1
  118. package/templates/apps/frontend-blank/package.json +7 -7
  119. package/templates/apps/frontend-blank/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  120. package/templates/apps/frontend-contact/package.json +7 -7
  121. package/templates/apps/frontend-contact/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  122. package/templates/apps/frontend-contact/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  123. package/templates/apps/frontend-dashboard/package.json +7 -7
  124. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  125. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  126. package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.test.tsx → index.page.test.tsx} +1 -1
  127. package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.test.tsx → settings.page.test.tsx} +1 -1
  128. package/templates/apps/frontend-docs/package.json +7 -7
  129. package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].tsx → [...slug].page.tsx} +3 -3
  130. package/templates/apps/frontend-docs/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  131. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
  132. package/templates/apps/frontend-docs/src/pages/docs/{[...slug].test.tsx → [...slug].page.test.tsx} +1 -1
  133. package/templates/apps/frontend-docs/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  134. package/templates/apps/frontend-i18n/package.json +6 -6
  135. package/templates/apps/frontend-i18n/src/pages/[locale]/{about.tsx → about.page.tsx} +2 -2
  136. package/templates/apps/frontend-i18n/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  137. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
  138. package/templates/apps/frontend-i18n/src/pages/{about.test.tsx → about.page.test.tsx} +1 -1
  139. package/templates/apps/frontend-i18n/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  140. package/templates/apps/frontend-landing/package.json +7 -7
  141. package/templates/apps/frontend-landing/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  142. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
  143. package/templates/apps/frontend-landing/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  144. package/templates/apps/frontend-spa/package.json +7 -7
  145. package/templates/apps/frontend-spa/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  146. package/templates/apps/frontend-ssr/package.json +7 -7
  147. package/templates/apps/frontend-ssr/src/pages/{feed-swr.test.tsx → feed-swr.page.test.tsx} +1 -1
  148. package/templates/apps/frontend-ssr/src/pages/{feed.test.tsx → feed.page.test.tsx} +1 -1
  149. package/templates/apps/frontend-ssr/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  150. package/templates/apps/frontend-ssr-api/package.json +8 -8
  151. package/templates/apps/frontend-ssr-api/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  152. package/templates/apps/frontend-static-blog/package.json +6 -6
  153. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].tsx → [slug].page.tsx} +2 -2
  154. package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  155. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
  156. package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  157. package/templates/apps/frontend-static-blog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  158. package/dist/apiBuild-s7swuEU5.js +0 -2
  159. package/dist/dbCommand-Bt6BCG7g.js +0 -2
  160. package/dist/dev-YHbHmeXt.js +0 -3
  161. package/dist/seedRunner-DZmhoqNt.js +0 -821
  162. package/dist/serveCommand-B2-YV9TU.js +0 -1112
  163. package/templates/apps/api-rbac/tests/notes.create.test.ts +0 -80
  164. /package/templates/apps/changelog/src/pages/{[slug].tsx → [slug].page.tsx} +0 -0
  165. /package/templates/apps/changelog/src/pages/{index.tsx → index.page.tsx} +0 -0
  166. /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  167. /package/templates/apps/frontend-admin/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  168. /package/templates/apps/frontend-admin/src/pages/admin/{[entity].tsx → [entity].page.tsx} +0 -0
  169. /package/templates/apps/frontend-admin/src/pages/admin/{index.tsx → index.page.tsx} +0 -0
  170. /package/templates/apps/frontend-app/src/pages/{index.tsx → index.page.tsx} +0 -0
  171. /package/templates/apps/frontend-app/src/pages/{schema-ui.tsx → schema-ui.page.tsx} +0 -0
  172. /package/templates/apps/frontend-blank/src/pages/{index.tsx → index.page.tsx} +0 -0
  173. /package/templates/apps/frontend-contact/src/pages/{index.tsx → index.page.tsx} +0 -0
  174. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  175. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  176. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.tsx → index.page.tsx} +0 -0
  177. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.tsx → settings.page.tsx} +0 -0
  178. /package/templates/apps/frontend-docs/src/pages/docs/{[...slug].tsx → [...slug].page.tsx} +0 -0
  179. /package/templates/apps/frontend-docs/src/pages/{index.tsx → index.page.tsx} +0 -0
  180. /package/templates/apps/frontend-i18n/src/pages/{about.tsx → about.page.tsx} +0 -0
  181. /package/templates/apps/frontend-i18n/src/pages/{index.tsx → index.page.tsx} +0 -0
  182. /package/templates/apps/frontend-landing/src/pages/{index.tsx → index.page.tsx} +0 -0
  183. /package/templates/apps/frontend-spa/src/pages/{index.tsx → index.page.tsx} +0 -0
  184. /package/templates/apps/frontend-ssr/src/pages/{feed-swr.tsx → feed-swr.page.tsx} +0 -0
  185. /package/templates/apps/frontend-ssr/src/pages/{feed.tsx → feed.page.tsx} +0 -0
  186. /package/templates/apps/frontend-ssr/src/pages/{index.tsx → index.page.tsx} +0 -0
  187. /package/templates/apps/frontend-ssr-api/src/pages/{index.tsx → index.page.tsx} +0 -0
  188. /package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].tsx → [slug].page.tsx} +0 -0
  189. /package/templates/apps/frontend-static-blog/src/pages/{index.tsx → index.page.tsx} +0 -0
@@ -1,3 +1,3 @@
1
- import { X as e } from "./inspectMetrics-uEfIS1vp.js";
2
- import { t } from "./start-DKcQd52a.js";
1
+ import { $ as e } from "./inspectMetrics-CyVMeofr.js";
2
+ import { t } from "./start-D8ATvTzo.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.12.0",
3
+ "version": "0.14.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.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",
65
+ "@voltro/ai": "0.14.0",
66
+ "@voltro/cache": "0.14.0",
67
+ "@voltro/data-transfer": "0.14.0",
68
+ "@voltro/database": "0.14.0",
69
+ "@voltro/env": "0.14.0",
70
+ "@voltro/kv": "0.14.0",
71
+ "@voltro/logger": "0.14.0",
72
+ "@voltro/plugin-auth": "0.14.0",
73
+ "@voltro/plugin-broadcast": "0.14.0",
74
+ "@voltro/plugin-mail": "0.14.0",
75
+ "@voltro/plugin-storage": "0.14.0",
76
+ "@voltro/plugin-webhooks": "0.14.0",
77
+ "@voltro/protocol": "0.14.0",
78
+ "@voltro/runtime": "0.14.0",
79
+ "@voltro/serverless": "0.14.0",
80
+ "@voltro/workflow": "0.14.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -219,7 +219,7 @@ a `web/` (frontend).
219
219
  │ │ └── rpcGroup.generated.ts # codegen — do NOT edit
220
220
  │ └── web/
221
221
  │ ├── app.config.ts # { type:'web', apis, theme, locales, … }
222
- │ └── src/pages/**/*.tsx # file-based routing
222
+ │ └── src/pages/**/*.page.tsx # file-based routing (only *.page.tsx)
223
223
  ├── packages/ # shared libs
224
224
  └── AGENTS.md / CLAUDE.md # this file
225
225
  ```
@@ -249,7 +249,77 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
249
249
  | `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
250
250
  | `*.tool.tsx` | tool an agent can call |
251
251
  | `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
252
- | `src/pages/**/*.tsx` | web page (file-based route) |
252
+ | `*.page.tsx` (under `src/pages/`) | web page (file-based route) + `*.page.test.tsx` |
253
+ | `*.component.tsx` | exactly ONE component (+ types) |
254
+ | `*.component.ui.tsx` | presentational: one component, READS only — never writes |
255
+ | `*.hook.ts` | exactly ONE `use*` hook (+ types) |
256
+ | `*.types.ts` | types only — zero runtime exports |
257
+ | `*.internal.ts` | only its own directory subtree may import it |
258
+ | `*.fixture.ts` | test material — no production path may reach it |
259
+ | `*.tracking.ts` | the ONLY place analytics may be called from |
260
+ | `*.store.ts` | exactly ONE `defineStore` — client state, never server state |
261
+ | `*.client.ts` | declares itself + its transitive imports browser-safe |
262
+
263
+ **Only `*.page.tsx` is a route.** Everything else under `src/pages/` — components,
264
+ hooks, tests — is ordinary code with no URL, so put it next to the page that uses
265
+ it rather than in a distant `components/` directory. The suffix decides *whether*
266
+ a file is a route; the path under `src/pages/` decides *which URL*. A `*.page.tsx`
267
+ outside `src/pages/` never routes. A page MUST default-export its component —
268
+ without it `voltro dev` refuses to boot and names the file.
269
+
270
+ **Every suffix above is a CONTRACT, not a label.** Something else depends on each
271
+ promise, and `voltro doctor` enforces all of them:
272
+
273
+ - `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
274
+ never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
275
+ what lets a caller render it ten thousand times in a list, reuse it across
276
+ features, and prerender it without reading its source. Lift the write into the
277
+ owning `*.component.tsx` and pass a handler down.
278
+ - `*.internal.ts` is the promise that refactoring inside that directory breaks
279
+ nobody. An import from another subtree revokes it.
280
+ - `*.types.ts` having no runtime export is what makes importing it free and makes
281
+ it impossible for it to sit in a runtime import cycle.
282
+ - `*.store.ts` holds ONE `defineStore`. Reads go through a selector
283
+ (`wizard.use((s) => s.step)`) — there is no whole-state read, because a
284
+ component holding all of it re-renders on every field. Scope a per-entity
285
+ instance with a KEY (`{ key: orderId }`), never a Provider. Seed it for SSR
286
+ from a loader with `seedStore(...)`; it rides the hydration payload the router
287
+ already writes. An action that writes SEVERAL fields wraps them in
288
+ `store.batch('label', () => …)` — one notification, one devtools entry, one
289
+ undo step, and a rollback if it throws; `await` first, then batch (an async
290
+ callback is an error). `{ persist: { key } }` survives a reload.
291
+ NEVER put server data in a store: a subscription is already
292
+ live, and a copy is not — the page then shows the stale one.
293
+ - `*.tracking.ts` is where every `defineTracking(...)` spec lives — the event
294
+ catalogue, so "what do we send to third parties" is a file listing rather than
295
+ archaeology. A component wires one up with `useTracking(spec, props, sink)`; it
296
+ NAMES a spec, it never declares one. (`useTracking` is a hook, so it is not
297
+ confined — only the declaration is.)
298
+ - Do NOT rename a file to `*.component.ui.tsx` just because it happens not to
299
+ write today. The suffix is a promise about what it MAY do.
300
+
301
+ **These rules apply to code YOU write — never to vendored code.** A shadcn
302
+ component (`npx shadcn add`) follows shadcn's conventions and is overwritten by
303
+ the next `add`; renaming it breaks their convention and is undone next run. A
304
+ directory is exempt when the app's `components.json` names it (`aliases.ui`) or
305
+ it carries a `.voltro-vendored` file whose first line says where the code came
306
+ from. Never add that marker to a directory of your own code — the point of the
307
+ rules is that everything we author follows them.
308
+
309
+ **Write the test in the same step as the code — always, for every primitive.**
310
+ Not "afterwards", not in a cleanup pass: the filename is derivable
311
+ (`users/[id].page.tsx` → `users/[id].page.test.tsx`,
312
+ `Card.component.tsx` → `Card.component.test.tsx`,
313
+ `order.mutation.server.ts` → `order.mutation.server.test.ts`), so there is
314
+ nothing to decide. A change is not finished until its test exists and passes.
315
+
316
+ Two traps worth naming, because a green run can hide both:
317
+
318
+ - **`voltro test` transpiles without type-checking.** A type error in a TEST file
319
+ passes the suite and fails the package's `typecheck`. Run both.
320
+ - **Never fake a service to make a test pass.** A test that only re-asserts its
321
+ own mock's return value is worse than no test — it reports coverage it does not
322
+ have. Assert the descriptor shape instead and cover the real path elsewhere.
253
323
 
254
324
  ## app.config.ts
255
325
 
@@ -344,9 +414,13 @@ declare it). Mixins compose via `.with(...)` and auto-stamp bookkeeping columns:
344
414
  ```ts
345
415
  export const todos = table('todos', { id: id(), title: text(), done: boolean().default(false) })
346
416
  .with(softDelete(), tenant()) // tenant() transitively requires audit()
347
- .reactive() // opt into the matcher engine (subscriptions)
348
417
  ```
349
418
 
419
+ - Every table is REACTIVE by default — nothing to opt into. `.nonReactive()`
420
+ turns reactivity OFF for a table entirely: no subscriber fires, locally or
421
+ across instances (the write still happens). Use it for a hot table nobody
422
+ subscribes to; never for one a query reads — that subscription never fires,
423
+ and `voltro dev` warns.
350
424
  - `id()` defaults to a branded **TypeID** (`todo_…`). Insert auto-injects it.
351
425
  - Indexes are **table-level** (`.index('name', [...])`) — there is NO
352
426
  column-level `.index()`. Declare `.index([...])` AFTER `.with(...)` to address
@@ -219,7 +219,7 @@ a `web/` (frontend).
219
219
  │ │ └── rpcGroup.generated.ts # codegen — do NOT edit
220
220
  │ └── web/
221
221
  │ ├── app.config.ts # { type:'web', apis, theme, locales, … }
222
- │ └── src/pages/**/*.tsx # file-based routing
222
+ │ └── src/pages/**/*.page.tsx # file-based routing (only *.page.tsx)
223
223
  ├── packages/ # shared libs
224
224
  └── AGENTS.md / CLAUDE.md # this file
225
225
  ```
@@ -249,7 +249,77 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
249
249
  | `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
250
250
  | `*.tool.tsx` | tool an agent can call |
251
251
  | `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
252
- | `src/pages/**/*.tsx` | web page (file-based route) |
252
+ | `*.page.tsx` (under `src/pages/`) | web page (file-based route) + `*.page.test.tsx` |
253
+ | `*.component.tsx` | exactly ONE component (+ types) |
254
+ | `*.component.ui.tsx` | presentational: one component, READS only — never writes |
255
+ | `*.hook.ts` | exactly ONE `use*` hook (+ types) |
256
+ | `*.types.ts` | types only — zero runtime exports |
257
+ | `*.internal.ts` | only its own directory subtree may import it |
258
+ | `*.fixture.ts` | test material — no production path may reach it |
259
+ | `*.tracking.ts` | the ONLY place analytics may be called from |
260
+ | `*.store.ts` | exactly ONE `defineStore` — client state, never server state |
261
+ | `*.client.ts` | declares itself + its transitive imports browser-safe |
262
+
263
+ **Only `*.page.tsx` is a route.** Everything else under `src/pages/` — components,
264
+ hooks, tests — is ordinary code with no URL, so put it next to the page that uses
265
+ it rather than in a distant `components/` directory. The suffix decides *whether*
266
+ a file is a route; the path under `src/pages/` decides *which URL*. A `*.page.tsx`
267
+ outside `src/pages/` never routes. A page MUST default-export its component —
268
+ without it `voltro dev` refuses to boot and names the file.
269
+
270
+ **Every suffix above is a CONTRACT, not a label.** Something else depends on each
271
+ promise, and `voltro doctor` enforces all of them:
272
+
273
+ - `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
274
+ never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
275
+ what lets a caller render it ten thousand times in a list, reuse it across
276
+ features, and prerender it without reading its source. Lift the write into the
277
+ owning `*.component.tsx` and pass a handler down.
278
+ - `*.internal.ts` is the promise that refactoring inside that directory breaks
279
+ nobody. An import from another subtree revokes it.
280
+ - `*.types.ts` having no runtime export is what makes importing it free and makes
281
+ it impossible for it to sit in a runtime import cycle.
282
+ - `*.store.ts` holds ONE `defineStore`. Reads go through a selector
283
+ (`wizard.use((s) => s.step)`) — there is no whole-state read, because a
284
+ component holding all of it re-renders on every field. Scope a per-entity
285
+ instance with a KEY (`{ key: orderId }`), never a Provider. Seed it for SSR
286
+ from a loader with `seedStore(...)`; it rides the hydration payload the router
287
+ already writes. An action that writes SEVERAL fields wraps them in
288
+ `store.batch('label', () => …)` — one notification, one devtools entry, one
289
+ undo step, and a rollback if it throws; `await` first, then batch (an async
290
+ callback is an error). `{ persist: { key } }` survives a reload.
291
+ NEVER put server data in a store: a subscription is already
292
+ live, and a copy is not — the page then shows the stale one.
293
+ - `*.tracking.ts` is where every `defineTracking(...)` spec lives — the event
294
+ catalogue, so "what do we send to third parties" is a file listing rather than
295
+ archaeology. A component wires one up with `useTracking(spec, props, sink)`; it
296
+ NAMES a spec, it never declares one. (`useTracking` is a hook, so it is not
297
+ confined — only the declaration is.)
298
+ - Do NOT rename a file to `*.component.ui.tsx` just because it happens not to
299
+ write today. The suffix is a promise about what it MAY do.
300
+
301
+ **These rules apply to code YOU write — never to vendored code.** A shadcn
302
+ component (`npx shadcn add`) follows shadcn's conventions and is overwritten by
303
+ the next `add`; renaming it breaks their convention and is undone next run. A
304
+ directory is exempt when the app's `components.json` names it (`aliases.ui`) or
305
+ it carries a `.voltro-vendored` file whose first line says where the code came
306
+ from. Never add that marker to a directory of your own code — the point of the
307
+ rules is that everything we author follows them.
308
+
309
+ **Write the test in the same step as the code — always, for every primitive.**
310
+ Not "afterwards", not in a cleanup pass: the filename is derivable
311
+ (`users/[id].page.tsx` → `users/[id].page.test.tsx`,
312
+ `Card.component.tsx` → `Card.component.test.tsx`,
313
+ `order.mutation.server.ts` → `order.mutation.server.test.ts`), so there is
314
+ nothing to decide. A change is not finished until its test exists and passes.
315
+
316
+ Two traps worth naming, because a green run can hide both:
317
+
318
+ - **`voltro test` transpiles without type-checking.** A type error in a TEST file
319
+ passes the suite and fails the package's `typecheck`. Run both.
320
+ - **Never fake a service to make a test pass.** A test that only re-asserts its
321
+ own mock's return value is worse than no test — it reports coverage it does not
322
+ have. Assert the descriptor shape instead and cover the real path elsewhere.
253
323
 
254
324
  ## app.config.ts
255
325
 
@@ -344,9 +414,13 @@ declare it). Mixins compose via `.with(...)` and auto-stamp bookkeeping columns:
344
414
  ```ts
345
415
  export const todos = table('todos', { id: id(), title: text(), done: boolean().default(false) })
346
416
  .with(softDelete(), tenant()) // tenant() transitively requires audit()
347
- .reactive() // opt into the matcher engine (subscriptions)
348
417
  ```
349
418
 
419
+ - Every table is REACTIVE by default — nothing to opt into. `.nonReactive()`
420
+ turns reactivity OFF for a table entirely: no subscriber fires, locally or
421
+ across instances (the write still happens). Use it for a hot table nobody
422
+ subscribes to; never for one a query reads — that subscription never fires,
423
+ and `voltro dev` warns.
350
424
  - `id()` defaults to a branded **TypeID** (`todo_…`). Insert auto-injects it.
351
425
  - Indexes are **table-level** (`.index('name', [...])`) — there is NO
352
426
  column-level `.index()`. Declare `.index([...])` AFTER `.with(...)` to address
@@ -452,7 +526,7 @@ each plugin's own README.
452
526
 
453
527
  | Topic | Open | Summary |
454
528
  |---|---|---|
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. |
529
+ | **What's new in 0.13.0** | `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. |
456
530
  | 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. |
457
531
  | 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. |
458
532
  | 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,7 +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
+ | **What's new in 0.13.0** | `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. |
13
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. |
14
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. |
15
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. |
@@ -53,7 +53,7 @@
53
53
  "group": null,
54
54
  "description": "How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.",
55
55
  "path": "agent-docs/data.md",
56
- "files": 16
56
+ "files": 17
57
57
  },
58
58
  {
59
59
  "id": "database/advancedqueries",
@@ -179,7 +179,7 @@
179
179
  "group": null,
180
180
  "description": "Scaffold a Voltro project and boot it locally in under a minute.",
181
181
  "path": "agent-docs/introduction.md",
182
- "files": 5
182
+ "files": 6
183
183
  },
184
184
  {
185
185
  "id": "multi-tenancy",
@@ -563,6 +563,45 @@ const resolve = composeAuthStrategies(
563
563
 
564
564
  Order matters: put the cheapest / most-common strategy first. When no strategy matches, the resolver returns an [anonymous Subject](/docs/authentication/subject) scoped to the `x-tenant` header (or a custom `fallback` you supply).
565
565
 
566
+ ## Roles from your database — `auth.resolveScopes`
567
+
568
+ If your authorization is a database ROLE rather than a scope on the token, the framework cannot see it. `voltro check`'s `rbac/unguarded-mutation` reports every such write as unguarded — correctly, because nothing about the decision is declared — and the declarative alternative is unusable for you: subjects that come from an external IdP 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.
569
+
570
+ `resolveScopes` closes that. It runs after a strategy matches and adds scopes to the resolved Subject from whatever source you like:
571
+
572
+ ```ts
573
+ // app.config.ts
574
+ export default defineApiConfig({
575
+ auth: {
576
+ resolveScopes: async (subject, { store }) => {
577
+ if (store === undefined) return [] // still booting — claim nothing
578
+ const role = await roleCache.get(subject.id, store) // cache it — see below
579
+ return role === 'admin' ? ['employee:admin', 'employee:read'] : ['employee:read']
580
+ },
581
+ },
582
+ })
583
+ ```
584
+
585
+ The same authorization is now declarable on the descriptor:
586
+
587
+ ```ts
588
+ export const payrollList = defineQuery({
589
+ name: 'payroll.list',
590
+ guards: [requireScope('employee:admin')], // visible in the manifest, checkable in CI
591
+
592
+ })
593
+ ```
594
+
595
+ **You get the app's DataStore.** A role lives in the database, and without it the only way to reach one was a second connection path beside the framework's — to the same database the request store opens a moment later. It is the BOOT store, not a request-scoped one: strategies resolve before a request store exists, so it is `undefined` while the store is still being built. Return `[]` then rather than guessing.
596
+
597
+ **Scopes only — never a Subject.** The hook cannot change `id` or `tenantId`: identity belongs to the auth strategy, and a hook that could rewrite it would be a forgery surface. The returned scopes are UNIONED with whatever the strategy already set, so a resolver can grant authority but never revoke it.
598
+
599
+ **It does not run for anonymous callers** — there is no identity to look a role up for.
600
+
601
+ **Cache it yourself.** It is on the request path. A `Map` keyed by subject id with a short TTL is usually enough. The framework deliberately does not cache for you, because only you know how quickly a role change has to take effect.
602
+
603
+ Wired identically under `voltro dev` and `voltro serve`.
604
+
566
605
  ## Wiring it into the app
567
606
 
568
607
  The composed resolver becomes the runtime's `AuthMiddleware` — the per-request middleware that populates `SubjectService` so every handler can `yield* SubjectService` (or read `ctx.subject`). On a single-strategy password app you never touch this; the plugin wires `voltroPasswordStrategy` for you. You only assemble the chain explicitly when you add a second strategy:
@@ -154,6 +154,20 @@ requiring a scope no role grants, which makes that procedure permanently and
154
154
  silently uncallable. (With a custom `resolvePermissions` the vocabulary isn't
155
155
  exhaustive, so the rule stays quiet rather than flagging correct code.)
156
156
 
157
+ `rbac/unenforced-scope` is the same registry read in the other direction: a
158
+ scope a role GRANTS that no handler ever guards on. That direction has no
159
+ artefact to inspect — you cannot grep for an authorization check that was never
160
+ written, which is exactly why it survives review. One app modelled
161
+ `api-keys:write` in its role catalogue, complete and reviewed, and no handler
162
+ checked it: any member could mint a shared credential, and nothing failed.
163
+ Tests pass when an authorization check is missing.
164
+
165
+ It is a **warning**, because three innocent explanations exist: a plugin route
166
+ enforces it internally (the graph cannot see inside a plugin), a REST route
167
+ carries its own guards, or it is a UI-affordance scope that `useCan` reads to
168
+ hide a button and no server check backs on purpose. All three are fine. Not
169
+ knowing which is not.
170
+
157
171
  #### Declared vs OBSERVED — reconciled against reality
158
172
 
159
173
  A query's `source` and a mutation's `targets` are not documentation: the
@@ -163,7 +177,7 @@ nothing type-checks — the mutation succeeds, the write lands, and the wrong li
163
177
  fails to update.
164
178
 
165
179
  `voltro dev` records what each procedure ACTUALLY touched, into
166
- `app.graph.observed.json` (gitignored automatically). When that file is present,
180
+ `app.graph.observed.generated.json` (gitignored automatically). When that file is present,
167
181
  `check` diffs it against the declarations:
168
182
 
169
183
  ```
@@ -447,6 +461,45 @@ _voltro dev, codegen, agents-md — what runs during local development and the e
447
461
 
448
462
  `voltro dev` is the day-to-day command. It runs different machinery for api vs web apps but the contract is the same: edit a file, the right thing happens.
449
463
 
464
+ ### Running `voltro dev` in a container
465
+
466
+ If your dev pod runs as root with the host workspace bind-mounted, everything
467
+ the framework generates would otherwise land `root:root` inside your own tree —
468
+ and on the host `voltro build` then fails on its own output:
469
+
470
+ ```
471
+ EACCES: permission denied, open '…/apps/display/.framework/index.html'
472
+ ```
473
+
474
+ `voltro dev` and `voltro build` hand their generated output (`.framework`,
475
+ `.env.local`, every `*.generated.*`) to whoever owns the app root, and warn
476
+ loudly when they cannot. Only generated state — the framework never takes
477
+ ownership of a file you wrote.
478
+
479
+ The cleaner fix is on your side and worth doing anyway: start the container as
480
+ the workspace owner, `docker run --user $(id -u):$(id -g)`. Then nothing needs
481
+ handing over at all.
482
+
483
+ ### What the boot tells you
484
+
485
+ Three checks run at boot and print one line each when they have something to say
486
+ — never fatal, and silent when the answer is fine:
487
+
488
+ - **A reactive table with no change trigger** (postgres). The schema fingerprint
489
+ covers columns, not triggers, so a restored dump or a hand-run `DROP TRIGGER`
490
+ leaves the schema "up to date" and the subscription silently not reaching other
491
+ instances. The reverse is reported too: a `.nonReactive()` table still carrying
492
+ a trigger pays `REPLICA IDENTITY FULL` and a NOTIFY per write for nothing.
493
+ - **A `.nonReactive()` table that a query reads.** That subscription will never
494
+ fire — first snapshot, then silence forever.
495
+ - **`apiKeys: true` with no `apikeys:issue:*` scope declared.** The capability is
496
+ on and reachable by nobody; every issue request fails its guard.
497
+
498
+ `voltro doctor` adds a fourth, over your source: an executor that writes
499
+ `ctx.request.subject.id` while naming no guard, which is how an anonymous caller
500
+ reaches a NOT NULL column and gets a raw statement failure instead of a typed
501
+ refusal.
502
+
450
503
  ## `voltro dev <appDir>`
451
504
 
452
505
  ```bash
@@ -885,9 +938,31 @@ exactly the apps that already adopted it, and misses the ones that never did.
885
938
  Those are usually the same apps that secured least elsewhere, which is why the
886
939
  absence is worth naming out loud rather than assuming the default did its job.
887
940
 
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.
941
+ The rule follows the IMPORT GRAPH, not the filename. Server-convention files
942
+ (`*.server.ts`, `*.cron.ts`, `*.subscribe.ts`, …) are the starting points, and
943
+ any file reachable from them and from **nothing else** counts as server code
944
+ too. That matters: keyed on filenames alone the rule caught 9 of 39 outbound
945
+ calls on the app that reported it — the other 30 sat in `lib/*.ts` helpers
946
+ (payments, an AI provider, TTS) imported only from server executors. A
947
+ `lib/payments-mollie.ts` is not client code, and no file extension can say so.
948
+
949
+ A helper a page ALSO imports stays unflagged, and that is the property keeping
950
+ this rule useful: `fetch` is unremarkable in a browser component, and flagging it
951
+ there would make the rule noise that gets scrolled past — taking the real
952
+ findings with it. Relative imports and your tsconfig `paths` aliases are both
953
+ followed.
954
+
955
+ The detector also flags an **executor that never names its own descriptor**.
956
+ Pairing is by FILENAME, which is right — and it means a `*.server.ts` can be a
957
+ complete, correct executor with no reference at all to the contract it
958
+ implements. Those are exactly the files where a hand-written input drifts from
959
+ the wire: in one reported codebase, six executors declared `boardPurpose: string`
960
+ where their own descriptor said `Schema.Literal(...)`, discarding the contract at
961
+ the executor boundary. Fix by importing the descriptor and typing the input as
962
+ `ExecutorInput<typeof descriptor>`. Only a SIBLING import clears the finding —
963
+ an executor importing nothing but `@voltro/*` and `node:*` has still not named
964
+ its contract.
965
+
891
966
 
892
967
  ### The `workflows.start` audit
893
968
 
@@ -1014,6 +1089,39 @@ matching rule lives inside the CLI, so you cannot re-derive the list with your
1014
1089
  own grep. `--json` prints the complete scan, nothing elided, with no preflight
1015
1090
  output mixed in:
1016
1091
 
1092
+ ### Duplicate package instances
1093
+
1094
+ `voltro doctor` also reports any identity-sensitive package resolved at more than
1095
+ one version — `effect`, `@effect/*`, `@voltro/*`, react/react-dom:
1096
+
1097
+ ```
1098
+ • 1 package(s) resolved at more than one version:
1099
+ effect — 3.18.4, 3.21.0
1100
+ node_modules/effect
1101
+ ../../node_modules/effect
1102
+ ```
1103
+
1104
+ This is worth its own check because of how it PRESENTS. Effect's types are
1105
+ nominal, so a `Schema` built by one copy is not the type the other expects, and
1106
+ the errors land in the GENERATED `rpcGroup.generated.ts` — a file you cannot edit
1107
+ and did not write:
1108
+
1109
+ ```
1110
+ Property '[TypeId]' is missing in type … Schema<any, any, unknown>
1111
+ Type 'typeof Never' is not assignable to type 'All'
1112
+ Argument of type 'Rpc<…, Stream<…>, …>' is not assignable to 'Any'
1113
+ ```
1114
+
1115
+ Read cold, that says "the framework emits bad types". It says nothing about the
1116
+ dependency tree, which is where the problem is. And the RUNTIME usually stays
1117
+ green — two instances only diverge where identity matters — so the app boots,
1118
+ serves and passes its tests while `tsc` is red.
1119
+
1120
+ Fix it in the install, not the code: align the version across the workspace (a
1121
+ root `pnpm.overrides` / `resolutions` entry for `effect` is the blunt
1122
+ instrument), then reinstall. Do NOT add `@ts-nocheck` to the generated file — it
1123
+ is exactly where a genuine mistake in your own descriptors surfaces.
1124
+
1017
1125
  ```bash
1018
1126
  voltro doctor . --json # the complete scan: every file path, machine-readable
1019
1127
  ```
@@ -1064,8 +1172,8 @@ A page is listed when ALL of these hold:
1064
1172
 
1065
1173
  ```text
1066
1174
  • renderMode:'spa' candidates (2 pages — loader-free, under a layout, currently ssr/default):
1067
- src/pages/dashboard/index.tsx (/dashboard) — default renderMode
1068
- src/pages/admin/settings.tsx (/admin/settings) — renderMode:'ssr'
1175
+ src/pages/dashboard/index.page.tsx (/dashboard) — default renderMode
1176
+ src/pages/admin/settings.page.tsx (/admin/settings) — renderMode:'ssr'
1069
1177
  → renderMode:'spa' skips this page's SSR compile while its layout shell still renders server-side — adopt it if the page BODY does not need SSR (internal/authenticated pages); keep 'ssr' if the page content needs SEO or server first-paint.
1070
1178
  ```
1071
1179
 
@@ -326,6 +326,54 @@ const issued = await svc.issue({ tenantId, name: 'mobile app', scopes: ['read']
326
326
  // show issued.token ONCE; later: svc.rotate(id), svc.revoke(id), svc.list(tenantId)
327
327
  ```
328
328
 
329
+ ## The second ownership axis — `metadata`
330
+
331
+ `tenantId` and `onBehalfOf` are the two relationships the framework models. If
332
+ your keys also belong to something else — a team, a project, an environment —
333
+ and that binding is what authorizes them, store it in `metadata`:
334
+
335
+ ```ts
336
+ const key = await keys.issue({
337
+ tenantId: ctx.request.subject.tenantId,
338
+ name: 'CI deploy',
339
+ createdBy: ctx.request.subject.id, // who minted it
340
+ onBehalfOf: null, // an ORG key: acts as no person
341
+ metadata: { teamId: 'team_7' }, // your axis
342
+ })
343
+ ```
344
+
345
+ It comes straight back on resolve, so a guard needs no second query:
346
+
347
+ ```ts
348
+ const resolved = await keys.verify(token)
349
+ resolved?.metadata // { teamId: 'team_7' }
350
+ ```
351
+
352
+ It survives `rotate` — a rotated key is the same credential with a new secret,
353
+ so dropping it would silently de-authorize every rotated key. And it reaches the
354
+ Subject as `metadata`, alongside the framework's own claims.
355
+
356
+ **It is app data, never identity.** The strategy merges your bag UNDER its own
357
+ claims: `provider`, and the acting `userId`, are written afterwards from
358
+ `onBehalfOf` and always win — including when the answer is "none". A bag that
359
+ could set `userId` would let whoever minted a key choose who the request is.
360
+
361
+ Before this slot existed, an app with a team axis could authenticate through the
362
+ built-in strategy and still not authorize, so `apiKeys: true` was unusable for
363
+ it. The alternatives people reached for were a second table joined on every auth
364
+ check, or `team:<id>` smuggled into `scopes` — where `hasScope` then sees a scope
365
+ that is not a scope.
366
+
367
+ ## Two strategies, one prefix
368
+
369
+ If your app already runs its own key strategy on a prefix and you then enable
370
+ `apiKeys: true`, both claim the same shape. The chain is first-match-wins, so the
371
+ first one decides the Subject — and if they resolve to different authority,
372
+ *which strategy answered* decides whether authorization works.
373
+
374
+ `voltro dev` / `voltro serve` warn at boot when this happens. Give them distinct
375
+ prefixes (`apiKeys: { prefix: 'vk_' }`) or drop one.
376
+
329
377
  ## Security model
330
378
 
331
379
  - **Hash-only storage.** A DB dump never exposes a usable key — only `sha256(token)`. Lose a token → rotate it (`rotate` revokes the old + issues a fresh one with the same scopes).