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