@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.
- package/CHANGELOG.md +231 -0
- package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
- package/dist/agentsMd-SDDSkyl4.js +2 -0
- package/dist/apiBuild-BYBpL7Pz.js +2 -0
- package/dist/{apiBuild-D3s7lx57.js → apiBuild-CSFI8QGq.js} +3 -3
- package/dist/appGraph-CFmFKHb2.js +2 -0
- package/dist/appGraph-yRuKjOba.js +111 -0
- package/dist/bin.js +1 -1
- package/dist/build-CPgcMQug.js +793 -0
- package/dist/checkCommand-2SbqzukH.js +2 -0
- package/dist/{checkCommand-DsKIjL6k.js → checkCommand-COmqc2cB.js} +107 -60
- package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
- package/dist/codegen-CctkDO-1.js +2 -0
- package/dist/{codegen-CrMXs4hb.js → codegen-VF479Cnb.js} +1 -1
- package/dist/{codegenCommand-C0S27nbB.js → codegenCommand-DCdG2JN-.js} +12 -12
- package/dist/{codemodRunner-DwY8Rjbl.js → codemodRunner-r7J9lIa7.js} +771 -636
- package/dist/{commands-Brp4DI8u.js → commands-Cc_nV8WI.js} +37 -36
- package/dist/{dashboardCommand-bbLfc9FW.js → dashboardCommand-C-gKvwqh.js} +3 -3
- package/dist/{dataCommand-k9mXTXin.js → dataCommand-BgpBHnlB.js} +299 -253
- package/dist/dbCommand-DNb6yeOG.js +2 -0
- package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-sHedr-NJ.js} +3 -3
- package/dist/dev--A3nsxA3.js +3 -0
- package/dist/{dev-C0cbqysu.js → dev-CRHoCEiy.js} +2258 -2219
- package/dist/doctorCommand-CqoWA2p5.js +2 -0
- package/dist/{doctorCommand-DyViaaCP.js → doctorCommand-DtfJ3FA6.js} +314 -234
- package/dist/{dormancyCommand-DMZPA_HW.js → dormancyCommand-Drn7o0No.js} +1 -1
- package/dist/{embeddingsCommand-CMxRPkg6.js → embeddingsCommand-Z-jO1fWN.js} +1 -1
- package/dist/{envCommand-CDLr-yD4.js → envCommand-D4gCrrTZ.js} +15 -15
- package/dist/{evolveCommand-Ct4NesL3.js → evolveCommand-CMROeKeA.js} +2 -2
- package/dist/fileConventions-DOqD3lPS.js +34 -0
- package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-DvDUV9wq.js} +1 -1
- package/dist/frameworkTableAssembly-C6ETawPR.js +2 -0
- package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-w-XnLa3q.js} +1 -1
- package/dist/{generateCommand-BDdOxhnv.js → generateCommand-DTNPGjxo.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/{infoCommand-orsKJKUy.js → infoCommand-DXM868o_.js} +1 -1
- package/dist/inspectMetrics-CGF94puw.js +143 -0
- package/dist/{metaCommands-CfRLra0s.js → metaCommands-C6RFmF1r.js} +2 -2
- package/dist/{migrate-D3MK9BpK.js → migrate-D0F-eTlK.js} +2 -2
- package/dist/{pageConvention-cEiRxdab.js → pageConvention-CzUiSbtU.js} +1 -1
- package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DGdopOI6.js} +1 -1
- package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
- package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
- package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CcH2X1_D.js} +25 -10
- package/dist/{renderProfile-1OWWAAtx.js → renderProfile-Ck32Fzxr.js} +2 -2
- package/dist/{runtimeTrace-Bo8jWsNe.js → runtimeTrace-BPQyCmC5.js} +1 -1
- package/dist/{sdkgen-OZhiT2vz.js → sdkgen-Se88ifTd.js} +1 -1
- package/dist/serveCommand-DLc-BznW.js +2 -0
- package/dist/{serveCommand-YbD5VgfV.js → serveCommand-DkP3OT0W.js} +885 -868
- package/dist/serveEntry.js +1 -1
- package/dist/start-DfL3fOiN.js +3 -0
- package/dist/start-jw89Xbqy.js +1339 -0
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-BwNEDlSU.js} +1 -1
- package/dist/{test-rXFq4S76.js → test-f3amja6a.js} +1 -1
- package/dist/updateCommand-5gFVfK5q.js +2 -0
- package/dist/{updateCommand-BgH6vTtq.js → updateCommand-BMk2e4ky.js} +1 -1
- package/dist/{webDev-BbdkzMbd.js → webDev-BgWL9gKV.js} +1233 -853
- package/dist/webDev-CZbTsDcH.js +2 -0
- package/dist/{webhooksCommand-DjE6u0r4.js → webhooksCommand-CoIO3jbj.js} +1 -1
- package/package.json +48 -17
- package/templates/AGENTS.core.md +26 -5
- package/templates/AGENTS.md +30 -9
- package/templates/agent-docs/_index.md +4 -4
- package/templates/agent-docs/_manifest.json +11 -11
- package/templates/agent-docs/cli.md +125 -14
- package/templates/agent-docs/data.md +210 -7
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +1 -1
- package/templates/agent-docs/deployment.md +22 -3
- package/templates/agent-docs/internationalization.md +131 -13
- package/templates/agent-docs/introduction.md +46 -0
- package/templates/agent-docs/local-first-mobile.md +34 -7
- package/templates/agent-docs/plugins/ai-flows.md +1 -1
- package/templates/agent-docs/plugins/audit.md +5 -5
- package/templates/agent-docs/plugins/cdc-out.md +2 -2
- package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
- package/templates/agent-docs/plugins/storage.md +2 -2
- package/templates/agent-docs/plugins.md +29 -7
- package/templates/agent-docs/reference.md +39 -2
- package/templates/agent-docs/routing.md +341 -47
- package/templates/agent-docs/schema-driven-ui.md +78 -2
- package/templates/agent-docs/security.md +125 -8
- package/templates/agent-docs/templates/apibackends.md +14 -14
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +100 -29
- package/templates/apps/api-ai/package.json +6 -7
- package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
- package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
- package/templates/apps/api-row-history/template.json +6 -0
- package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
- package/templates/apps/api-saas/app.config.ts +1 -0
- package/templates/apps/api-saas/package.json +10 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +10 -10
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
- package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +6 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +6 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/agentsMd-Bu_XQgVf.js +0 -2
- package/dist/apiBuild-BZ-plaQN.js +0 -2
- package/dist/appGraph-CM9wvUmD.js +0 -2
- package/dist/appGraph-Cv2W_wO5.js +0 -111
- package/dist/build-CFmqF39c.js +0 -721
- package/dist/checkCommand-DomvYDi1.js +0 -2
- package/dist/codegen-DiMn2KkZ.js +0 -2
- package/dist/dbCommand-Bnl7MSGC.js +0 -2
- package/dist/dev-Dz9GqA6P.js +0 -3
- package/dist/doctorCommand-BlciWUJb.js +0 -2
- package/dist/fileConventions-Cof68_BL.js +0 -33
- package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
- package/dist/inspectMetrics-CfdKLh6t.js +0 -72
- package/dist/serveCommand-Bbgg7Paq.js +0 -2
- package/dist/start-7b_bL8Uc.js +0 -3
- package/dist/start-B1nH9ahf.js +0 -1150
- package/dist/updateCommand-BweY17z-.js +0 -2
- package/dist/webDev-BcRFTsMA.js +0 -2
- package/templates/apps/api-versioning/template.json +0 -6
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
- /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 }`
|
|
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
|
|
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
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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-
|
|
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-
|
|
2885
|
-
## API ·
|
|
2884
|
+
<!-- source: en/templates/api-row-history.md -->
|
|
2885
|
+
## API · Row history
|
|
2886
2886
|
|
|
2887
|
-
_Full row history + time-travel with @voltro/plugin-
|
|
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-
|
|
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-
|
|
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 {
|
|
2901
|
+
import { rowHistoryPlugin } from '@voltro/plugin-row-history'
|
|
2902
2902
|
export default { type: 'api', name: 'AcmeVer', store: 'memory',
|
|
2903
|
-
plugins: [
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
2941
|
+
| WHAT it changed to (every prior value) | `@voltro/plugin-row-history` (this) |
|
|
2942
2942
|
|
|
2943
|
-
They compose — `audit()` for accountability,
|
|
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 `
|
|
2952
|
-
- **
|
|
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-
|
|
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`. |
|