@voltro/cli 0.39.1 → 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 (110) hide show
  1. package/CHANGELOG.md +232 -0
  2. package/dist/agentsMd-Bu_XQgVf.js +2 -0
  3. package/dist/{agentsMd-7zI2h5l9.js → agentsMd-D6yD7IQv.js} +31 -14
  4. package/dist/{apiBuild-D9FB13_2.js → apiBuild-B_iH24qf.js} +2 -2
  5. package/dist/apiBuild-RM5W5YXk.js +2 -0
  6. package/dist/bin.js +1 -1
  7. package/dist/{build-DORV5mLM.js → build-DUdi6F_Q.js} +100 -97
  8. package/dist/{checkCommand-D9qK_gtA.js → checkCommand-B2RWrpWi.js} +1 -1
  9. package/dist/{checkCommand-Bf6oMao4.js → checkCommand-DWL2_PPR.js} +2 -2
  10. package/dist/{cloudCmd-C42gaO8s.js → cloudCmd-F4YJeqM3.js} +1 -1
  11. package/dist/{codegenCommand-CxOyKmc1.js → codegenCommand-DkjoJ30Q.js} +2 -2
  12. package/dist/{codemodRunner-D-jTyvWo.js → codemodRunner-sFuWDETH.js} +520 -357
  13. package/dist/{commands-D6aW_6AR.js → commands-D0VR1yBB.js} +32 -32
  14. package/dist/{dashboardCommand-CwTZh-XZ.js → dashboardCommand-Bro7Jwts.js} +3 -3
  15. package/dist/{dataCommand-Cc9RiLQZ.js → dataCommand-OKC5FscP.js} +183 -172
  16. package/dist/{dbCommand-DSwGv9wS.js → dbCommand-BHRamDsy.js} +441 -414
  17. package/dist/dbCommand-C9iE1pd9.js +2 -0
  18. package/dist/{dev-3mgc8c-H.js → dev-D4l_YOzI.js} +1570 -1570
  19. package/dist/{dev-CjJAasDs.js → dev-DuqBJC7g.js} +1 -1
  20. package/dist/doctorCommand-Bw3V4L1j.js +2 -0
  21. package/dist/{doctorCommand-oLuzc-aC.js → doctorCommand-TmZTAepc.js} +386 -280
  22. package/dist/{dormancyCommand-Bxe4WtHU.js → dormancyCommand-CUKhRmGG.js} +1 -1
  23. package/dist/{embeddingsCommand-CyNQkFIc.js → embeddingsCommand-Db-iQM3l.js} +1 -1
  24. package/dist/{envCommand-CAkSgBbh.js → envCommand-DQ_URZq9.js} +14 -14
  25. package/dist/{evolveCommand-CWhBoe_P.js → evolveCommand-CghRPrGO.js} +2 -2
  26. package/dist/index.js +1 -1
  27. package/dist/{infoCommand-CTo8Jnhq.js → infoCommand-BCs_ZFnl.js} +1 -1
  28. package/dist/{metaCommands-DUYR--Ts.js → metaCommands-CfRLra0s.js} +1 -1
  29. package/dist/{migrate-BnPw2zC8.js → migrate-DLtPv2_D.js} +1 -1
  30. package/dist/{projectScaffold-BEhhHjPr.js → projectScaffold-BIl97_E6.js} +1 -1
  31. package/dist/{projectScaffold-BvhLOrLq.js → projectScaffold-mIX_DpSe.js} +1 -1
  32. package/dist/{runtimeTrace-gNXeLf6H.js → runtimeTrace-jFKvOPBz.js} +1 -1
  33. package/dist/{sdkgen-C8zz10-X.js → sdkgen-COQqHB9V.js} +1 -1
  34. package/dist/{serveCommand-BpK8-m1N.js → serveCommand-Cba_M2Su.js} +656 -656
  35. package/dist/serveCommand-DRbYK646.js +2 -0
  36. package/dist/serveEntry.js +1 -1
  37. package/dist/{start-ft_KzFTd.js → start-BjcuFME_.js} +421 -361
  38. package/dist/{start-BNuTWdRd.js → start-fifc0xpW.js} +1 -1
  39. package/dist/startEntry.js +1 -1
  40. package/dist/updateCommand-B2alql9k.js +2 -0
  41. package/dist/{updateCommand-CHBmCB17.js → updateCommand-HZQVBU3T.js} +1 -1
  42. package/dist/webDev-D6QeXw3P.js +2 -0
  43. package/dist/{webDev-CTpSY-e_.js → webDev-Dk8lFUzf.js} +672 -501
  44. package/dist/{webhooksCommand-EccPZQZ4.js → webhooksCommand-D7ZpH7w0.js} +1 -1
  45. package/package.json +35 -17
  46. package/templates/AGENTS.md +1 -1
  47. package/templates/agent-docs/_index.md +1 -1
  48. package/templates/agent-docs/_manifest.json +1 -1
  49. package/templates/agent-docs/cli.md +6 -0
  50. package/templates/agent-docs/data.md +43 -0
  51. package/templates/agent-docs/plugins/governance.md +8 -0
  52. package/templates/agent-docs/routing.md +153 -0
  53. package/templates/agent-docs/security.md +24 -0
  54. package/templates/agent-docs/whats-new.md +49 -27
  55. package/templates/apps/api-ai/package.json +7 -7
  56. package/templates/apps/api-auth/package.json +8 -8
  57. package/templates/apps/api-backend/package.json +7 -7
  58. package/templates/apps/api-backend-deactivation/package.json +7 -7
  59. package/templates/apps/api-backend-mail/package.json +8 -8
  60. package/templates/apps/api-backend-mariadb/package.json +9 -9
  61. package/templates/apps/api-backend-sqlite/package.json +8 -8
  62. package/templates/apps/api-backend-storage/package.json +8 -8
  63. package/templates/apps/api-cms/package.json +10 -10
  64. package/templates/apps/api-collab/package.json +8 -8
  65. package/templates/apps/api-data-advanced/package.json +8 -8
  66. package/templates/apps/api-durable/package.json +8 -8
  67. package/templates/apps/api-durable/tests/orders.place.test.ts +17 -4
  68. package/templates/apps/api-feature-flags/package.json +9 -9
  69. package/templates/apps/api-governance/package.json +8 -8
  70. package/templates/apps/api-kv/package.json +8 -8
  71. package/templates/apps/api-moderation/package.json +8 -8
  72. package/templates/apps/api-observability/package.json +8 -8
  73. package/templates/apps/api-ratelimit/package.json +8 -8
  74. package/templates/apps/api-rbac/package.json +8 -8
  75. package/templates/apps/api-rest/package.json +7 -7
  76. package/templates/apps/api-saas/package.json +11 -11
  77. package/templates/apps/api-saas-starter/package.json +10 -10
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-status/package.json +8 -8
  80. package/templates/apps/api-versioning/package.json +8 -8
  81. package/templates/apps/api-webhooks/package.json +9 -9
  82. package/templates/apps/changelog/package.json +6 -6
  83. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +8 -1
  84. package/templates/apps/edge-functions/package.json +2 -2
  85. package/templates/apps/frontend-admin/package.json +8 -8
  86. package/templates/apps/frontend-app/package.json +9 -9
  87. package/templates/apps/frontend-auth/package.json +8 -8
  88. package/templates/apps/frontend-blank/package.json +7 -7
  89. package/templates/apps/frontend-cms/package.json +9 -9
  90. package/templates/apps/frontend-collab/package.json +10 -10
  91. package/templates/apps/frontend-contact/package.json +7 -7
  92. package/templates/apps/frontend-dashboard/package.json +7 -7
  93. package/templates/apps/frontend-docs/package.json +7 -7
  94. package/templates/apps/frontend-i18n/package.json +6 -6
  95. package/templates/apps/frontend-landing/package.json +7 -7
  96. package/templates/apps/frontend-portal/package.json +8 -8
  97. package/templates/apps/frontend-saas/package.json +8 -8
  98. package/templates/apps/frontend-spa/package.json +7 -7
  99. package/templates/apps/frontend-ssr/package.json +7 -7
  100. package/templates/apps/frontend-ssr-api/package.json +8 -8
  101. package/templates/apps/frontend-static-blog/package.json +6 -6
  102. package/templates/apps/frontend-status/package.json +8 -8
  103. package/templates/apps/mobile-app/package.json +4 -4
  104. package/dist/agentsMd-BFCXh2gl.js +0 -2
  105. package/dist/apiBuild-BmR_bIxN.js +0 -2
  106. package/dist/dbCommand-DcqEyxju.js +0 -2
  107. package/dist/doctorCommand-Cghssndo.js +0 -2
  108. package/dist/serveCommand-CW-goXvh.js +0 -2
  109. package/dist/updateCommand-6FMU2klq.js +0 -2
  110. package/dist/webDev-id3I5PvG.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-CjJAasDs.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.39.1",
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",
@@ -610,6 +610,24 @@
610
610
  "title": "@voltro/plugin-rbac/web removed — the UI gate lives in @voltro/client",
611
611
  "kind": "transform"
612
612
  },
613
+ {
614
+ "version": "0.40.0",
615
+ "id": "0.40.0/01_cold-start-failure-is-its-own-state",
616
+ "title": "a failed cold start is `failed: true`, not `loading: true`",
617
+ "kind": "manual"
618
+ },
619
+ {
620
+ "version": "0.40.0",
621
+ "id": "0.40.0/02_rejected-credential-widens-guard-results",
622
+ "title": "a rejected credential answers `Unauthenticated`, widening three guard results",
623
+ "kind": "manual"
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
+ },
613
631
  {
614
632
  "version": "0.5.0",
615
633
  "id": "0.5.0/01_one-terminal-exactly-one",
@@ -697,22 +715,22 @@
697
715
  "@effect/platform-node": "^0.108.0",
698
716
  "@effect/sql": "^0.52.0",
699
717
  "@effect/workflow": "^0.19.0",
700
- "@voltro/ai": "0.39.1",
701
- "@voltro/cache": "0.39.1",
702
- "@voltro/data-transfer": "0.39.1",
703
- "@voltro/database": "0.39.1",
704
- "@voltro/env": "0.39.1",
705
- "@voltro/kv": "0.39.1",
706
- "@voltro/logger": "0.39.1",
707
- "@voltro/plugin-auth": "0.39.1",
708
- "@voltro/plugin-broadcast": "0.39.1",
709
- "@voltro/plugin-mail": "0.39.1",
710
- "@voltro/plugin-storage": "0.39.1",
711
- "@voltro/plugin-webhooks": "0.39.1",
712
- "@voltro/protocol": "0.39.1",
713
- "@voltro/runtime": "0.39.1",
714
- "@voltro/serverless": "0.39.1",
715
- "@voltro/workflow": "0.39.1",
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",
716
734
  "chokidar": "^5.0.0",
717
735
  "ioredis": "^5.11.1",
718
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.39.1** | `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.39.1** | `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
 
@@ -1728,6 +1728,33 @@ const { data } = useSubscription(
1728
1728
 
1729
1729
  While skipped, no WebSocket subscription opens and `data` stays `undefined`.
1730
1730
 
1731
+ ### A failed cold start is `failed`, not `loading`
1732
+
1733
+ If the first snapshot never arrives because the subscription ERRORED, the state
1734
+ is **`failed: true`, `loading: false`, `data: undefined`** — nothing is in
1735
+ flight and nothing more is coming, so "it is loading" would be a false
1736
+ statement. `error` carries the cause.
1737
+
1738
+ ```tsx
1739
+ const s = useSubscription<Team[]>('app', 'teams.list')
1740
+
1741
+ if (s.loading) return <Skeleton/>
1742
+ if (s.failed) return <RetryPanel error={s.error}/>
1743
+ return <TeamsTable teams={s.data}/>
1744
+ ```
1745
+
1746
+ `failed` is a positive check on purpose. The state used to be `loading: true`
1747
+ with `error` set, and the escape hatch was reading that second field — which the
1748
+ natural shape of a wrapper (`{ data, loading }` passed through) drops. **If you
1749
+ wrap this state, carry `failed` with it**, or your callers inherit an infinite
1750
+ skeleton through your hook.
1751
+
1752
+ Two things this does NOT cover. A failure AFTER the first snapshot leaves the
1753
+ good data on screen and sets `error` — replacing working rows with an error
1754
+ because the socket hiccuped is worse than the hiccup. And the failed state is
1755
+ terminal for one TRANSPORT, not forever: a reconnect discards the error and
1756
+ re-subscribes, so the entry returns to loading on its own.
1757
+
1731
1758
  ### Skipped is `idle`, not `loading`
1732
1759
 
1733
1760
  A skipped subscription reports **`idle: true`, `loading: false`**. The two are
@@ -4711,6 +4738,22 @@ To distinguish "we know about this" from "this surprised us":
4711
4738
  | `throw new Error('oops')` | Surfaces as a defect — full log + trace, generic failure to client |
4712
4739
  | Unexpected exception from a library | Same — caught, logged, generic failure |
4713
4740
 
4741
+ ## What the framework merges into your `error:` union
4742
+
4743
+ You do not declare the errors the framework itself can raise for a procedure —
4744
+ they are unioned into the wire contract for you, and only when the procedure can
4745
+ actually produce them:
4746
+
4747
+ | Merged | Into | When |
4748
+ |---|---|---|
4749
+ | `ScopeError` | query · mutation · action · event | the procedure declares a real `guards:` entry. NOT for `openAccess:` — a procedure advertising a denial it cannot produce is what makes an error union stop meaning anything |
4750
+ | `Unauthenticated` | query · mutation · action · event | the same condition. A guard can refuse for TWO reasons and they mean different things: the caller is known and lacks the scope (`ScopeError`), or they presented a credential that was REJECTED and so arrived anonymous (`Unauthenticated`). Without the second, an expired session reads as a permissions problem |
4751
+ | `BusinessRuleViolation` | mutation | always. A cross-table `rule()` on any table the mutation writes can fail it, and the descriptor cannot know which tables carry rules |
4752
+ | `ApprovalRequired` · `ApprovalExpired` · `ApprovalUnavailable` | mutation · action | the procedure declares `requiresApproval:` |
4753
+
4754
+ So a guarded mutation does **not** need `ScopeError` in its own `error:`. If you
4755
+ declared it anyway, that is harmless — the union is the same either way.
4756
+
4714
4757
  ## Framework-shipped error variants
4715
4758
 
4716
4759
  `@voltro/protocol` exports exactly two tagged errors. The rest of your typed errors are ones you declare yourself (above) or ones a plugin / the runtime contributes.
@@ -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}`.
@@ -1150,6 +1150,23 @@ Auth is resolved by the **api**, never the web app. Two rules follow:
1150
1150
 
1151
1151
  So a cookie-mode Supabase app configures `supabaseStrategy({ cookieName: 'sb-<ref>-auth-token' })` on the **api**; the web loader's `ctx.query` then authenticates for free. See [Supabase Auth](/docs/plugins/auth-supabase).
1152
1152
 
1153
+ ### Renewing an EXPIRED session — `middleware.ts`
1154
+
1155
+ The rules above assume the cookie is still valid. When it is not — a token older
1156
+ than the IdP's lifetime, which for a 1-hour token is practically every first
1157
+ page view of the day — the api resolves the caller to anonymous and every loader
1158
+ and `preload` on the page fails.
1159
+
1160
+ You cannot fix that in a loader. `ctx.query` and every `preload` entry are bound
1161
+ from ONE cookie string **before any loader runs**, so a layout loader that
1162
+ renews the session cannot reach them. `middleware.ts` at the web app root runs
1163
+ earlier than both, says which routes it covers, and writes the rotated cookie
1164
+ back so this render and the browser agree.
1165
+
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.
1169
+
1153
1170
  ## Errors from loaders
1154
1171
 
1155
1172
  If the loader throws, the framework:
@@ -1962,3 +1979,139 @@ matches (workspace link and npm install alike).
1962
1979
  How to verify: `curl -sS http://localhost:<port>/@fs/<abs>/src/globals.css | grep
1963
1980
  animate-` should list every kit animation utility you use. If a class is missing,
1964
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:
@@ -1,4 +1,4 @@
1
- # What's new in 0.39.1
1
+ # What's new in 0.41.0
2
2
 
3
3
  Read this FIRST when a task touches an area you have not worked in recently.
4
4
  It is the cheapest way to notice that the framework grew the thing you were
@@ -7,50 +7,72 @@ workaround for something that shipped two versions ago.
7
7
 
8
8
  BREAKING entries name a codemod; run `voltro update` to apply it.
9
9
 
10
+ ### ⚠ BREAKING
11
+
12
+ - **@voltro/web, @voltro/cli** — `middleware.ts` exports `defineMiddleware(...)` (from `@voltro/web/middleware`) instead of a bare function, and each export carries its own `match`. Several middlewares per file are allowed; **at most one may match a given route**.
13
+
14
+ Migration: the codemod wraps the existing default export. That is behaviour-preserving — no `match` means every server-rendered route, which is what an unwrapped middleware did — and its note explains how to replace a hand-written path gate with a `match`.
15
+
16
+ **Why it was worth a break.** A hand-written `if (!req.pathname.startsWith('/app')) return` is invisible: nothing can tell you a middleware runs nowhere, or that two of them claim one route. `match` puts it where both the boot and `voltro doctor` can read it.
17
+
18
+ **The matcher speaks ROUTES, not URL patterns** — `under` / `routes` / `except`, validated against the app's own route patterns. A path matching no route refuses the boot instead of silently never firing. This is the deliberate difference from the `'/((?!api|_next/static|…).*)'` shape: our hook runs after route matching, so an app has never needed to know its own asset layout, and non-page requests are reachable only by asking (`assets: true`) — where, note, there is no render, so only `setCookies` takes effect.
19
+
20
+ An overlap refuses the boot and names both middlewares plus the route. Declaration order is not a semantic, "most specific wins" silently drops the broader hook, and merging needs a per-field rule nobody remembers — so two hooks writing one `authorization` header is a refusal, not a resolution.
21
+
22
+ **The web bundle budget moved UP, and the split is worth stating** because only one half is a cost the framework imposes:
23
+
24
+ | measured | before | after | | --- | --- | --- | | first load | 184 955 B | 185 309 B (**+354**) | | lazy route chunks | 3 502 B | 4 415 B (+913) |
25
+
26
+ The **+354 B of first load is the real price** — one `serverContext` chunk, 0.2 KB gz, which every app now carries whether or not it declares a middleware. That is the number to argue with, and it leaves 6.7 KB of headroom under the ceiling.
27
+
28
+ The +913 B is NOT a per-route regression: the fixture gained four routes (`exact`, `exact/[id]`, `mw`, `mw/skip`) to exercise the feature end to end, at 0.1–0.2 KB gz each, which accounts for the growth without remainder. Re-pinned with `--update` rather than by hand, so the `slackFloor` keeps ratcheting — a ceiling nobody lowers again silently permits re-inflating to the old number.
29
+
10
30
  ### Fixed
11
31
 
12
- - **@voltro/runtime, @voltro/cli** — An app-registered tuple source is no longer replaced by the framework default.
32
+ - **@voltro/data-transfer, @voltro/cli** — `voltro data export` could not export a table whose primary key is not named `id`, and one of its two failure modes reported success.
33
+
34
+ The keyset column was `columns.find(c => c.type === 'id')?.name ?? 'id'`, and `type: 'id'` is tagged only on a column that is BOTH the single-column primary key AND literally named `id` — identically in all four dialect introspectors. So any introspected table with another PK name was ordered by a column that does not exist. It now comes from the real primary key (the synthesised `<table>_pkey` index), with the declared `id()` column still winning where there is one.
35
+
36
+ **A composite or absent primary key is now REFUSED**, not silently ordered by the first column: keyset pagination on a non-unique order splits equal values across page boundaries, so rows are dropped or duplicated into a bundle that reports success. Bounded exports are recoverable; a quietly short backup is discovered at the restore.
13
37
 
14
- Both boot paths carried this comment, verbatim:
38
+ **A requested table missing from the schema is refused too.** `scope: { kind: 'tables' }` used to drop unknown names, so a run that explicitly named a table wrote `"tables": []` and printed `export complete` with exit 0. `kind: 'all'` over an empty database is still a legal empty export — the asymmetry is deliberate: a named table is an expectation.
15
39
 
16
- > An app whose relationships live in its own tables overrides with > `setTupleSource`.
40
+ **Failure reasons survive.** `String(e?.message ?? e)` produced `"write table failed: "` with nothing after the colon — `??` falls back on null/undefined, and an Effect `TaggedError` carries an empty-string `message`. Every catch site in the exporter now reports tag, message or cause.
17
41
 
18
- and then called `setTupleSource(default)` **unconditionally**. That call runs AFTER the app's startups `runBootLifecycle` at `dev.ts:3910` and `serveCommand.ts:869`, against the registration at `dev.ts:4065` and `serveApi.ts:937` and `setTupleSource` is last-write-wins. So the framework won every time: an app registering its own source in a `*.startup.tsx`, which is what the docs tell it to do, had it silently replaced.
42
+ **New: `voltro data export --exclude a,b`** everything except these, resolved against the live table list. It is the escape hatch the refusals above require; without it a single unkeyable table would block a whole-database export. An unknown name is refused for the same reason. Direct target only (the expansion needs the live table list), and it expands to an explicit `tables` scope, so the manifest records what was actually exported.
19
43
 
20
- The consequence is not subtle. Every relationship guard would then be answered from `_voltro_rebac_tuples` — empty, for exactly the app that keeps its relations in its own tables so every guard DENIES, fail-closed, with a `no tuple source` warning that never fires because a source *is* registered: the wrong one.
44
+ Reported with a reduced repro, a four-way variation over PK TYPES that ruled type out, and two disproved hypotheses. The affected tables include `@effect/cluster`'s own (`cluster_locks`, `cluster_migrations`), so no app running workflows could take a whole-database export.
45
+ - **@voltro/database, @voltro/runtime, @voltro/cli** — `.encrypted()` had three writers and two encodings. The store wrote `encrypt(JSON.stringify(v))`; `encryptField` — the documented raw-SQL escape hatch — and `voltro db encrypt-column` wrote `encrypt(v)`. All three produce the same `enc:v1:` envelope and nothing distinguished them, so a value written by one and read by another either threw with the wrong diagnosis or came back subtly wrong (`decryptField` handed back the JSON encoding verbatim, quotes and all, raising nothing).
21
46
 
22
- The comment was not wrong about the design; it described the design while the code removed it. Same shape as the `DORMANCY_WAKEUP_TENANT` scar equal by value at both call sites, so nothing could fail, and the comment was the only place the intent survived.
47
+ There is one encoding for every WRITE now, and every READ resolves BOTH forms so **no data has to be rewritten and nothing is blocked**. That second half is the point: the old form is already on staging and production disks, and a fix that needs the rows rewritten before the app works is an outage with a migration attached.
23
48
 
24
- `registerDefaultTupleSource` fills the gap only when nobody else did, and returns whether it acted so the boot can say which source is live instead of leaving an operator to guess. `setTupleSource` itself is unchanged: an app calling it twice still gets the second one, because narrowing that would trade one silent surprise for another.
49
+ Reading two forms is deterministic, not a heuristic. After decrypting, a parse failure is the raw form; a parse to a STRING is the JSON form; a parse to a non-string depends on the column's declared type (a text column cannot hold a number, so `12345` is a raw string that parsed by accident). The one case nothing can separate — a raw secret whose literal text is `"abc"`, quotes included — is stated in the code rather than hidden.
25
50
 
26
- Found while re-checking a consumer's claim that a different item was still open. It was notbut it sits beside this, and this is the one that would have bitten them. Pinned in both directions: a unit test for the registrar, and a source guard asserting neither boot path installs `loadResourceTuples` through `setTupleSource` again. Red-verified by restoring the original line.
27
- - **@voltro/cli** — `voltro doctor` no longer reports "scope vocabulary: none found" when it could not read the config at all.
51
+ **`voltro db encrypt-column` verified itself against the wrong decoder.** It wrote the raw form and checked it with `cipher.decrypt` a decoder nothing reads these columns with so it reported success over columns the app could not read. It round-trips through `decodeFieldValue` now, the same function the store calls. A self-check against a decoder the runtime does not use is not a weaker check; it is a second opinion from the same mistake.
28
52
 
29
- A consumer HAD configured `doctor.scopeVocabulary` and still read `none found`. The cause was not their vocabulary: `app.config.ts` could not be imported outside their pod — an auth strategy demands its secret while the config is evaluated — so the vocabulary was never read. Inside the pod the message disappears and the rule runs.
53
+ The command also NORMALISES rows in the old encoding as it goes (reported separately from the ones it encrypts), so an operator does not write a script per column. It skips anything ambiguous and anything it cannot decrypt.
30
54
 
31
- "You have no vocabulary" and "I could not look" are different states, and only the first is a statement about their app. This section already made exactly that distinction one member earlier its own comment reads **DORMANT AND CLEAN MUST NOT PRINT THE SAME** and then collapsed the third.
55
+ **The width pre-flight measured the wrong thing after the encoding changed.** It sized the ciphertext from the PLAINTEXT's byte length while the cipher is handed the JSON encoding — two characters more at minimum, and more for every escape. Measured on a real MariaDB: a 63-byte value in a `varchar(135)` passed the check and the UPDATE answered `ER_DATA_TOO_LONG`, which is the failure that check exists to prevent, mid-column with the rest already converted. It measures the encoded length now, and the refusal says "encodes to" rather than "is" so an operator measuring their own column finds the number it names.
32
56
 
33
- The signal existed and was not connected. `offlineManifest` deliberately survives an unimportable config (a doctor run must not die on one), so the empty vocabulary arrived looking like a real absence, while the same failure was reported a hundred lines earlier under plugin tables. The consumer read the section about scopes and searched at the wrong end which is the correct way to read a report.
57
+ **Two dialect defects, both found by running the command against real servers.** SQL Server reports `-1` for `NVARCHAR(MAX)` its spelling of unbounded and the pre-flight read it as a one-character column, so it refused the widest column the dialect has and printed `declared as -1` at the operator. And SQLITE has no `information_schema` at all: the shared catalog query died there with `Failed to prepare statement` and no statement attached, on a dialect the command claims to support. It uses `pragma_table_info` now, reporting no length because sqlite enforces none.
34
58
 
35
- The rule now probes importability before claiming an absence, and says so:
59
+ Measured end to end on postgres, mysql, mariadb, mssql and sqlite: a table holding plaintext, the old encoding and the current encoding side by side converts, every row decodes back to its original value, a re-run writes nothing, and a wrong key refuses with exit 1.
36
60
 
37
- ```
38
- • scope vocabulary: NOT EVALUATED `app.config.ts` could not be imported.
39
- This is not a statement about your scopes: the rule never got to read them.
40
- … The same import failure is reported above for plugin tables; both sections
41
- have this one cause.
42
- ```
61
+ **Backups and restores were never affected and now say so.** `voltro data export` reads through the raw dialect store, so ciphertext travels verbatim in either encoding — pinned by a test, because a future change that wrapped that store would put plaintext credentials in a bundle.
62
+ - **@voltro/protocol, @voltro/cli** — Three findings from one consumer round, all of the same shape: something the framework knows and does not say.
43
63
 
44
- The dormant case keeps its own wording, so a quiet run on an app that genuinely publishes no vocabulary is not relabelled as a broken config.
64
+ **A decode failure on a GUARDED procedure now says the guard did not run.** The payload decodes before the handler, so a guard on a procedure with a malformed payload never gets the chance to refuse. A consumer auditing a guard called one with an incomplete payload, got a decode error instead of a `ScopeError`, and concluded the guard was not applied — the wrong conclusion in the dangerous direction. The title now carries `(guarded — the guard did NOT run: the payload failed to decode first, so this says nothing about access)`. It discloses nothing new: that a procedure is guarded is already visible to anyone who sends a VALID payload. An `openAccess:` declaration is not an enforced guard and gets no such sentence — `hasEnforcedGuard` is the one predicate, read by both the label and the wire error union, because two copies of that rule would disagree invisibly.
45
65
 
46
- ### Internal (no consumer-facing effect)
66
+ **`middleware.ts`'s `httpOnly` default is documented at the field, and warned about.** It defaults to `HttpOnly`, which is wrong for a session cookie a browser SDK reads back: Supabase's `createBrowserClient` reads `document.cookie`, so a forgotten `httpOnly: false` gives the browser a session it cannot see — the SSR render is perfect and the user is signed out at the first client-side call. The consumer only avoided shipping it because their probes already set the flag. `voltro dev` warns once per cookie when a session-shaped name is written with no `httpOnly` decision; an explicit decision either way silences it, because warning on a decision is how a diagnostic becomes noise.
47
67
 
48
- - **@voltro/cache, @voltro/kv** The RESP TTL tests in `@voltro/cache` and `@voltro/kv` stopped measuring the machine. Test-only; no product code changed.
68
+ **`voltro dev` restarts when `middleware.ts` changes.** It is loaded once per boot, that is documented, and a consumer read it and still lost an afternoon: they sabotaged the middleware, saw no change, and concluded it was not wired — in an environment where everything else hot-reloads. It now restarts through the same respawn a hard-restart field in `app.config.ts` uses, extracted so there is one copy of the `execArgv` inheritance and the signal forwarding.
69
+ - **@voltro/cli** — `middleware.ts` now produces ONE view of the request that every downstream reader takes. Previously only `buildLoaderQuery` saw the hook's result, while the loader context (`ctx.headers`), the SSR request snapshot (`useServerRequest()`) and the locale resolver kept reading the raw request — four readers, two answers, within eighty lines of one function.
49
70
 
50
- Both wrote a key with `ttlMs: 150`, asserted it was still there, then slept 300 ms and asserted it was gone. The second half is fine waiting LONGER only strengthens "it expired". The first half was a race: the write and the read are two round-trips, so on a loaded runner the key legitimately expired before the liveness assertion, and the test reported a defect that was not there. It failed exactly that way on a release gate, in the keydb engine, while passing locally with 24/24 green.
71
+ The consequence was worse than an inconsistency: a hook that renews purely via `setCookies`no `headers` at all, which is the normal shape for a cookie-session IdP and the reason the response half exists moved nothing for the render that ran it. The rpc call still sent the old `Cookie` header, because a renewed cookie only reached the browser.
51
72
 
52
- Now: a 2 s window, so liveness has real headroom rather than 150 ms of it, and the expiry is awaited as a CONDITION (`awaitGone` polls) rather than as a duration. Idle machines finish in about the TTL; loaded ones take as long as they need; a key that never expires still fails, because the ceiling is a failure mode and not a timing assumption.
73
+ `setCookies` is applied to the cookie jar before the render, the `Cookie` header is rebuilt from that jar (an explicit `cookie` in the hook's own `headers` still wins), and `maxAge <= 0` deletes, so a hook that signs someone out renders them signed out. Both SSR boot paths shadow the raw headers out of scope after the hook runs, so a new reader added below is correct without knowing any of this.
74
+ - **@voltro/cli** — `voltro start` dropped `middleware.ts`'s `Set-Cookie` on **streamed** responses — which is the arm a plain `renderMode: 'ssr'` page takes, so it was the common case. The hook renewed the session server-side, the render used the fresh value, and the browser kept the consumed one. Against an IdP that rotates refresh tokens and detects reuse, that is worse than not renewing at all.
53
75
 
54
- Fixed in BOTH packages in one change. The two files carried the identical construction, and this repo's standing lesson is that a fix landing in one copy of a duplicated shape leaves the other one broken `@voltro/kv` had not failed yet, which is a statement about luck rather than about the test.
76
+ The cause is worth stating because it read as handled: a streamed response hands the socket to `stream(res)` and the caller never looks at the returned `headers`, so the `withCookies(...)` wrapper on that arm was dead code sitting under a comment promising the cookies were written on every arm. The cookies now travel with the headers `streamSsrResponse` itself writes, and the dead wrapper is gone.
55
77
 
56
- `@voltro/kv`'s header also pointed at `../cache/test/docker-compose.yml` for bringing the engines up. That path does not exist; there is one compose file, at the repo root.
78
+ Found by booting real `voltro dev` and `voltro start` servers against a fixture and reading the response. Every unit test was green throughout, and the render's own HTML was correct — only the wire was wrong.
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/ai": "0.39.1",
16
- "@voltro/cli": "0.39.1",
17
- "@voltro/database": "0.39.1",
18
- "@voltro/env": "0.39.1",
19
- "@voltro/protocol": "0.39.1",
20
- "@voltro/runtime": "0.39.1",
15
+ "@voltro/ai": "0.41.0",
16
+ "@voltro/cli": "0.41.0",
17
+ "@voltro/database": "0.41.0",
18
+ "@voltro/env": "0.41.0",
19
+ "@voltro/protocol": "0.41.0",
20
+ "@voltro/runtime": "0.41.0",
21
21
  "effect": "^3.22.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.39.1",
24
+ "@voltro/testing": "0.41.0",
25
25
  "typescript": "^6.0.3",
26
26
  "@vitest/coverage-v8": "^4.1.10",
27
27
  "vitest": "^4.1.10"
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.39.1",
17
- "@voltro/database": "0.39.1",
18
- "@voltro/env": "0.39.1",
19
- "@voltro/plugin-auth": "0.39.1",
20
- "@voltro/protocol": "0.39.1",
21
- "@voltro/runtime": "0.39.1",
22
- "@voltro/sql-postgres": "0.39.1",
16
+ "@voltro/cli": "0.41.0",
17
+ "@voltro/database": "0.41.0",
18
+ "@voltro/env": "0.41.0",
19
+ "@voltro/plugin-auth": "0.41.0",
20
+ "@voltro/protocol": "0.41.0",
21
+ "@voltro/runtime": "0.41.0",
22
+ "@voltro/sql-postgres": "0.41.0",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.39.1",
26
+ "@voltro/testing": "0.41.0",
27
27
  "typescript": "^6.0.3",
28
28
  "@vitest/coverage-v8": "^4.1.10",
29
29
  "vitest": "^4.1.10"