Router: Authorization
Route protection is one server-side guard, not a middleware matcher you wire up per route. The guard runs in the adapter’s core() request handler before any HTML flushes (pre-stream), so a protected page never leaks its content — there’s no client-side gate to bypass, no useEffect redirect, no flash of protected UI. A page opts in by declaring restricted; the guard resolves the current session, evaluates the declaration, and either renders the page or returns the matching status-code page.
Declaring protection — two surfaces
Section titled “Declaring protection — two surfaces”1. Per-page restricted — the page declares it itself. In a .phaze page a bare restricted in the ---page fence compiles to true; value forms pass through. A .tsx page writes export const restricted:
---pagerestricted // bare → true (any signed-in session)restricted: 'admin' // requires the 'admin' role---export const restricted = ['admin', 'billing'] // holds any one of these roles2. +name/ protected folders — a folder prefixed with + flags its whole subtree restricted (any session) with no per-page export. The + strips from the URL (+admin/ → /admin) and protection inherits to every descendant:
src/pages/└── +admin/ ← whole subtree restricted; URL is /admin (not /+admin) ├── index.phaze → /admin (inherits — any session) └── super/page.phaze → /admin/super (inherits; can narrow further — below)A per-page restricted wins over the folder flag (the guard reads module.restricted ?? route.restricted), so +admin/super/page.phaze can declare restricted: 'admin' to narrow that one page to a role while the rest of +admin/ stays any-session. Nesting two + folders is a build error — protection is a single subtree flag, not a stack.
The restricted value
Section titled “The restricted value”| Value | Meaning | Signed-in but unqualified → |
|---|---|---|
true (or bare restricted) | any authenticated session | — |
false | explicit public opt-out — wins even inside a +name/ subtree (the one way to carve a public page out of a protected folder) | — |
'admin' | requires that role | 403 |
['admin', 'editor'] | holds any one of these roles | 403 |
(session) => boolean | predicate — all-of, step-up, any custom rule; receives the full session | 403 |
{ roles, notFound: true } | options — a roles list + notFound to return 404 instead of 403 (hide the route’s existence) | 403 / 404 |
false is a true opt-out, not just “skipped”: the guard short-circuits on it, and module.restricted ?? route.restricted keeps false (nullish-coalescing only falls through on undefined), so a restricted = false page is public even under a +protected/ ancestor.
Sessions
Section titled “Sessions”The guard resolves the session through a consumer-provided resolver — cloudflare({ session }) or a src/session.ts convention — shaped like NextAuth’s getSessionAndUser (KV-backed); the guard needs only session.user.roles for RBAC. With no resolver registered, every request to a restricted page is treated as unauthenticated (the 401 flow) — protection fails closed.
Outcomes — 401 / 403 / 404
Section titled “Outcomes — 401 / 403 / 404”The guard’s verdict maps to a status-code page, rendered in place with the real status:
| Verdict | Outcome |
|---|---|
| no session | 401 — signed /login redirect, or 401.phaze in place under isomorphicAuth (below) |
| signed in, role/rule fails | 403 in place (403.phaze) — or 404 (notFound) |
| passes | the page renders normally |
403 / 404 always render in place — same URL, real status, no Location header. Re-authenticating never clears a 403: it’s an authorization gap, not a sign-in one.
401 — signed redirect, or in-place (isomorphicAuth)
Section titled “401 — signed redirect, or in-place (isomorphicAuth)”The unauthenticated (401) case is the one configurable outcome:
cloudflare({ isomorphicAuth }) | 401 behaviour |
|---|---|
false (default) | Ed25519-signed 302 redirect to /login (the loginPath), shaped /login?to=<return-path>&exp=<ms>&sig=<base64>. The guard signs to|exp with PHAZE_SIGN_KEY; the phaze-router verifies that signature before following, so a forged/tampered to (MITM, poisoned cache, an open-redirect attempt) is rejected. exp bounds replay of a stale link. Bookmarkable login flow. |
true | renders 401.{tsx,phaze} in place at the protected URL — no redirect, no Location, no return-URL. A successful sign-in re-gates the same page to its content with no navigation. |
// vite.config.ts — default is the signed /login redirect; flip to render 401 in placecloudflare({ pages: 'src/pages', isomorphicAuth: true, loginPath: '/login' })The signed redirect’s validity window is authRedirectTtl (seconds, default minutes(5)) — how long a signed /login?to=… link stays honorable before the router rejects it as stale:
import { minutes } from '@madenowhere/phaze/time'cloudflare({ pages: 'src/pages', authRedirectTtl: minutes(2) })The signing scheme, the non-bypassable pre-stream guarantee, and the contrast with Next’s middleware-matcher CVEs: Security → Secure by design.