@voltro/cli 0.50.1 → 0.52.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 (170) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
  3. package/dist/agentsMd-SDDSkyl4.js +2 -0
  4. package/dist/apiBuild-BYBpL7Pz.js +2 -0
  5. package/dist/{apiBuild-D3s7lx57.js → apiBuild-CSFI8QGq.js} +3 -3
  6. package/dist/appGraph-CFmFKHb2.js +2 -0
  7. package/dist/appGraph-yRuKjOba.js +111 -0
  8. package/dist/bin.js +1 -1
  9. package/dist/build-CPgcMQug.js +793 -0
  10. package/dist/checkCommand-2SbqzukH.js +2 -0
  11. package/dist/{checkCommand-DsKIjL6k.js → checkCommand-COmqc2cB.js} +107 -60
  12. package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
  13. package/dist/codegen-CctkDO-1.js +2 -0
  14. package/dist/{codegen-CrMXs4hb.js → codegen-VF479Cnb.js} +1 -1
  15. package/dist/{codegenCommand-C0S27nbB.js → codegenCommand-DCdG2JN-.js} +12 -12
  16. package/dist/{codemodRunner-DwY8Rjbl.js → codemodRunner-r7J9lIa7.js} +771 -636
  17. package/dist/{commands-Brp4DI8u.js → commands-Cc_nV8WI.js} +37 -36
  18. package/dist/{dashboardCommand-bbLfc9FW.js → dashboardCommand-C-gKvwqh.js} +3 -3
  19. package/dist/{dataCommand-k9mXTXin.js → dataCommand-BgpBHnlB.js} +299 -253
  20. package/dist/dbCommand-DNb6yeOG.js +2 -0
  21. package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-sHedr-NJ.js} +3 -3
  22. package/dist/dev--A3nsxA3.js +3 -0
  23. package/dist/{dev-C0cbqysu.js → dev-CRHoCEiy.js} +2258 -2219
  24. package/dist/doctorCommand-CqoWA2p5.js +2 -0
  25. package/dist/{doctorCommand-DyViaaCP.js → doctorCommand-DtfJ3FA6.js} +314 -234
  26. package/dist/{dormancyCommand-DMZPA_HW.js → dormancyCommand-Drn7o0No.js} +1 -1
  27. package/dist/{embeddingsCommand-CMxRPkg6.js → embeddingsCommand-Z-jO1fWN.js} +1 -1
  28. package/dist/{envCommand-CDLr-yD4.js → envCommand-D4gCrrTZ.js} +15 -15
  29. package/dist/{evolveCommand-Ct4NesL3.js → evolveCommand-CMROeKeA.js} +2 -2
  30. package/dist/fileConventions-DOqD3lPS.js +34 -0
  31. package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-DvDUV9wq.js} +1 -1
  32. package/dist/frameworkTableAssembly-C6ETawPR.js +2 -0
  33. package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-w-XnLa3q.js} +1 -1
  34. package/dist/{generateCommand-BDdOxhnv.js → generateCommand-DTNPGjxo.js} +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/{infoCommand-orsKJKUy.js → infoCommand-DXM868o_.js} +1 -1
  37. package/dist/inspectMetrics-CGF94puw.js +143 -0
  38. package/dist/{metaCommands-CfRLra0s.js → metaCommands-C6RFmF1r.js} +2 -2
  39. package/dist/{migrate-D3MK9BpK.js → migrate-D0F-eTlK.js} +2 -2
  40. package/dist/{pageConvention-cEiRxdab.js → pageConvention-CzUiSbtU.js} +1 -1
  41. package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DGdopOI6.js} +1 -1
  42. package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
  43. package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
  44. package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CcH2X1_D.js} +25 -10
  45. package/dist/{renderProfile-1OWWAAtx.js → renderProfile-Ck32Fzxr.js} +2 -2
  46. package/dist/{runtimeTrace-Bo8jWsNe.js → runtimeTrace-BPQyCmC5.js} +1 -1
  47. package/dist/{sdkgen-OZhiT2vz.js → sdkgen-Se88ifTd.js} +1 -1
  48. package/dist/serveCommand-DLc-BznW.js +2 -0
  49. package/dist/{serveCommand-YbD5VgfV.js → serveCommand-DkP3OT0W.js} +885 -868
  50. package/dist/serveEntry.js +1 -1
  51. package/dist/start-DfL3fOiN.js +3 -0
  52. package/dist/start-jw89Xbqy.js +1339 -0
  53. package/dist/startEntry.js +1 -1
  54. package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-BwNEDlSU.js} +1 -1
  55. package/dist/{test-rXFq4S76.js → test-f3amja6a.js} +1 -1
  56. package/dist/updateCommand-5gFVfK5q.js +2 -0
  57. package/dist/{updateCommand-BgH6vTtq.js → updateCommand-BMk2e4ky.js} +1 -1
  58. package/dist/{webDev-BbdkzMbd.js → webDev-BgWL9gKV.js} +1233 -853
  59. package/dist/webDev-CZbTsDcH.js +2 -0
  60. package/dist/{webhooksCommand-DjE6u0r4.js → webhooksCommand-CoIO3jbj.js} +1 -1
  61. package/package.json +48 -17
  62. package/templates/AGENTS.core.md +26 -5
  63. package/templates/AGENTS.md +30 -9
  64. package/templates/agent-docs/_index.md +4 -4
  65. package/templates/agent-docs/_manifest.json +11 -11
  66. package/templates/agent-docs/cli.md +125 -14
  67. package/templates/agent-docs/data.md +210 -7
  68. package/templates/agent-docs/database/schema.md +1 -1
  69. package/templates/agent-docs/database/seedsdialects.md +1 -1
  70. package/templates/agent-docs/deployment.md +22 -3
  71. package/templates/agent-docs/internationalization.md +131 -13
  72. package/templates/agent-docs/introduction.md +46 -0
  73. package/templates/agent-docs/local-first-mobile.md +34 -7
  74. package/templates/agent-docs/plugins/ai-flows.md +1 -1
  75. package/templates/agent-docs/plugins/audit.md +5 -5
  76. package/templates/agent-docs/plugins/cdc-out.md +2 -2
  77. package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
  78. package/templates/agent-docs/plugins/storage.md +2 -2
  79. package/templates/agent-docs/plugins.md +29 -7
  80. package/templates/agent-docs/reference.md +39 -2
  81. package/templates/agent-docs/routing.md +341 -47
  82. package/templates/agent-docs/schema-driven-ui.md +78 -2
  83. package/templates/agent-docs/security.md +125 -8
  84. package/templates/agent-docs/templates/apibackends.md +14 -14
  85. package/templates/agent-docs/templates/overview.md +1 -1
  86. package/templates/agent-docs/whats-new.md +100 -29
  87. package/templates/apps/api-ai/package.json +6 -7
  88. package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
  89. package/templates/apps/api-auth/package.json +8 -8
  90. package/templates/apps/api-backend/package.json +7 -7
  91. package/templates/apps/api-backend-deactivation/package.json +7 -7
  92. package/templates/apps/api-backend-mail/package.json +8 -8
  93. package/templates/apps/api-backend-mariadb/package.json +9 -9
  94. package/templates/apps/api-backend-sqlite/package.json +8 -8
  95. package/templates/apps/api-backend-storage/package.json +8 -8
  96. package/templates/apps/api-cms/package.json +9 -10
  97. package/templates/apps/api-collab/package.json +8 -8
  98. package/templates/apps/api-data-advanced/package.json +8 -8
  99. package/templates/apps/api-durable/package.json +8 -8
  100. package/templates/apps/api-feature-flags/package.json +9 -9
  101. package/templates/apps/api-governance/package.json +8 -8
  102. package/templates/apps/api-kv/package.json +8 -8
  103. package/templates/apps/api-moderation/package.json +8 -8
  104. package/templates/apps/api-observability/package.json +8 -8
  105. package/templates/apps/api-ratelimit/package.json +8 -8
  106. package/templates/apps/api-rbac/package.json +8 -8
  107. package/templates/apps/api-rest/package.json +7 -7
  108. package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
  109. package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
  110. package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
  111. package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
  112. package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
  113. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
  114. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
  115. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
  116. package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
  117. package/templates/apps/api-row-history/template.json +6 -0
  118. package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
  119. package/templates/apps/api-saas/app.config.ts +1 -0
  120. package/templates/apps/api-saas/package.json +10 -11
  121. package/templates/apps/api-saas-starter/package.json +10 -10
  122. package/templates/apps/api-search/package.json +8 -8
  123. package/templates/apps/api-status/package.json +8 -8
  124. package/templates/apps/api-webhooks/package.json +9 -9
  125. package/templates/apps/changelog/package.json +6 -6
  126. package/templates/apps/edge-functions/package.json +2 -2
  127. package/templates/apps/frontend-admin/package.json +8 -8
  128. package/templates/apps/frontend-app/package.json +9 -9
  129. package/templates/apps/frontend-auth/package.json +8 -8
  130. package/templates/apps/frontend-blank/package.json +7 -7
  131. package/templates/apps/frontend-cms/package.json +9 -9
  132. package/templates/apps/frontend-collab/package.json +10 -10
  133. package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
  134. package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
  135. package/templates/apps/frontend-contact/package.json +7 -7
  136. package/templates/apps/frontend-dashboard/package.json +7 -7
  137. package/templates/apps/frontend-docs/package.json +6 -7
  138. package/templates/apps/frontend-i18n/package.json +6 -6
  139. package/templates/apps/frontend-landing/package.json +6 -7
  140. package/templates/apps/frontend-portal/package.json +8 -8
  141. package/templates/apps/frontend-saas/package.json +8 -8
  142. package/templates/apps/frontend-spa/package.json +7 -7
  143. package/templates/apps/frontend-ssr/package.json +7 -7
  144. package/templates/apps/frontend-ssr-api/package.json +8 -8
  145. package/templates/apps/frontend-static-blog/package.json +6 -6
  146. package/templates/apps/frontend-status/package.json +8 -8
  147. package/templates/apps/mobile-app/package.json +4 -4
  148. package/dist/agentsMd-Bu_XQgVf.js +0 -2
  149. package/dist/apiBuild-BZ-plaQN.js +0 -2
  150. package/dist/appGraph-CM9wvUmD.js +0 -2
  151. package/dist/appGraph-Cv2W_wO5.js +0 -111
  152. package/dist/build-CFmqF39c.js +0 -721
  153. package/dist/checkCommand-DomvYDi1.js +0 -2
  154. package/dist/codegen-DiMn2KkZ.js +0 -2
  155. package/dist/dbCommand-Bnl7MSGC.js +0 -2
  156. package/dist/dev-Dz9GqA6P.js +0 -3
  157. package/dist/doctorCommand-BlciWUJb.js +0 -2
  158. package/dist/fileConventions-Cof68_BL.js +0 -33
  159. package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
  160. package/dist/inspectMetrics-CfdKLh6t.js +0 -72
  161. package/dist/serveCommand-Bbgg7Paq.js +0 -2
  162. package/dist/start-7b_bL8Uc.js +0 -3
  163. package/dist/start-B1nH9ahf.js +0 -1150
  164. package/dist/updateCommand-BweY17z-.js +0 -2
  165. package/dist/webDev-BcRFTsMA.js +0 -2
  166. package/templates/apps/api-versioning/template.json +0 -6
  167. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
  168. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
  169. /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
  170. /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
@@ -199,8 +199,8 @@ A combined create-or-edit screen is a three-line wrapper:
199
199
  - **Rung 3 — own the layout.** Pass children + arrange `<Field>`s (columns,
200
200
  sections, tabs); each still auto-renders.
201
201
  - **Eject (headless).** `useFormBinding('app', 'todos.create')` returns
202
- `{ fields, values, errors, isValid, pending, setValue, submit, reset }` for
203
- 100% custom JSX — the binding stays.
202
+ `{ fields, values, errors, formError, isValid, pending, setValue, submit, reset }`
203
+ for 100% custom JSX — the binding stays.
204
204
 
205
205
  ### Accessible by default
206
206
 
@@ -214,6 +214,82 @@ Schema.Struct({
214
214
  })
215
215
  ```
216
216
 
217
+ ### Forms without JavaScript
218
+
219
+ On a server-rendered page, `<AutoForm>` works with JavaScript disabled — or not
220
+ yet loaded. It always renders `action="/form/<mutationTag>"` + `method="post"`,
221
+ so the browser has a native form-POST fallback; with JavaScript, `onSubmit`
222
+ intercepts as before (the RPC path, optimistic — unchanged).
223
+
224
+ The `/form/<mutationTag>` endpoint is mounted by the WEB listener on both boot
225
+ paths (`voltro dev` AND `voltro start`). It maps the posted `FormData` against
226
+ the SAME input schema the RPC path decodes:
227
+
228
+ - a checkbox present → `true`, absent → `false`
229
+ - `''` on a number/date field → the field is omitted (an optional field stays
230
+ absent; a required one reports "missing" — never a silent `0`)
231
+ - arrays arrive as repeated keys (`getAll` semantics)
232
+ - a non-numeric string passes through RAW, so the decode fails honestly (never
233
+ `NaN`)
234
+ - unknown keys are dropped
235
+
236
+ Validation runs through the same `validateFields` as the client-side
237
+ validation, so the error texts are identical. Then:
238
+
239
+ - **Success → `303 See Other`** (POST-redirect-GET): back to the submitting
240
+ page, or to `redirectTo` (same-origin relative paths only; anything else is
241
+ discarded). Reloading the target page never produces a second write.
242
+ - **Validation error → `422`**: the referer page is re-rendered in the same
243
+ response, with the field errors and the entered values server-side in the
244
+ same error UI (`role="alert"`, aria unchanged) — sent with
245
+ `cache-control: no-store`, past the ISR cache. An RPC error AFTER valid
246
+ input (a guard, the server) renders as a form-level error (`role="alert"`,
247
+ `data-voltro-form-error`).
248
+ - **Multipart → `415`** ("file uploads need JavaScript").
249
+
250
+ Three props exist for this path:
251
+
252
+ - **`formKey?: string`** — distinguishes several forms on one page in the
253
+ no-JS round-trip (default: the mutation tag); the 422 re-render fills only
254
+ the submitted form.
255
+ - **`redirectTo?: string`** — where the success `303` goes (no-JS path only;
256
+ with JavaScript, `onSuccess` applies). A same-origin relative path.
257
+ - **`action?: false`** — renders no `action` attribute, for pure
258
+ static-hosting deploys (dist on a CDN without `voltro start`), where
259
+ `/form/*` does not exist.
260
+
261
+ **On SSR pages, pass `schema` explicitly.** Descriptor resolution
262
+ (`descriptors[tag].input`) is a client-runtime feature; the SSR render sees an
263
+ empty descriptor map and would render zero fields without the `schema` prop.
264
+ Recommended source: import the schema from a shared browser-safe file — the
265
+ same one the mutation uses. One schema, no drift.
266
+
267
+ ```tsx
268
+ export const renderMode = 'ssr' as const
269
+
270
+ <AutoForm api="app" mutation="notes.create" schema={noteInput} redirectTo="/thanks" />
271
+ ```
272
+
273
+ Three limits, and this list is complete:
274
+
275
+ 1. **The no-JS ERROR display** (the 422 re-render) works only on `ssr`/`isr`
276
+ pages — the server cannot re-render a static page with request state
277
+ (fallback: a minimal error page).
278
+ 2. **Pure static-hosting deploys** (dist on a CDN, no `voltro start`) have no
279
+ `/form/` endpoint — set `action={false}` there.
280
+ 3. **File uploads stay JS-only** (multipart → `415`).
281
+
282
+ Security-wise, the endpoint forwards server-side to the api as `POST /rpc` —
283
+ auth middleware, guards and RPC interceptors run identically to the normal RPC
284
+ path; details, including the one boundary, in the
285
+ [security overview](/docs/security/overview).
286
+
287
+ Headless: `useFormBinding` now takes a `flash` option and returns a
288
+ `formError` field; `useFormFlash(formKey)` (`@voltro/web`) returns the flash —
289
+ on SSR from the request context, on the client from the
290
+ `#__voltro_form_flash__` JSON script. Both are identical, so hydration is
291
+ deterministic.
292
+
217
293
  ## `<DataTable>` — bind to a QUERY
218
294
 
219
295
  Columns come from the query's output `Schema`; rows are a LIVE subscription
@@ -172,7 +172,8 @@ one machine is invisible to code review.
172
172
  ## Cross-site requests are refused
173
173
 
174
174
  **Every request that can change state is checked** — anything but `GET`, `HEAD`
175
- and `OPTIONS` — plus the `/ws` upgrade, which is a GET. That is deliberately a
175
+ and `OPTIONS` — plus **every WebSocket upgrade**, which is a GET: the rpc
176
+ socket's `/ws` AND every raw gateway path a `*.ws.ts` file mounts. That is deliberately a
176
177
  rule about the METHOD rather than a list of paths, because a mutation reaches
177
178
  your api by more than one road: `POST /rpc`, every REST route projected from a
178
179
  `publicApi:` mutation, everything in `apiConfig.restRoutes`, and
@@ -192,6 +193,43 @@ bearer/JWT flow.
192
193
  Reads are not checked: a `GET` cannot be a cross-site write, and checking it
193
194
  would break every link into your api.
194
195
 
196
+ ### Raw WebSocket gateways are guarded before the upgrade
197
+
198
+ A [`defineWebSocket` gateway](/docs/data/subscriptions#raw-websocket-gateways--definewebsocket)
199
+ mounts its own upgrade path beside the rpc socket, and the listener treats it
200
+ exactly like the rpc upgrade:
201
+
202
+ - **Origin-checked BEFORE the upgrade.** An upgrade is a GET, so the
203
+ state-changing rule above never covers it — every gateway path therefore
204
+ joins the upgrade origin guard's set, and a cross-origin upgrade is `403`.
205
+ That closes cross-site WebSocket hijacking for every gateway, not only the
206
+ framework's own socket.
207
+ - **Authenticated BEFORE the upgrade.** `auth` is required with no default:
208
+ `'subject'` resolves the caller through the SAME auth chain as rpc/SSR and
209
+ answers `401` while the request is still plain http — no socket ever exists
210
+ for an unauthenticated caller — and the connection is bound to the
211
+ credential's expiry (application close code `4001`, so a foreign client can
212
+ re-auth and reconnect). `'public'` is a decision somebody wrote down, not a
213
+ default anyone fell into.
214
+
215
+ ### The web listener's `/form/*` endpoint is checked the same way
216
+
217
+ `<AutoForm>`'s no-JavaScript fallback POSTs to `/form/<mutationTag>` on the
218
+ **web** listener — mounted on both boot paths, `voltro dev` and `voltro start`.
219
+ That is a state-changing surface too, and it is classified by the same
220
+ `classifyRequestOrigin` as the api rpc listener: a cross-origin form POST gets
221
+ the same `403`.
222
+
223
+ Behind the check, the endpoint forwards the request server-side to the api as
224
+ `POST /rpc` — auth middleware, guards and rpc interceptors run identically to
225
+ the normal rpc path, with the session resolved from the cookie exactly as an
226
+ SSR render resolves it.
227
+
228
+ One boundary, stated explicitly: `@voltro/plugin-ratelimit`'s **pre-auth IP
229
+ shield** runs only on the api listener and never sees `/form/*`. The
230
+ ratelimit **rpc interceptor** does see every write, because the endpoint
231
+ reaches the api as `POST /rpc` like any other mutation.
232
+
195
233
  ### A split web/api deployment must declare its origins
196
234
 
197
235
  If your web app is served from a different origin than your api, say so — or
@@ -307,6 +345,18 @@ signed ticket the attacker does not have", the route is exempt. Otherwise it is
307
345
  not. The exemption covers the route's whole path prefix, not the sub-paths its
308
346
  handler branches on.
309
347
 
348
+ ## Shared (isr) renders never see credentials
349
+
350
+ An `isr` page's HTML is cached and served to every visitor inside its
351
+ revalidate window — a shared artefact. The framework therefore strips
352
+ credential material before the render runs: the cookie jar (except
353
+ `voltro:locale`), `authorization`, and every `x-voltro-*` header never reach an
354
+ isr page's loaders, `ctx.query`, or `useServerRequest()`. A subject-reading
355
+ loader gets the anonymous answer instead of caching one user's data for
356
+ everyone, and the strip applies identically under `voltro dev` and
357
+ `voltro start`. Details: [Render modes → isr renders are
358
+ anonymous](/docs/routing/render-modes).
359
+
310
360
  ## The client IP comes from the socket, not from a header
311
361
 
312
362
  `x-forwarded-for` is a request header, so **any client can write it**. Voltro
@@ -420,6 +470,17 @@ Environment overrides: `VOLTRO_SECURITY_HEADERS` (`off|default|strict`),
420
470
  just that one. Embedders pass the same object as
421
471
  `RpcServerOptions.security.headers`.
422
472
 
473
+ ### A nonce-based CSP for the web app's HTML
474
+
475
+ The headers above are the **api** listener's. For the web app's rendered HTML,
476
+ `middleware.ts` can mint a per-request `cspNonce`: the framework stamps
477
+ `nonce="…"` onto every script tag of that render — the state script, the
478
+ deferred registry, the shell's bundle tags, and React's own bootstrap/settle
479
+ scripts — while the policy header itself stays the middleware's to set via
480
+ `responseHeaders`, carrying the same nonce. An `isr` page combined with
481
+ `cspNonce` refuses the render: a cached nonce is a lie the browser enforces.
482
+ Mechanics + examples: [Middleware → cspNonce](/docs/routing/middleware#a-per-request-csp-nonce--cspnonce).
483
+
423
484
  ## Incoming webhooks must verify their caller
424
485
 
425
486
  An incoming webhook is a **public POST that runs your application code**. A
@@ -474,13 +535,23 @@ row never fails; it is there so a deliberately public URL is visible in a review
474
535
  instead of only in a boot log. Both rows are in `voltro doctor --json` under
475
536
  `webhookVerification`.
476
537
 
477
- ## The rpc body cap counts bytes as they arrive — and answers 413 either way
538
+ ## The body cap applies EVERYWHERE — and answers 413 either way
539
+
540
+ The 8-MiB body cap covers every surface that reads a request body: `POST /rpc`
541
+ (as it always did, `VOLTRO_MAX_RPC_BODY_BYTES`), plugin HTTP routes, REST
542
+ routes and incoming webhooks — whose read is also **binary-clean** now (it used
543
+ to round-trip through UTF-8, corrupting binary payloads). The non-rpc cap is
544
+ `http.maxBodyBytes` in `app.config.ts` (env `VOLTRO_MAX_BODY_BYTES`), with
545
+ per-route overrides on `defineRestRoute({ maxBodyBytes })` and on a webhook
546
+ handler's `maxBodyBytes` — fat provider payloads are the normal case there.
547
+ Routes **sharing a path share one body read**, so the widest override in the
548
+ group applies to the group.
478
549
 
479
- `POST /rpc` is capped at 8 MiB (`VOLTRO_MAX_RPC_BODY_BYTES`). The limit is
480
- enforced **while the body streams**, so a `Transfer-Encoding: chunked` request
481
- with no `Content-Length` is cut off at the cap rather than buffered without
482
- bound. A declared oversize length is still rejected up front, so an honest client
483
- gets its `413` without uploading anything.
550
+ The limit is enforced **while the body streams**, so a
551
+ `Transfer-Encoding: chunked` request with no `Content-Length` is cut off at the
552
+ cap rather than buffered without bound. A declared oversize length is still
553
+ rejected up front, so an honest client gets its `413` without uploading
554
+ anything.
484
555
 
485
556
  **Both shapes end in the same response:**
486
557
 
@@ -497,6 +568,52 @@ size — it is not read).
497
568
  Uploads ride separate plugin routes with their own `limits.maxBytes`, and are
498
569
  unaffected.
499
570
 
571
+ ## The global HTTP interceptor is fail-closed
572
+
573
+ The pre-auth plugin hook (`onHttpRequest`) wraps every request, which makes it
574
+ a place people put security gates — rate limits, geo-blocks, bot detection. A
575
+ gate that crashes must not become an open door, so **a throwing interceptor is
576
+ a `500` plus a log line; the request does not continue**. It used to: the
577
+ failure was swallowed and the request flowed on, silently, which is the worst
578
+ possible failure mode for exactly the code this hook attracts.
579
+
580
+ An interceptor that wants to tolerate its OWN outage — protection with a
581
+ dependency, like a rate-limit counter in Redis — catches internally and
582
+ degrades **loudly**. `@voltro/plugin-ratelimit`'s `httpShield` does exactly
583
+ that: a Redis outage means unlimited-with-a-warning, never a self-inflicted API
584
+ outage. The full authoring guidance is in the
585
+ [plugin contract](/docs/plugins/contract#onhttprequest-httprequestinterceptor--pre-auth-http-pipeline-hook).
586
+
587
+ ## Response compression — and where BREACH sits
588
+
589
+ Buffered responses are compression-negotiated on both boot paths: brotli
590
+ preferred, gzip as the fallback, a client's `q=0` respected. Only an allowlist
591
+ of compressible types is touched (`text/*`, `application/json`,
592
+ `application/javascript`, `application/xml`, `image/svg+xml`), nothing under
593
+ 1 KiB is compressed, and `Vary: Accept-Encoding` is set on every compressible
594
+ type — including responses that go out uncompressed, so caches never mix
595
+ encodings. It covers the api's plugin/REST/webhook responses AND the HTML
596
+ `voltro start` serves. Streams and SSE are never compressed. Config:
597
+
598
+ ```ts
599
+ // app.config.ts
600
+ export default {
601
+ http: { compression: { enabled: true, minBytes: 1024 } },
602
+ }
603
+ ```
604
+
605
+ **The BREACH position is structural, not a tuning knob:** `POST /rpc`
606
+ responses are NEVER compressed — that is where session-authenticated reads
607
+ flow, the exact combination (secret + attacker-reflected input + compression
608
+ + observable length) the attack needs. The residual risk is an
609
+ **authenticated REST route** that carries a secret next to reflected caller
610
+ input in one response; an app with such a route sets
611
+ `http.compression.enabled: false`.
612
+
613
+ One cache note: an `isr` page's cache holds ONE uncompressed entry and
614
+ compresses per hit (brotli q4, single-digit milliseconds) — caching a variant
615
+ per encoding would have multiplied the memory for no measurable win.
616
+
500
617
  ## Logs are redacted by default
501
618
 
502
619
  Every logger surface — `createLogger`, `makeLogger`, `LoggerLayer` — installs a redactor with no configuration, and it runs **before** formatting and **before** the sink fan-out, so a masked value reaches neither stdout nor any downstream sink (the CLI buffer, logship, Datadog).
@@ -570,7 +687,7 @@ Every hit line names the needles that matched, with the number of rows each appe
570
687
 
571
688
  The predicate is a substring match over the whole serialized column, so a match is a match on a *name*, wherever it sits. The third line above is a bounded second pass that reads the matched rows back and says what actually matched — a JSON **key**, or only a **redaction marker**. Values are never printed and never logged.
572
689
 
573
- A redaction marker is the framework's own record that a credential was deliberately *not* stored: `_omitted` (`@voltro/plugin-versioning`, the column names left out of a row snapshot) and `__redacted` (`@voltro/plugin-audit`). A target whose every matched row is one of those is reported as explained, and does **not** fail CI:
690
+ A redaction marker is the framework's own record that a credential was deliberately *not* stored: `_omitted` (`@voltro/plugin-row-history`, the column names left out of a row snapshot) and `__redacted` (`@voltro/plugin-audit`). A target whose every matched row is one of those is reported as explained, and does **not** fail CI:
574
691
 
575
692
  ```
576
693
  ~ _voltro_row_history.data — 69 of 149 row(s) contain a credential-shaped NAME (anywhere in the value)
@@ -2881,40 +2881,40 @@ Replace `keywordProvider([...])` with `aiProvider()` — the rules don't change.
2881
2881
 
2882
2882
  ---
2883
2883
 
2884
- <!-- source: en/templates/api-versioning.md -->
2885
- ## API · Versioning
2884
+ <!-- source: en/templates/api-row-history.md -->
2885
+ ## API · Row history
2886
2886
 
2887
- _Full row history + time-travel with @voltro/plugin-versioning — snapshots every insert/update/delete on the listed tables via the post-commit change tap; read every version with rowHistory() and the value at a past instant with rowAsOf(). audit() is who/when; this is what-changed-to-what. Memory store, zero infra._
2887
+ _Full row history + time-travel with @voltro/plugin-row-history — snapshots every insert/update/delete on your tables (every table by default — narrow with include/exclude) via the post-commit change tap; read every version with rowHistory() and the value at a past instant with rowAsOf(). audit() is who/when; this is what-changed-to-what. Memory store, zero infra._
2888
2888
 
2889
- Every version of a row, queryable. `@voltro/plugin-versioning` rides the post-commit ChangeEvent tap and records a FULL snapshot of every insert/update/delete on the listed tables. Read it back with `rowHistory(store, table, id)` (every version) and `rowAsOf(store, table, id, when)` (the value at a past instant — time-travel). `audit()` records WHO/WHEN; this records WHAT it changed to, so you can diff or restore. Memory store, zero infra. Template id: **`api-versioning`**.
2889
+ Every version of a row, queryable. `@voltro/plugin-row-history` rides the post-commit ChangeEvent tap and records a FULL snapshot of every insert/update/delete on your tables — every table by default, narrowed with `include:`/`exclude:`. Read it back with `rowHistory(store, table, id)` (every version) and `rowAsOf(store, table, id, when)` (the value at a past instant — time-travel). `audit()` records WHO/WHEN; this records WHAT it changed to, so you can diff or restore. Memory store, zero infra. Template id: **`api-row-history`**.
2890
2890
 
2891
2891
  ## Scaffold
2892
2892
 
2893
2893
  ```bash
2894
- voltro create-project acme --api=api-versioning
2894
+ voltro create-project acme --api=api-row-history
2895
2895
  ```
2896
2896
 
2897
2897
  ## Wire it + read it back
2898
2898
 
2899
2899
  ```ts
2900
2900
  // app.config.ts
2901
- import { versioningPlugin } from '@voltro/plugin-versioning'
2901
+ import { rowHistoryPlugin } from '@voltro/plugin-row-history'
2902
2902
  export default { type: 'api', name: 'AcmeVer', store: 'memory',
2903
- plugins: [versioningPlugin({})] }
2903
+ plugins: [rowHistoryPlugin({})] }
2904
2904
  ```
2905
2905
 
2906
2906
  The snapshotting is automatic. The point is to **read history through handlers** — the template ships two actions:
2907
2907
 
2908
2908
  ```ts
2909
2909
  // documents.history.action.server.ts
2910
- import { rowHistory } from '@voltro/plugin-versioning'
2910
+ import { rowHistory } from '@voltro/plugin-row-history'
2911
2911
  export default async ({ id }, ctx) => {
2912
2912
  const rows = await rowHistory(ctx.store, 'documents', id, ctx.request.subject.tenantId ?? null)
2913
2913
  return rows.map((r) => ({ version: r.version, op: r.op, content: r.data?.content ?? null, changedAt: r.changedAt }))
2914
2914
  }
2915
2915
 
2916
2916
  // documents.asOf.action.server.ts
2917
- import { rowAsOf } from '@voltro/plugin-versioning'
2917
+ import { rowAsOf } from '@voltro/plugin-row-history'
2918
2918
  export default async ({ id, at }, ctx) =>
2919
2919
  rowAsOf(ctx.store, 'documents', id, ctx.request.subject.tenantId ?? null, at) // the row as it was at `at` (epoch ms), or null
2920
2920
  ```
@@ -2933,14 +2933,14 @@ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json
2933
2933
  # documents.asOf with v1's changedAt → { content:"draft v1" } (time-travel)
2934
2934
  ```
2935
2935
 
2936
- ## audit vs versioning
2936
+ ## audit vs row-history
2937
2937
 
2938
2938
  | You want… | Use |
2939
2939
  |---|---|
2940
2940
  | WHO changed a row + WHEN | `audit()` mixin |
2941
- | WHAT it changed to (every prior value) | `@voltro/plugin-versioning` (this) |
2941
+ | WHAT it changed to (every prior value) | `@voltro/plugin-row-history` (this) |
2942
2942
 
2943
- They compose — `audit()` for accountability, versioning for diff/restore.
2943
+ They compose — `audit()` for accountability, row-history for diff/restore.
2944
2944
 
2945
2945
  ## Production
2946
2946
 
@@ -2948,8 +2948,8 @@ The memory history store is single-process. With a SQL store, history persists i
2948
2948
 
2949
2949
  ## Anti-patterns
2950
2950
 
2951
- - **Listing `versioningPlugin()` and stopping there.** That records history but never shows it. The value is in the READS — wire `rowHistory` / `rowAsOf` into a handler (or the dashboard) like this template does.
2952
- - **Versioning every table.** Snapshots cost storage proportional to write volume. Pick the tables whose past values you actually need.
2951
+ - **Listing `rowHistoryPlugin()` and stopping there.** That records history but never shows it. The value is in the READS — wire `rowHistory` / `rowAsOf` into a handler (or the dashboard) like this template does.
2952
+ - **Keeping history for every table.** Snapshots cost storage proportional to write volume. Pick the tables whose past values you actually need.
2953
2953
 
2954
2954
 
2955
2955
 
@@ -54,7 +54,7 @@ The `api-backend*` shapes share the same minimal `notes` domain and differ only
54
54
  | [`api-ratelimit`](/docs/templates/api-ratelimit) | **Per-endpoint rate limiting** — `@voltro/plugin-ratelimit` intercepts rpc: a default + rules (sliding-window / token-bucket, by subject/tenant/…), typed `RateLimited` auto-merged into the error union. Memory store. |
55
55
  | [`api-rbac`](/docs/templates/api-rbac) | **Role → scope access control** — `@voltro/plugin-rbac`: a role map, an interceptor resolving roles to scopes, and an in-handler `permission(ctx, 'notes:write')` guard (typed `ScopeError`). `admin:full` bypasses. Config-only. |
56
56
  | [`api-moderation`](/docs/templates/api-moderation) | **Pre-commit content moderation** — `@voltro/plugin-moderation` checks fields before the handler: a `block` rule rejects with typed `ContentRejected`, a `flag` rule queues for review. keywordProvider / aiProvider. |
57
- | [`api-versioning`](/docs/templates/api-versioning) | **Row history + time-travel** — `@voltro/plugin-versioning` snapshots every write via the change tap; `rowHistory()` (every version) + `rowAsOf()` (value at a past instant), read through handlers. Memory store. |
57
+ | [`api-row-history`](/docs/templates/api-row-history) | **Row history + time-travel** — `@voltro/plugin-row-history` snapshots every write via the change tap; `rowHistory()` (every version) + `rowAsOf()` (value at a past instant), read through handlers. Memory store. |
58
58
  | [`api-backend-deactivation`](/docs/templates/api-backend-deactivation) | **The `deactivation()` mixin** — lock a user out (`deactivatedAt`) while the row stays VISIBLE/queryable — the deliberate opposite of `softDelete()`'s hide+anonymise. Pure schema. |
59
59
  | [`api-governance`](/docs/templates/api-governance) | **Field encryption + GDPR + consent + retention** — `@voltro/plugin-governance`: AES-256-GCM `.encrypted()` columns (plaintext to handlers, ciphertext at rest), admin-gated `governance.export`/`erase`, a consent ledger, retention sweeps. |
60
60
  | [`api-collab`](/docs/templates/api-collab) | **Local-first / CRDT collaborative editing** — a `documents` table with a `crdtText()` `body`; concurrent edits from many clients CONVERGE via an authoritative server-side merge on the write path (no last-write-wins loser), then broadcast over the reactive engine. Zero-infra; pairs with `frontend-collab`. |