@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.
- package/CHANGELOG.md +232 -0
- package/dist/agentsMd-Bu_XQgVf.js +2 -0
- package/dist/{agentsMd-7zI2h5l9.js → agentsMd-D6yD7IQv.js} +31 -14
- package/dist/{apiBuild-D9FB13_2.js → apiBuild-B_iH24qf.js} +2 -2
- package/dist/apiBuild-RM5W5YXk.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-DORV5mLM.js → build-DUdi6F_Q.js} +100 -97
- package/dist/{checkCommand-D9qK_gtA.js → checkCommand-B2RWrpWi.js} +1 -1
- package/dist/{checkCommand-Bf6oMao4.js → checkCommand-DWL2_PPR.js} +2 -2
- package/dist/{cloudCmd-C42gaO8s.js → cloudCmd-F4YJeqM3.js} +1 -1
- package/dist/{codegenCommand-CxOyKmc1.js → codegenCommand-DkjoJ30Q.js} +2 -2
- package/dist/{codemodRunner-D-jTyvWo.js → codemodRunner-sFuWDETH.js} +520 -357
- package/dist/{commands-D6aW_6AR.js → commands-D0VR1yBB.js} +32 -32
- package/dist/{dashboardCommand-CwTZh-XZ.js → dashboardCommand-Bro7Jwts.js} +3 -3
- package/dist/{dataCommand-Cc9RiLQZ.js → dataCommand-OKC5FscP.js} +183 -172
- package/dist/{dbCommand-DSwGv9wS.js → dbCommand-BHRamDsy.js} +441 -414
- package/dist/dbCommand-C9iE1pd9.js +2 -0
- package/dist/{dev-3mgc8c-H.js → dev-D4l_YOzI.js} +1570 -1570
- package/dist/{dev-CjJAasDs.js → dev-DuqBJC7g.js} +1 -1
- package/dist/doctorCommand-Bw3V4L1j.js +2 -0
- package/dist/{doctorCommand-oLuzc-aC.js → doctorCommand-TmZTAepc.js} +386 -280
- package/dist/{dormancyCommand-Bxe4WtHU.js → dormancyCommand-CUKhRmGG.js} +1 -1
- package/dist/{embeddingsCommand-CyNQkFIc.js → embeddingsCommand-Db-iQM3l.js} +1 -1
- package/dist/{envCommand-CAkSgBbh.js → envCommand-DQ_URZq9.js} +14 -14
- package/dist/{evolveCommand-CWhBoe_P.js → evolveCommand-CghRPrGO.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/{infoCommand-CTo8Jnhq.js → infoCommand-BCs_ZFnl.js} +1 -1
- package/dist/{metaCommands-DUYR--Ts.js → metaCommands-CfRLra0s.js} +1 -1
- package/dist/{migrate-BnPw2zC8.js → migrate-DLtPv2_D.js} +1 -1
- package/dist/{projectScaffold-BEhhHjPr.js → projectScaffold-BIl97_E6.js} +1 -1
- package/dist/{projectScaffold-BvhLOrLq.js → projectScaffold-mIX_DpSe.js} +1 -1
- package/dist/{runtimeTrace-gNXeLf6H.js → runtimeTrace-jFKvOPBz.js} +1 -1
- package/dist/{sdkgen-C8zz10-X.js → sdkgen-COQqHB9V.js} +1 -1
- package/dist/{serveCommand-BpK8-m1N.js → serveCommand-Cba_M2Su.js} +656 -656
- package/dist/serveCommand-DRbYK646.js +2 -0
- package/dist/serveEntry.js +1 -1
- package/dist/{start-ft_KzFTd.js → start-BjcuFME_.js} +421 -361
- package/dist/{start-BNuTWdRd.js → start-fifc0xpW.js} +1 -1
- package/dist/startEntry.js +1 -1
- package/dist/updateCommand-B2alql9k.js +2 -0
- package/dist/{updateCommand-CHBmCB17.js → updateCommand-HZQVBU3T.js} +1 -1
- package/dist/webDev-D6QeXw3P.js +2 -0
- package/dist/{webDev-CTpSY-e_.js → webDev-Dk8lFUzf.js} +672 -501
- package/dist/{webhooksCommand-EccPZQZ4.js → webhooksCommand-D7ZpH7w0.js} +1 -1
- package/package.json +35 -17
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/cli.md +6 -0
- package/templates/agent-docs/data.md +43 -0
- package/templates/agent-docs/plugins/governance.md +8 -0
- package/templates/agent-docs/routing.md +153 -0
- package/templates/agent-docs/security.md +24 -0
- package/templates/agent-docs/whats-new.md +49 -27
- package/templates/apps/api-ai/package.json +7 -7
- 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 +10 -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-durable/tests/orders.place.test.ts +17 -4
- 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-saas/package.json +11 -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-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +8 -1
- 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-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -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-BFCXh2gl.js +0 -2
- package/dist/apiBuild-BmR_bIxN.js +0 -2
- package/dist/dbCommand-DcqEyxju.js +0 -2
- package/dist/doctorCommand-Cghssndo.js +0 -2
- package/dist/serveCommand-CW-goXvh.js +0 -2
- package/dist/updateCommand-6FMU2klq.js +0 -2
- 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-
|
|
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.
|
|
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.
|
|
701
|
-
"@voltro/cache": "0.
|
|
702
|
-
"@voltro/data-transfer": "0.
|
|
703
|
-
"@voltro/database": "0.
|
|
704
|
-
"@voltro/env": "0.
|
|
705
|
-
"@voltro/kv": "0.
|
|
706
|
-
"@voltro/logger": "0.
|
|
707
|
-
"@voltro/plugin-auth": "0.
|
|
708
|
-
"@voltro/plugin-broadcast": "0.
|
|
709
|
-
"@voltro/plugin-mail": "0.
|
|
710
|
-
"@voltro/plugin-storage": "0.
|
|
711
|
-
"@voltro/plugin-webhooks": "0.
|
|
712
|
-
"@voltro/protocol": "0.
|
|
713
|
-
"@voltro/runtime": "0.
|
|
714
|
-
"@voltro/serverless": "0.
|
|
715
|
-
"@voltro/workflow": "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",
|
|
716
734
|
"chokidar": "^5.0.0",
|
|
717
735
|
"ioredis": "^5.11.1",
|
|
718
736
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.md
CHANGED
|
@@ -707,7 +707,7 @@ each plugin's own README.
|
|
|
707
707
|
|
|
708
708
|
| Topic | Open | Summary |
|
|
709
709
|
|---|---|---|
|
|
710
|
-
| **What's new in 0.
|
|
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.
|
|
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":
|
|
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.
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/database": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
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.
|
|
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.
|
|
17
|
-
"@voltro/database": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/plugin-auth": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
22
|
-
"@voltro/sql-postgres": "0.
|
|
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.
|
|
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"
|