@voltro/protocol 0.8.0 → 0.9.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 +19 -0
- package/dist/jwt.d.ts +39 -3
- package/dist/jwt.js +7 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,25 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.9.0] — 2026-07-21
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/web, @voltro/cli** — A `renderMode: 'spa'` page whose route has an SSR layout chain now renders that LAYOUT on the server — an SSR shell — instead of rendering nothing server-side. This decouples layout SSR from page render mode: the layout (nav shell, sidebar, auth gate) is server-rendered for an instant first paint and SEO, while the page itself stays client-only. Concretely, on both `voltro dev` and `voltro start`, a spa page under a layout used to ship the empty client shell (`<div id="root"></div>`) and mount the whole tree — layout included — in the browser. Now the server renders the layout chain around an empty page slot (`<div data-voltro-page-slot>`), runs the LAYOUT loaders, and inlines their data + a `pageClientOnly` flag; the browser hydrates that shell and mounts the client-only page into the slot after hydration. The page's OWN loader still runs in the browser, exactly as before. A spa page with NO layout is unchanged (still a pure client mount); `static`/`ssr`/`isr` pages are unchanged. Why this is BREAKING: a layout component AND its `loader` now execute during the server render for spa routes. Layouts shared with any `static`/`ssr`/`isr` page already ran server-side (and `static` is the default), so they are unaffected — the only newly-server-rendered layout is one whose ENTIRE page subtree is `'spa'`. Such a layout must be SSR-safe: no unguarded `window`/`document` at render time or in its `loader`. The `voltro update` codemod (`0.9.0/01`) prints this review step, and only for projects that actually have both a spa page and a layout. Out of scope this release (follow-ups): build-time prerender of the spa layout shell (`voltro build` still skips spa pages, so `voltro start` renders the shell on demand rather than from a prerendered file), `defer()`/streaming inside a spa-shell layout (the shell is buffered), and Fast-Refresh coverage of the new shell path.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **@voltro/protocol** — `jwtBearerStrategy` (and `extractBearerOrCookie`) accept an optional `cookieToToken` hook (`CookieTokenExtractor`) that transforms the request's cookie transport into the JWT to verify. It receives a `getCookie(name)` accessor (so a strategy can read sibling / chunked cookies) plus the configured cookie name. Applied to the cookie path only — the Bearer header stays a raw token — and defaulting to a verbatim read, so the raw-JWT cookie path for WorkOS / Kinde / Clerk / Auth0 / OIDC is byte-identical. `supabaseStrategy` supplies one to unwrap the `@supabase/ssr` session envelope. Additive: existing `jwtBearerStrategy` configs and 2-arg `extractBearerOrCookie` calls are unaffected.
|
|
51
|
+
- **@voltro/cli** — `voltro doctor` now flags pages that are safe `renderMode:'spa'` candidates. A page is listed when ALL hold: it is a page file (not `layout`/`loading`/`error`/`not-found`), it exports no `loader`, its `renderMode` is `'ssr'` or unset/default (not already `'spa'`/`'static'`/`'isr'`), AND a `layout.tsx` sits somewhere in its directory chain (root, an ancestor, or the page's own dir). That last condition is load-bearing: only with a layout does switching to `'spa'` keep a server-rendered shell (the layout SSRs while the page body goes client-only) — a page with no layout would, as `'spa'`, ship no server HTML at all, so it is never flagged. The hint is ADVISORY and names the tradeoff: `renderMode:'spa'` skips the page's per-page SSR compile while its layout shell still renders server-side — adopt it for internal/authenticated pages whose body needs no SSR; keep `'ssr'` when the page content needs SEO or server first-paint. We deliberately ship NO codemod to auto-flip pages, because dropping a page body's server render is a per-page product decision, not a mechanically-safe transform. The scan reuses the framework's own page discovery (`walkPagesTree`), so it can't drift from what dev/build classify as a page. The full list is retrievable via `voltro doctor --json` (a new `spaCandidates` array); the human view caps at 10 with a "+N more" pointer.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **@voltro/cli** — `voltro dev` no longer OOM-kills itself when many `renderMode:'ssr'` pages compile at once. The dev SSR renderer compiles each page (and every layout in its chain) on demand via Vite's `ssrLoadModule`, and that call was unbounded: two browser tabs on the same cold route, or a health-check sweep hitting hundreds of distinct routes, each started its OWN esbuild module tree with no dedupe and no concurrency cap, so the transient heaps added up and the process was killed (a downstream app with ~224 SSR pages reached >14 GB in seconds; `--max-old-space-size=8192` died after ~5 concurrent cold pages). The fix is a cold-compile gate (`coldCompileGate.ts`) around every `ssrLoadModule` in the dev SSR handler — the page, each layout/error/loading segment, and the shared `@voltro/web/ssr` helpers, so a sweep can't fan out on layouts either. It does two things: (1) DEDUPES concurrent requests for the same module — N tabs on one cold route trigger ONE compile they all await; (2) BOUNDS how many DISTINCT cold compiles run at once (default 4). A WARM module (already in Vite's graph) bypasses the gate entirely, so a hot app stays fully concurrent — the gate only tames the cold stampede, it never serializes warm serving. Verified: a concurrent sweep of 100 distinct cold routes runs exactly 4 concurrent esbuild trees under the default instead of 100, and 8 concurrent requests to one cold route compile the page once. The bound is overridable with `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY` — drop it to `1`/`2` on a low-memory box, raise it on a beefy one. Default 4 balances memory against cold-sweep throughput (a lower value is safer but serializes first-paint of freshly-hit routes). Production `voltro start` is unaffected: it renders from a precompiled bundle and refuses to boot without one, so it never compiles on demand. The dev-like `voltro start` middleware FALLBACK (used only when `NODE_ENV!=='production'` and no bundle exists) shares the same `ssrLoadModule` path and now goes through the same gate. Covered by `coldCompileGate.test.ts` (dedupe, bound, warm bypass, failure-clears-and-retries, env parsing); the heavy end-to-end reproduction is a scratchpad (`scripts/measure-ssr-compile-oom.mjs`), not a CI gate.
|
|
56
|
+
- **@voltro/cli** — `voltro update` (and `voltro update --codemods-only`) no longer aborts with `ELOOP: too many symbolic links` when the app tree contains a circular symlink. The codemod file scan built its ts-morph `Project` with `addSourceFilesAtPaths([...globs])`, whose underlying glob FOLLOWS symbolic links and applies the `!**/node_modules/**` negations only to the RESULTS — so a self-referential symlink anywhere under the app root (pnpm's package layout inside `node_modules`, or any stray symlinked scratch dir) made the walk descend forever and throw before any negation could apply. The scan now enumerates source files itself with `followSymbolicLinks: false` and prunes heavy directories (`node_modules`, `.git`, `dist`, `build`, `.framework`, `.turbo`, `.next`, `.cache`, `.output`, `.voltro-*`, …) at the traversal level rather than by post-filtering — the crawler never descends into them, and no symlink is followed, so a cycle put there by anything is harmless. App source a codemod rewrites is always real files on disk, so files reachable only through a symlink are deliberately not scanned (and never rewritten). A codemod's explicit `scope` still narrows the file set exactly as before.
|
|
57
|
+
- **@voltro/plugin-auth-supabase** — `supabaseStrategy({ cookieName: 'sb-<ref>-auth-token' })` now reads `@supabase/ssr` session cookies. Those cookies do not hold a raw JWT — the SDK stores the GoTrue session as a JSON envelope, optionally `base64-`-encoded and split across `sb-<ref>-auth-token.0`, `.1`, … chunk cookies. The strategy previously handed that envelope straight to the JWT verifier, so every cookie-mode request failed with `malformed jwt` and fell back to anonymous (including `ctx.query` from a `type:'web'` loader, which forwards the browser cookie to the api). The strategy now unwraps the envelope — URL-decode, strip the `base64-` prefix and base64url-decode when present, concatenate chunks in order, then lift the inner `access_token` — before verification. The `Authorization: Bearer` path is unchanged (always a raw token). Hostile / malformed / oversized cookies resolve to anonymous (skip), never throw. Auth strategies belong on the `type:'api'` app, not the web app — the api verifies the forwarded cookie.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
42
61
|
## [0.8.0] — 2026-07-20
|
|
43
62
|
|
|
44
63
|
### ⚠ BREAKING
|
package/dist/jwt.d.ts
CHANGED
|
@@ -16,6 +16,28 @@ declare interface AuthStrategyInput {
|
|
|
16
16
|
readonly clientId: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A per-strategy transform from the request's COOKIE transport to the bearer
|
|
21
|
+
* token to verify. It receives `getCookie` — a reader that returns any cookie
|
|
22
|
+
* by exact name, URL-decoded exactly as `readCookie` does — plus the configured
|
|
23
|
+
* `cookieName`, and returns the JWT (or `undefined` when the cookie(s) carry no
|
|
24
|
+
* usable token).
|
|
25
|
+
*
|
|
26
|
+
* Why an accessor and not a single value: some SDKs don't store a raw JWT in
|
|
27
|
+
* one cookie. `@supabase/ssr`, for one, stores a JSON envelope that can be
|
|
28
|
+
* base64-wrapped AND split across `<name>.0`, `<name>.1`, … chunk cookies —
|
|
29
|
+
* unwrapping it means reading sibling cookies by name, not just the base one.
|
|
30
|
+
*
|
|
31
|
+
* The DEFAULT — used by every raw-JWT IdP (WorkOS / Kinde / Clerk / Auth0 /
|
|
32
|
+
* OIDC) — is to read the named cookie verbatim, i.e. `getCookie(cookieName)`,
|
|
33
|
+
* which keeps the raw-JWT cookie path byte-identical to a plain cookie read.
|
|
34
|
+
*
|
|
35
|
+
* SECURITY: the cookie is attacker-controlled. An implementation MUST NOT throw
|
|
36
|
+
* — a malformed / oversized / wrong-shape value must resolve to `undefined`
|
|
37
|
+
* (the strategy then returns `skip`), never an exception.
|
|
38
|
+
*/
|
|
39
|
+
export declare type CookieTokenExtractor = (getCookie: (name: string) => string | undefined, cookieName: string) => string | undefined;
|
|
40
|
+
|
|
19
41
|
/**
|
|
20
42
|
* Fetch (and per-URL cache) an OIDC discovery document — the
|
|
21
43
|
* `.well-known/openid-configuration` an IdP publishes so consumers can
|
|
@@ -28,9 +50,13 @@ declare interface AuthStrategyInput {
|
|
|
28
50
|
*/
|
|
29
51
|
export declare const discoverOidc: (url: string) => Promise<OidcDiscoveryDocument>;
|
|
30
52
|
|
|
31
|
-
/** Pull a JWT from either `Authorization: Bearer …`
|
|
32
|
-
*
|
|
33
|
-
|
|
53
|
+
/** Pull a JWT from either `Authorization: Bearer …` (always a raw token) or
|
|
54
|
+
* the named cookie. The optional `cookieToToken` transform unwraps a cookie
|
|
55
|
+
* transport that is NOT itself a raw JWT (e.g. Supabase's `@supabase/ssr`
|
|
56
|
+
* session envelope); it is applied to the COOKIE path ONLY — the Bearer header
|
|
57
|
+
* is a raw token for every IdP. Default: read the named cookie verbatim.
|
|
58
|
+
* Returns undefined when neither transport yields a token (strategy `skip`s). */
|
|
59
|
+
export declare const extractBearerOrCookie: (headers: Readonly<Record<string, string | undefined>>, cookieName: string | null, cookieToToken?: CookieTokenExtractor) => string | undefined;
|
|
34
60
|
|
|
35
61
|
/**
|
|
36
62
|
* JWKS-backed verification (asymmetric: ES256/RS256/PS256/EdDSA) — the
|
|
@@ -93,6 +119,16 @@ declare interface JwtBearerStrategyBase {
|
|
|
93
119
|
* handler-side scope gate then denies). Return `[]` to grant none.
|
|
94
120
|
*/
|
|
95
121
|
readonly scopesFromClaims?: (claims: Record<string, unknown>) => ReadonlyArray<string>;
|
|
122
|
+
/**
|
|
123
|
+
* Transform the request's cookie transport into the JWT to verify. Default:
|
|
124
|
+
* read the named cookie verbatim — the raw-JWT cookie every hosted IdP here
|
|
125
|
+
* uses (WorkOS / Kinde / Clerk / Auth0 / OIDC). A strategy whose cookie is
|
|
126
|
+
* NOT a raw JWT (Supabase's `@supabase/ssr` JSON envelope) supplies one to
|
|
127
|
+
* unwrap it. Applied to the cookie path ONLY; the Bearer header stays a raw
|
|
128
|
+
* token. See {@link CookieTokenExtractor} — it MUST NOT throw on hostile
|
|
129
|
+
* input (it resolves to `undefined` → `skip` instead).
|
|
130
|
+
*/
|
|
131
|
+
readonly cookieToToken?: CookieTokenExtractor;
|
|
96
132
|
}
|
|
97
133
|
|
|
98
134
|
/** JWT-based strategy config — verified EITHER via a JWKS endpoint
|
package/dist/jwt.js
CHANGED
|
@@ -78,22 +78,22 @@ var i = 3600 * 1e3, a = /* @__PURE__ */ new Map(), o = (e, n) => {
|
|
|
78
78
|
if (e instanceof n.JOSEAlgNotAllowed) return new c("jwt algorithm not allowed", "unsupported_algorithm");
|
|
79
79
|
let t = e?.message ?? String(e);
|
|
80
80
|
return /fetch|network|enotfound|econnrefused/i.test(t) ? new c(`jwks unreachable: ${t}`, "jwks_unreachable") : new c(`jwt verify failed: ${t}`, "other");
|
|
81
|
-
}, v = (t, n) => {
|
|
82
|
-
let
|
|
83
|
-
if (
|
|
84
|
-
let e =
|
|
81
|
+
}, v = (t, n, r) => {
|
|
82
|
+
let i = t.authorization ?? t.Authorization;
|
|
83
|
+
if (i?.startsWith("Bearer ")) {
|
|
84
|
+
let e = i.slice(7).trim();
|
|
85
85
|
if (e) return e;
|
|
86
86
|
}
|
|
87
87
|
if (n) {
|
|
88
|
-
let
|
|
89
|
-
if (
|
|
88
|
+
let i = (n) => e(t.cookie, n), a = r ? r(i, n) : i(n);
|
|
89
|
+
if (a) return a;
|
|
90
90
|
}
|
|
91
91
|
}, y = (e) => {
|
|
92
92
|
let t = e.cookieName === void 0 ? null : e.cookieName;
|
|
93
93
|
return {
|
|
94
94
|
id: e.id,
|
|
95
95
|
resolve: async (n) => {
|
|
96
|
-
let r = v(n.headers, t);
|
|
96
|
+
let r = v(n.headers, t, e.cookieToToken);
|
|
97
97
|
if (!r) return { kind: "skip" };
|
|
98
98
|
try {
|
|
99
99
|
let t = e.jwtSecret === void 0 ? await u(r, e.jwksUrl, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The Voltro wire + plugin contract — defineQuery/Mutation/Action/Stream, definePlugin, sessions / JWT / API-keys, and the RPC protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@effect/sql": "^0.51.1",
|
|
56
|
-
"@voltro/database": "0.
|
|
56
|
+
"@voltro/database": "0.9.0",
|
|
57
57
|
"jose": "^6.2.3"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|