Authorization middleware with explicit public allowlist (#10) #36

Merged
james merged 1 commit from 10-authorization-middleware into main 2026-06-13 14:49:38 +00:00
Owner

Closes #10.

Summary

  • middleware.ts at the project root — rejects every request without a session, except routes in the public allowlist.
  • lib/auth/public-routes.ts — the single source of truth for what's reachable without auth. /api/health is the only exact public route; /api/auth/* is pre-registered for #12's OAuth callbacks. Adding a new public route is a one-file change that has to go through PR review.
  • lib/auth/session.ts — placeholder session check (presence of carol_session cookie). Real validation lands with #11; the function is the only place that needs to change at that point.
  • 12 new tests in tests/middleware.test.ts covering both the policy (middleware() returns 401 / passes through) and the allowlist matcher (isPublicRoute exact + prefix semantics).

Two stack choices worth flagging

  • Node runtime for middleware, not edge. Adding middleware.ts triggers Next.js to compile for the edge runtime, which can't resolve fs/path that pg uses transitively via instrumentation.ts → db. Carol is a self-hosted Node service — edge runtime buys nothing for a single-instance deployment. Enabled experimental.nodeMiddleware: true in next.config.mjs and runtime: "nodejs" in the middleware config. Experimental flag, but Next.js's intent is to graduate it.
  • Vitest @/* path alias. New tests import from @/lib/auth/...; vitest doesn't read tsconfig paths by default, so added a matching resolve.alias in vitest.config.ts. Existing tests using relative imports continue to work.

End-to-end verification (production standalone server)

Request Result Expected
GET /api/health 200 200
GET / (no session) 401 {"error":"Unauthorized"} 401
GET / (carol_session=...) 200 not 401
GET /api/some-thing (no session) 401 401
GET /api/auth/callback/github (no session) 404 (route absent; middleware passed it through) not 401

Out of scope

UI-friendly redirect-to-login. There is no sign-in page until #11, so a redirect target doesn't exist. The middleware returns JSON 401 for both API and UI requests today; swapping in a redirect is a one-line change when the page arrives.

Test plan

  • npm run lint / typecheck / build — clean
  • npm test — 25 pass, 5 skip (the Postgres-skip is unrelated to this PR)
  • End-to-end against .next/standalone/server.js — every row in the table above
  • CI matrix (will run on PR open)
Closes #10. ## Summary - **`middleware.ts`** at the project root — rejects every request without a session, except routes in the public allowlist. - **`lib/auth/public-routes.ts`** — the single source of truth for what's reachable without auth. `/api/health` is the only exact public route; `/api/auth/*` is pre-registered for #12's OAuth callbacks. Adding a new public route is a one-file change that has to go through PR review. - **`lib/auth/session.ts`** — placeholder session check (presence of `carol_session` cookie). Real validation lands with #11; the function is the only place that needs to change at that point. - **12 new tests** in `tests/middleware.test.ts` covering both the policy (`middleware()` returns 401 / passes through) and the allowlist matcher (`isPublicRoute` exact + prefix semantics). ## Two stack choices worth flagging - **Node runtime for middleware, not edge.** Adding `middleware.ts` triggers Next.js to compile for the edge runtime, which can't resolve `fs`/`path` that `pg` uses transitively via `instrumentation.ts → db`. Carol is a self-hosted Node service — edge runtime buys nothing for a single-instance deployment. Enabled `experimental.nodeMiddleware: true` in `next.config.mjs` and `runtime: "nodejs"` in the middleware config. Experimental flag, but Next.js's intent is to graduate it. - **Vitest `@/*` path alias.** New tests import from `@/lib/auth/...`; vitest doesn't read tsconfig paths by default, so added a matching `resolve.alias` in `vitest.config.ts`. Existing tests using relative imports continue to work. ## End-to-end verification (production standalone server) | Request | Result | Expected | | -------------------------------------------- | ------ | -------- | | `GET /api/health` | 200 | 200 | | `GET /` (no session) | 401 `{"error":"Unauthorized"}` | 401 | | `GET /` (`carol_session=...`) | 200 | not 401 | | `GET /api/some-thing` (no session) | 401 | 401 | | `GET /api/auth/callback/github` (no session) | 404 (route absent; middleware passed it through) | not 401 | ## Out of scope UI-friendly redirect-to-login. There is no sign-in page until #11, so a redirect target doesn't exist. The middleware returns JSON 401 for both API and UI requests today; swapping in a redirect is a one-line change when the page arrives. ## Test plan - [x] `npm run lint` / `typecheck` / `build` — clean - [x] `npm test` — 25 pass, 5 skip (the Postgres-skip is unrelated to this PR) - [x] End-to-end against `.next/standalone/server.js` — every row in the table above - [ ] CI matrix (will run on PR open)
Authorization middleware with explicit public allowlist (#10)
All checks were successful
PR / Lint (pull_request) Successful in 27s
PR / Typecheck (pull_request) Successful in 30s
PR / Test (postgres) (pull_request) Successful in 31s
PR / Test (sqlite) (pull_request) Successful in 50s
PR / Build (pull_request) Successful in 55s
9bfee3e20a
Centralises the auth policy: every route requires a valid session by
default; only routes listed in lib/auth/public-routes.ts go through
unauthenticated. Adding a new public route is a single-file change
to that list — the PR description has to justify why exposing it is
safe.

The session check itself (lib/auth/session.ts) is a placeholder
until #11 lands: today it only checks for a `carol_session` cookie's
presence. The function exists so the middleware policy can be
expressed and tested now, with one obvious place to swap in real
session-store lookup once #11 defines the session shape.

Runtime: Node, not edge. Carol is a self-hosted Node service. Edge
middleware is the Next.js default but it can't import the DB
abstraction transitively (fs/path are unavailable), and we get
nothing from edge bundling for a single-instance deployment.
Enabling experimental.nodeMiddleware in next.config.mjs and adding
runtime: "nodejs" to the middleware config keeps the build green
once instrumentation.ts has to compile against the edge bundle.

Tests cover the policy directly: /api/health and /api/auth/* pass
through; arbitrary new /api/* routes and UI routes are denied with
401 unless a session cookie is present. End-to-end against the
production standalone server agrees:

  /api/health            → 200
  /                       → 401 {"error":"Unauthorized"}
  / with carol_session    → 200
  /api/something          → 401
  /api/auth/callback/...  → 404 (route absent; middleware let it through)

Out of scope for this ticket: UI redirect-to-login. There is no
sign-in page until #11, so a redirect target doesn't exist. The
middleware returns JSON 401 for both API and UI requests for now;
swapping in a redirect is a small change in one place when the page
arrives.

Vitest needed the same `@/*` → `./*` path alias that tsconfig
defines (the new tests import from `@/lib/auth/...`); added in
vitest.config.ts.

Closes #10

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james merged commit a01ce50cba into main 2026-06-13 14:49:38 +00:00
Sign in to join this conversation.
No description provided.