@voltro/cli 0.40.0 → 0.41.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 (100) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/{apiBuild-C8xBEYbL.js → apiBuild-B_iH24qf.js} +2 -2
  3. package/dist/apiBuild-RM5W5YXk.js +2 -0
  4. package/dist/bin.js +1 -1
  5. package/dist/{build-CkhFGnKA.js → build-DUdi6F_Q.js} +150 -150
  6. package/dist/{checkCommand-QbHshOac.js → checkCommand-B2RWrpWi.js} +1 -1
  7. package/dist/{checkCommand-BcXJa2IS.js → checkCommand-DWL2_PPR.js} +2 -2
  8. package/dist/{codegenCommand-DF2OJh3O.js → codegenCommand-DkjoJ30Q.js} +2 -2
  9. package/dist/{codemodRunner-BcOvi4A2.js → codemodRunner-sFuWDETH.js} +520 -460
  10. package/dist/{commands-7Z7-HOE3.js → commands-D0VR1yBB.js} +22 -22
  11. package/dist/{dashboardCommand-CUycH6dR.js → dashboardCommand-Bro7Jwts.js} +5 -5
  12. package/dist/{dataCommand-By78lscK.js → dataCommand-OKC5FscP.js} +183 -172
  13. package/dist/{dbCommand-DSwGv9wS.js → dbCommand-BHRamDsy.js} +441 -414
  14. package/dist/dbCommand-C9iE1pd9.js +2 -0
  15. package/dist/{dev-Dh1NuYx1.js → dev-D4l_YOzI.js} +22 -22
  16. package/dist/{dev-D8JSKlBM.js → dev-DuqBJC7g.js} +1 -1
  17. package/dist/doctorCommand-Bw3V4L1j.js +2 -0
  18. package/dist/{doctorCommand-C-8-Z9D6.js → doctorCommand-TmZTAepc.js} +385 -279
  19. package/dist/{dormancyCommand-A79Odglm.js → dormancyCommand-CUKhRmGG.js} +1 -1
  20. package/dist/{embeddingsCommand-CoiyHfkY.js → embeddingsCommand-Db-iQM3l.js} +1 -1
  21. package/dist/{envCommand-B6d0s-jZ.js → envCommand-DQ_URZq9.js} +12 -12
  22. package/dist/{evolveCommand-BC5a9lxX.js → evolveCommand-CghRPrGO.js} +2 -2
  23. package/dist/index.js +1 -1
  24. package/dist/{infoCommand-ZqSVzJZ4.js → infoCommand-BCs_ZFnl.js} +1 -1
  25. package/dist/{migrate-BnPw2zC8.js → migrate-DLtPv2_D.js} +1 -1
  26. package/dist/{runtimeTrace-BslKJjDY.js → runtimeTrace-jFKvOPBz.js} +1 -1
  27. package/dist/{sdkgen-DQ8uEmmn.js → sdkgen-COQqHB9V.js} +1 -1
  28. package/dist/{serveCommand-Dqb9Ku1-.js → serveCommand-Cba_M2Su.js} +3 -3
  29. package/dist/serveCommand-DRbYK646.js +2 -0
  30. package/dist/serveEntry.js +1 -1
  31. package/dist/{start-dkNYKtgQ.js → start-BjcuFME_.js} +379 -343
  32. package/dist/{start-BA7txHhY.js → start-fifc0xpW.js} +1 -1
  33. package/dist/startEntry.js +1 -1
  34. package/dist/updateCommand-B2alql9k.js +2 -0
  35. package/dist/{updateCommand-eoGpZr3E.js → updateCommand-HZQVBU3T.js} +1 -1
  36. package/dist/webDev-D6QeXw3P.js +2 -0
  37. package/dist/{webDev-bybjZA0o.js → webDev-Dk8lFUzf.js} +642 -525
  38. package/dist/{webhooksCommand-DubZWNOx.js → webhooksCommand-D7ZpH7w0.js} +1 -1
  39. package/package.json +23 -17
  40. package/templates/AGENTS.md +1 -1
  41. package/templates/agent-docs/_index.md +1 -1
  42. package/templates/agent-docs/_manifest.json +1 -1
  43. package/templates/agent-docs/cli.md +6 -0
  44. package/templates/agent-docs/plugins/governance.md +8 -0
  45. package/templates/agent-docs/routing.md +141 -28
  46. package/templates/agent-docs/security.md +24 -0
  47. package/templates/agent-docs/whats-new.md +37 -121
  48. package/templates/apps/api-ai/package.json +7 -7
  49. package/templates/apps/api-auth/package.json +8 -8
  50. package/templates/apps/api-backend/package.json +7 -7
  51. package/templates/apps/api-backend-deactivation/package.json +7 -7
  52. package/templates/apps/api-backend-mail/package.json +8 -8
  53. package/templates/apps/api-backend-mariadb/package.json +9 -9
  54. package/templates/apps/api-backend-sqlite/package.json +8 -8
  55. package/templates/apps/api-backend-storage/package.json +8 -8
  56. package/templates/apps/api-cms/package.json +10 -10
  57. package/templates/apps/api-collab/package.json +8 -8
  58. package/templates/apps/api-data-advanced/package.json +8 -8
  59. package/templates/apps/api-durable/package.json +8 -8
  60. package/templates/apps/api-feature-flags/package.json +9 -9
  61. package/templates/apps/api-governance/package.json +8 -8
  62. package/templates/apps/api-kv/package.json +8 -8
  63. package/templates/apps/api-moderation/package.json +8 -8
  64. package/templates/apps/api-observability/package.json +8 -8
  65. package/templates/apps/api-ratelimit/package.json +8 -8
  66. package/templates/apps/api-rbac/package.json +8 -8
  67. package/templates/apps/api-rest/package.json +7 -7
  68. package/templates/apps/api-saas/package.json +11 -11
  69. package/templates/apps/api-saas-starter/package.json +10 -10
  70. package/templates/apps/api-search/package.json +8 -8
  71. package/templates/apps/api-status/package.json +8 -8
  72. package/templates/apps/api-versioning/package.json +8 -8
  73. package/templates/apps/api-webhooks/package.json +9 -9
  74. package/templates/apps/changelog/package.json +6 -6
  75. package/templates/apps/edge-functions/package.json +2 -2
  76. package/templates/apps/frontend-admin/package.json +8 -8
  77. package/templates/apps/frontend-app/package.json +9 -9
  78. package/templates/apps/frontend-auth/package.json +8 -8
  79. package/templates/apps/frontend-blank/package.json +7 -7
  80. package/templates/apps/frontend-cms/package.json +9 -9
  81. package/templates/apps/frontend-collab/package.json +10 -10
  82. package/templates/apps/frontend-contact/package.json +7 -7
  83. package/templates/apps/frontend-dashboard/package.json +7 -7
  84. package/templates/apps/frontend-docs/package.json +7 -7
  85. package/templates/apps/frontend-i18n/package.json +6 -6
  86. package/templates/apps/frontend-landing/package.json +7 -7
  87. package/templates/apps/frontend-portal/package.json +8 -8
  88. package/templates/apps/frontend-saas/package.json +8 -8
  89. package/templates/apps/frontend-spa/package.json +7 -7
  90. package/templates/apps/frontend-ssr/package.json +7 -7
  91. package/templates/apps/frontend-ssr-api/package.json +8 -8
  92. package/templates/apps/frontend-static-blog/package.json +6 -6
  93. package/templates/apps/frontend-status/package.json +8 -8
  94. package/templates/apps/mobile-app/package.json +4 -4
  95. package/dist/apiBuild-BIyGkym8.js +0 -2
  96. package/dist/dbCommand-DcqEyxju.js +0 -2
  97. package/dist/doctorCommand-FhQ7EVYL.js +0 -2
  98. package/dist/serveCommand-BmvEQCMS.js +0 -2
  99. package/dist/updateCommand-BauSVxPp.js +0 -2
  100. package/dist/webDev-CBbKs4dZ.js +0 -2
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
222
222
  ...t === void 0 ? {} : { payload: t }
223
223
  };
224
224
  }, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
225
- let { walk: t, loadDiscovered: n } = await import("./dev-D8JSKlBM.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-DuqBJC7g.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
226
  return r((await n(await t(e))).events.map((e) => ({
227
227
  file: e.file,
228
228
  descriptor: e.descriptor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.40.0",
3
+ "version": "0.41.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",
@@ -622,6 +622,12 @@
622
622
  "title": "a rejected credential answers `Unauthenticated`, widening three guard results",
623
623
  "kind": "manual"
624
624
  },
625
+ {
626
+ "version": "0.41.0",
627
+ "id": "0.41.0/01_middleware-defines-where-it-runs",
628
+ "title": "`middleware.ts` exports `defineMiddleware(...)` and can say where it runs",
629
+ "kind": "transform"
630
+ },
625
631
  {
626
632
  "version": "0.5.0",
627
633
  "id": "0.5.0/01_one-terminal-exactly-one",
@@ -709,22 +715,22 @@
709
715
  "@effect/platform-node": "^0.108.0",
710
716
  "@effect/sql": "^0.52.0",
711
717
  "@effect/workflow": "^0.19.0",
712
- "@voltro/ai": "0.40.0",
713
- "@voltro/cache": "0.40.0",
714
- "@voltro/data-transfer": "0.40.0",
715
- "@voltro/database": "0.40.0",
716
- "@voltro/env": "0.40.0",
717
- "@voltro/kv": "0.40.0",
718
- "@voltro/logger": "0.40.0",
719
- "@voltro/plugin-auth": "0.40.0",
720
- "@voltro/plugin-broadcast": "0.40.0",
721
- "@voltro/plugin-mail": "0.40.0",
722
- "@voltro/plugin-storage": "0.40.0",
723
- "@voltro/plugin-webhooks": "0.40.0",
724
- "@voltro/protocol": "0.40.0",
725
- "@voltro/runtime": "0.40.0",
726
- "@voltro/serverless": "0.40.0",
727
- "@voltro/workflow": "0.40.0",
718
+ "@voltro/ai": "0.41.0",
719
+ "@voltro/cache": "0.41.0",
720
+ "@voltro/data-transfer": "0.41.0",
721
+ "@voltro/database": "0.41.0",
722
+ "@voltro/env": "0.41.0",
723
+ "@voltro/kv": "0.41.0",
724
+ "@voltro/logger": "0.41.0",
725
+ "@voltro/plugin-auth": "0.41.0",
726
+ "@voltro/plugin-broadcast": "0.41.0",
727
+ "@voltro/plugin-mail": "0.41.0",
728
+ "@voltro/plugin-storage": "0.41.0",
729
+ "@voltro/plugin-webhooks": "0.41.0",
730
+ "@voltro/protocol": "0.41.0",
731
+ "@voltro/runtime": "0.41.0",
732
+ "@voltro/serverless": "0.41.0",
733
+ "@voltro/workflow": "0.41.0",
728
734
  "chokidar": "^5.0.0",
729
735
  "ioredis": "^5.11.1",
730
736
  "tinyglobby": "^0.2.17",
@@ -707,7 +707,7 @@ each plugin's own README.
707
707
 
708
708
  | Topic | Open | Summary |
709
709
  |---|---|---|
710
- | **What's new in 0.40.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. |
710
+ | **What's new in 0.41.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. |
711
711
  | 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. |
712
712
  | 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. |
713
713
  | 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.40.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. |
12
+ | **What's new in 0.41.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. |
@@ -242,7 +242,7 @@
242
242
  "group": null,
243
243
  "description": "Voltro's file-based router — pages, layouts, render modes, loaders, navigation, islands. The web side of the framework.",
244
244
  "path": "agent-docs/routing.md",
245
- "files": 8
245
+ "files": 9
246
246
  },
247
247
  {
248
248
  "id": "scheduling",
@@ -2525,6 +2525,7 @@ Both stream — a table or a blob is never fully held in memory — and both sur
2525
2525
  voltro data export ./backup-2026-07-01
2526
2526
  voltro data export ./out --tenant org_abc # one tenant + its FK closure
2527
2527
  voltro data export ./out --tables users,posts # an explicit set
2528
+ voltro data export ./out --exclude cluster_locks # everything else
2528
2529
  voltro data export ./out --assets # include stored blobs
2529
2530
  voltro data export ./out --compression gzip # zstd (default) | gzip | none
2530
2531
  voltro data export ./out --consistency snapshot # point-in-time (see below)
@@ -2596,6 +2597,11 @@ Rows are written as newline-delimited JSON — streamable, resumable by line, an
2596
2597
  | *(none)* | Every table (the default). |
2597
2598
  | `--tenant <id>` | Every `tenant()`-scoped table filtered to that tenant, **plus** the FK closure in BOTH directions: (1) the transitive **FK-parent** closure of those rows — closure-pulled shared tables (a global `users` / reference table) are **row-subset to the ids the tenant's rows actually reference**, never exported in full; and (2) the **child** closure — rows that *reference* the tenant's rows (the comments on the tenant's projects) come along too, each scoped to the ids that actually point into the tenant set. The child walk is anchored on the `tenant()` tables, so a row that references **only a shared parent** (a global `users` another tenant also references) is **not** pulled — that would be a cross-tenant leak. A `--tenant` bundle therefore carries the tenant's parents AND children and **no other tenant's rows** — that's what makes it safe as a GDPR / per-tenant takeout. |
2598
2599
  | `--tables a,b` | An explicit set (you own referential integrity; the importer's deferred-FK resolution covers load-order dangles, see below). |
2600
+ | `--exclude a,b` | Everything EXCEPT these, expanded against the live table list. Direct target only. Cannot be combined with `--tables` or `--tenant`; a name that does not exist is refused, because an exclusion that excludes nothing leaves the run looking like it worked. |
2601
+
2602
+ **Every table needs a single-column primary key.** The export is keyset-paginated, so it orders by one column and resumes from the last value on the next page. That column comes from the real primary key — a declared `id()` where there is one, otherwise the table's actual PK, whatever it is called.
2603
+
2604
+ A table with a **composite** primary key, or none at all, is **refused by name** rather than exported. Ordering by one column of a composite key splits equal values across page boundaries, which drops or duplicates rows into a bundle that reports success — and a short backup is discovered at the restore. Leave such a table out with `--exclude`.
2599
2605
 
2600
2606
  Tenant-scope details:
2601
2607
 
@@ -232,6 +232,14 @@ const token = decryptField(row.token) // a non-ciphertext value returns unchan
232
232
 
233
233
  `decryptField` returns a non-`enc:v1:…` value **unchanged**, so you can switch a raw-SQL path to encryption while pre-existing plaintext rows keep working until they're re-written. Both throw a clear error if no cipher is registered (enable `fieldEncryption`). Encryption stops being all-or-nothing tied to going through `ctx.store`.
234
234
 
235
+ **One encoding, both directions.** These helpers and the store middleware write the same thing, so a value written by `encryptField` reads through `ctx.store` and vice versa. That was not always true: the store encoded JSON and the helpers did not, both under the same `enc:v1:` envelope, and a value written by one and read by the other either threw blaming the KEY — while the key was fine — or came back with the JSON quotes still on it and raised nothing.
236
+
237
+ **Rows written under the OLD encoding still read — nothing has to be rewritten.** Both forms are resolved on read, deterministically: after decrypting, a value that does not parse as JSON is the raw form, one that parses to a string is the JSON form, and one that parses to a non-string is decided by the column's declared type (a text column cannot hold a number, so `12345` is a raw string that parsed by accident). This matters because the old form is already on staging and production disks, and a fix that needs the data rewritten before the app works is an outage with a migration attached.
238
+
239
+ Normalising is optional hygiene, and `voltro db encrypt-column` does it: it re-encodes rows in the old form as it goes and reports them separately from the ones it encrypts. It skips anything ambiguous and anything it cannot decrypt.
240
+
241
+ **Backups are unaffected either way.** `voltro data export` reads through the raw store, so an encrypted column travels as ciphertext in whichever encoding it holds, and comes back unchanged.
242
+
235
243
  ## Dashboard panel
236
244
 
237
245
  Both dashboards ship a **Governance** panel (api apps): retention-policy status + field-encryption state + last-sweep results (with a **Run sweep now** button), a **GDPR runner** (enter a subject id → Export / Erase delete / Erase anonymize), a consent-ledger lookup, and the erasure log. Write-actions gate on the `canRunGovernance` capability. Backed by `/_voltro/inspect/plugins/governance/{status,erasures,consent,export,erase,sweep}`.
@@ -1160,35 +1160,12 @@ and `preload` on the page fails.
1160
1160
  You cannot fix that in a loader. `ctx.query` and every `preload` entry are bound
1161
1161
  from ONE cookie string **before any loader runs**, so a layout loader that
1162
1162
  renews the session cannot reach them. `middleware.ts` at the web app root runs
1163
- earlier than both:
1163
+ earlier than both, says which routes it covers, and writes the rotated cookie
1164
+ back so this render and the browser agree.
1164
1165
 
1165
- ```ts
1166
- // middleware.ts server-only. NOT app.config.ts, which is imported into the
1167
- // client bundle whenever an api declares `authHeaders`.
1168
- export default async (req) => {
1169
- const fresh = await refreshSession(req.cookies['sb-session'])
1170
- if (!fresh) return
1171
- return {
1172
- headers: { authorization: `Bearer ${fresh.accessToken}` },
1173
- setCookies: [{ name: 'sb-session', value: fresh.cookie, maxAge: 3600 }],
1174
- }
1175
- }
1176
- ```
1177
-
1178
- **Write the cookie back.** An IdP that rotates refresh tokens (Supabase does, and
1179
- detects reuse) will invalidate the session if you renew server-side and leave the
1180
- browser holding the consumed one. `setCookies` is not an optimisation.
1181
-
1182
- **It cannot redirect or refuse a request**, deliberately: authorization belongs
1183
- on the api, which is the only thing that sees the data, and a web-side hook that
1184
- could refuse would be a second authorization layer beside the real one. For a
1185
- login redirect, throw `RedirectError` from the loader.
1186
-
1187
- Headers you return are merged over the request's, and only auth-shaped ones
1188
- (`authorization`, `x-tenant`, `x-voltro-*`) are forwarded to the api. Cookies
1189
- default to `HttpOnly`, `Path=/`, `SameSite=lax`. The file is loaded once per
1190
- boot; if it throws, the request fails rather than rendering with the credential
1191
- you asked to replace.
1166
+ See [Middleware](/docs/routing/middleware) for the full contract: `match`
1167
+ (`under` / `routes` / `except` / `assets`), the one-middleware-per-route rule,
1168
+ and what it deliberately cannot do.
1192
1169
 
1193
1170
  ## Errors from loaders
1194
1171
 
@@ -2002,3 +1979,139 @@ matches (workspace link and npm install alike).
2002
1979
  How to verify: `curl -sS http://localhost:<port>/@fs/<abs>/src/globals.css | grep
2003
1980
  animate-` should list every kit animation utility you use. If a class is missing,
2004
1981
  the `@source` is misconfigured.
1982
+
1983
+
1984
+
1985
+ ---
1986
+
1987
+ <!-- source: en/routing/middleware.md -->
1988
+ ## Middleware
1989
+
1990
+ _'`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, and say which routes it runs on.'_
1991
+
1992
+ `middleware.ts` at the web app root runs **before** a server render binds its data. It exists for one job — renewing a credential — and it is deliberately narrow about everything else.
1993
+
1994
+ ```ts
1995
+ // middleware.ts — server-only. NOT app.config.ts, which is imported into the
1996
+ // client bundle whenever an api declares `authHeaders`.
1997
+ import { defineMiddleware } from '@voltro/web/middleware'
1998
+
1999
+ export const session = defineMiddleware({
2000
+ match: { under: '/app' },
2001
+ run: async (req) => {
2002
+ const fresh = await refreshSession(req.cookies['sb-session'])
2003
+ if (!fresh) return
2004
+ return {
2005
+ headers: { authorization: `Bearer ${fresh.accessToken}` },
2006
+ setCookies: [{ name: 'sb-session', value: fresh.cookie, maxAge: 3600 }],
2007
+ }
2008
+ },
2009
+ })
2010
+ ```
2011
+
2012
+ ## The problem it solves
2013
+
2014
+ A cookie older than the IdP's token lifetime — practically every first page view of the day for a 1-hour token — makes the api resolve the caller to anonymous, and every loader and `preload` on the page fails.
2015
+
2016
+ **You cannot fix that in a loader.** `ctx.query` and every `preload` entry are bound from ONE cookie string *before any loader runs*, so a layout loader that renews the session cannot reach them. The middleware runs earlier than both.
2017
+
2018
+ ## What it receives, and what it can return
2019
+
2020
+ `run` gets a read-only request and returns `{ headers?, setCookies? }` — or nothing, to change nothing.
2021
+
2022
+ | Field | |
2023
+ |---|---|
2024
+ | `req.pathname` | matched path, no query string |
2025
+ | `req.search` | raw query string including `?`, or `''` |
2026
+ | `req.headers` | incoming headers, lowercased keys |
2027
+ | `req.cookies` | the parsed `Cookie` header |
2028
+ | `req.route` | the matched **route pattern** (`/notes/[id]`), or `undefined` for a non-page request |
2029
+
2030
+ Returned headers are merged over the request's, and only auth-shaped names (`authorization`, `x-tenant`, `x-voltro-*`) are forwarded to the api — a returned `host` would otherwise produce failures that look like anything but a header copy. Cookies default to `HttpOnly`, `Path=/`, `SameSite=lax`, and several are written as separate header lines, never comma-joined (a cookie's `Expires` contains a comma).
2031
+
2032
+ **Write the cookie back.** An IdP that rotates refresh tokens (Supabase does, and detects reuse) will invalidate the session if you renew server-side and leave the browser holding the consumed one. `setCookies` is not an optimisation.
2033
+
2034
+ **A cookie your IdP SDK reads in the browser needs `httpOnly: false`.** The default is `HttpOnly` — right for a cookie only the server touches, and wrong for this one. Supabase's `createBrowserClient` reads the session from `document.cookie`, so a forgotten `false` hands the browser a session it cannot see: the SSR render is perfect, every server-side check passes, and the user is signed out at the first client-side call. `voltro dev` warns once per cookie when a session-shaped name is written with no `httpOnly` decision; setting it explicitly either way silences that.
2035
+
2036
+ ## What you return applies to THIS render
2037
+
2038
+ The middleware produces one view of the request that everything downstream reads:
2039
+
2040
+ | Reader | sees |
2041
+ |---|---|
2042
+ | `ctx.query` and every `preload` | your headers, and the renewed cookie |
2043
+ | `ctx.headers` in a loader | your headers |
2044
+ | `useServerRequest().cookies` / `.headers` | the jar after `setCookies` was applied |
2045
+ | locale resolution (`cookie`, `accept-language`) | the same jar |
2046
+
2047
+ So a hook that renews **only** via `setCookies` — no `headers` at all, which is the normal shape for a cookie-session IdP — still authenticates this render's rpc calls: the `Cookie` header is rebuilt from the updated jar. A `maxAge` of `0` deletes, so a hook that signs someone out renders them signed out. If you return an explicit `cookie` header yourself, yours wins.
2048
+
2049
+ ## `match` — where it runs
2050
+
2051
+ Without a `match`, a middleware runs on every server-rendered route, including your marketing pages. That is an IdP round trip on the page least able to afford one.
2052
+
2053
+ ```ts
2054
+ import { defineMiddleware } from '@voltro/web/middleware'
2055
+
2056
+ export const session = defineMiddleware({
2057
+ match: { under: '/app', except: ['/app/public'] },
2058
+ run: async (req) => { /* … */ },
2059
+ })
2060
+
2061
+ export const adminTenant = defineMiddleware({
2062
+ match: { under: '/admin' },
2063
+ run: async () => ({ headers: { 'x-tenant': 'ops' } }),
2064
+ })
2065
+ ```
2066
+
2067
+ | Field | Means |
2068
+ |---|---|
2069
+ | `under` | a route subtree — `/app` covers `/app` and everything below it, on segment boundaries (never `/application`). A string or an array. |
2070
+ | `routes` | exact route patterns, as the router spells them: `/notes/[id]`. |
2071
+ | `except` | subtrees or patterns to subtract from the two above. |
2072
+ | `assets` | also run on requests that matched no page. Off by default. |
2073
+
2074
+ **Every path here is a ROUTE path, checked against your routes at boot.** A `under: '/ap'` that covers nothing refuses the boot; it does not become a middleware that quietly never fires. An `except` that excludes nothing is reported the same way — it reads as an active rule and is not.
2075
+
2076
+ That is the deliberate difference from the URL-pattern shape you may have met elsewhere:
2077
+
2078
+ ```ts
2079
+ matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)'
2080
+ ```
2081
+
2082
+ You have to know your own asset layout to write that, it breaks when a build tool renames a directory, and it breaks silently. Voltro's hook runs **after** route matching — framework URLs, anything with a file extension, and anything matching no page are already gone — so an app has never had to know an asset path.
2083
+
2084
+ ## One middleware per route
2085
+
2086
+ **At most one middleware may match a given route.** Two hooks writing one `authorization` header have no defensible winner, so an overlap refuses the boot and names both plus the route:
2087
+
2088
+ ```
2089
+ ✗ middleware.ts: 1 problem(s)
2090
+
2091
+ • `session` and `admin` both match /app/admin. A route may have at most one
2092
+ middleware — two hooks writing one `authorization` header have no defined
2093
+ winner. Narrow one with `except`, or fold them into one middleware that
2094
+ branches on `req.route`.
2095
+ ```
2096
+
2097
+ Declaration order is not a semantic, "most specific wins" would silently drop the broader hook — for a session renewal, that means a subtree stops renewing with nothing red anywhere — and merging needs a per-field rule nobody remembers.
2098
+
2099
+ `voltro doctor` reports overlaps and dead matchers before you deploy, and says so out loud when a matcher is built from a variable and it could not read it statically.
2100
+
2101
+ ## Reaching non-page requests
2102
+
2103
+ `assets: true` extends a middleware to requests that matched no page — your files, and paths the router does not serve. It is opt-in and narrow on purpose: there is no render and no rpc call on such a request, so `headers` has nothing to act on and **only `setCookies` takes effect**. The framework's own surface (`/@vite/*`, `/_voltro/*`) is never reachable.
2104
+
2105
+ ## What it deliberately cannot do
2106
+
2107
+ **It cannot redirect or refuse a request.** Authorization belongs on the api, which is the only thing that sees the data; a web-side hook that could refuse would be a second authorization layer beside the real one, and a hook that cannot refuse also cannot be mistaken for a guard. For a login redirect, throw `RedirectError` from the loader.
2108
+
2109
+ It also cannot live in `app.config.ts`: that file is imported into the client bundle whenever an api declares `authHeaders`, and a hook that renews a session reaches for an IdP SDK by definition.
2110
+
2111
+ ## Lifecycle
2112
+
2113
+ The file is loaded **once per boot** — it is app code with a stable identity, and re-importing per request would rebuild whatever an IdP client constructs at module level. A failure to import is fatal rather than degrading to "the app has none", and a middleware that throws fails the request: the render must not proceed on the credential the hook was told to replace.
2114
+
2115
+ **`voltro dev` therefore RESTARTS when you edit it**, the same way a hard-restart field in `app.config.ts` does, and says so in the log. Once-per-boot is documented, and it is still the rule most easily forgotten — everything else in a dev server hot-reloads, so a sabotaged middleware that changes nothing reads as a hook that was never wired.
2116
+
2117
+ It runs on both SSR boot paths, `voltro dev` and `voltro start`, with the cookies written on every response arm.
@@ -51,6 +51,30 @@ job with no diff to review. Full detail — including how plugin routes are
51
51
  covered per-request in the dispatch spine rather than by the boot gate — in
52
52
  [Authorization](/docs/authentication/authorization).
53
53
 
54
+ ### A decode failure on a guarded procedure is NOT a guard verdict
55
+
56
+ The payload is decoded before the handler runs, so a guard on a procedure with a
57
+ malformed payload never gets the chance to refuse. Calling one with an incomplete
58
+ payload therefore answers with a decode error, not a `ScopeError` — and reading
59
+ that as "the guard is not applied" is the wrong conclusion in the dangerous
60
+ direction.
61
+
62
+ A consumer reached exactly that conclusion while auditing a guard, and flipped
63
+ back the moment they sent a complete payload. So a guarded procedure says it in
64
+ the failure:
65
+
66
+ ```
67
+ workAreas.create input (guarded — the guard did NOT run: the payload failed to
68
+ decode first, so this says nothing about access)
69
+ └─ ["type"] └─ is missing
70
+ ```
71
+
72
+ **Measure a guard with a VALID payload.** The ordering itself is not fixable at
73
+ that seam — the request never reaches the handler or the auth middleware, so
74
+ "evaluate guards first" means replacing the protocol layer rather than annotating
75
+ a schema. An `openAccess:` declaration is not an enforced guard and gets no such
76
+ sentence.
77
+
54
78
  ## Outbound HTTP is SSRF-guarded by default
55
79
 
56
80
  The `HttpClient` your handlers `yield*` refuses internal targets: