Build Web Middleware
Instructions
Build the request middleware chain and cross-cutting infrastructure for the Go project. This skill produces the infrastructure that use case implementations depend on: authentication, authorization, CSRF protection, security headers, structured logging, and panic recovery.
Every route is registered on the one http.ServeMux in internal/web/routes.go, and the whole
mux is wrapped by the chain built there. Authentication fails closed: a route needs a signed-in
user unless its path is listed in auth.PublicPaths. Roles are not a path table — a
role-protected route is wrapped with auth.RequireRole on the line that registers it, so the
mux stays the only router.
Run this skill after the entity model and the database migration exist (so user/role tables are available) and before implementing use cases.
Step 0: Consult Documentation
Before writing any code, read the go directive in go.mod, then use the context7 MCP
server to look up, for that Go version:
http.CrossOriginProtection— constructor,Handler, trusted origins, and bypass patterns (Go 1.25+). Ifgo.moddeclares an older version, stop and ask the user to raise thegodirective — do not add a CSRF token library insteadServeMuxpatterns — method and wildcard syntax ("GET /items/{id}"),r.PathValue, precedence rules, andServeMux.Handler, which returns the pattern a request matcheslog/slog—JSONHandleroptions andslog.NewLogLoggerforhttp.Server.ErrorLog- The session library chosen in Step 3 — its middleware, store, and cookie options
Every API choice in the steps below must match the documentation for the installed versions — not hardcoded assumptions.
DO NOT
- Overwrite an existing
internal/web/routes.gochain without showing the user what will change and asking for confirmation - Hard-code secrets, tokens, or credentials (read them through
internal/config) - Add an auth or session library without user confirmation — ask which auth approach to use
- Register a route outside
internal/web/routes.go - Check roles inside handlers, or keep a path-to-roles table — wrap the route with
auth.RequireRolewhere it is registered - Add rate limiting inside the middleware chain (use a dedicated service or external provider)
- Put feature-specific logic in middleware — keep it generic and cross-cutting
- Skip reading the entity model — the middleware must align with the project's user/role structure
- Set security headers in more than one place (a reverse proxy and the app) — pick the app
- Log session IDs, cookies, tokens, secrets, passwords, or full
Authorizationheaders - Add a CSRF token library —
http.CrossOriginProtectioncovers it - Load htmx or any script from a CDN — it is embedded from
internal/web/static/so the CSP stays'self'
Nexa Rules Gate
Read and follow ${CLAUDE_PLUGIN_ROOT}/shared/readiness/NEXA_RULES_GATE.md.
Prerequisites
The following must exist before running this skill:
docs/requirements.md(from/requirements) — to identify security and auth NFRsdocs/entity_model.md(from/entity-model) — to understand user/role entitiesdb/migrations/with the user/role tables (from/db-migration)internal/config/config.goandinternal/app/app.go(from/setup-env-profiles)
If any prerequisite is missing, stop and tell the user which /command to run first.
Workflow
Step 1: Gather Context
- Read
docs/requirements.mdand extract:- Authentication requirements (login, session management, session lifetime)
- Authorization requirements (roles, permissions, RBAC rules)
- Security NFRs (CSRF, headers, HTTPS enforcement)
- Any explicit middleware-related requirements
- Read
docs/entity_model.mdand identify:- The User entity (or equivalent) and its fields
- Role/Permission entities and their relationship to User
- Session or Token entities if defined
- Read
db/migrations/and verify the user/role tables exist - Read
go.modfor existing auth libraries (github.com/alexedwards/scs,github.com/gorilla/sessions,github.com/coreos/go-oidc,github.com/golang-jwt/jwt, etc.)
Step 2: Retrofit Detection
Determine whether the skill runs in greenfield mode (no existing features) or retrofit mode (existing code that will be affected).
2a. Scan for Existing Code
Search the codebase for:
- Routes — every
Handle/HandleFuncregistration ininternal/web/routes.go(and any strayhttp.HandleFunc/ServeMuxelsewhere — flag those, all routes belong in routes.go) - Handlers —
internal/**/handler.go - htmx fragment endpoints — handlers rendering partials for
hx-get/hx-post - Existing middleware chain —
func(http.Handler) http.Handlerwrappers applied ininternal/web/routes.goorinternal/app/app.go - Ad-hoc auth checks — grep for
r.Cookie(,Header.Get("Authorization"),jwt.Parse, session managerGetcalls, or manual role checks in handlers and services
2b. If an Existing Middleware Chain Is Found
Show the user the current chain and ask:
An existing middleware chain was found. Choose how to proceed:
- Extend — keep existing middleware and add auth/security layers around it
- Replace — discard the current chain and build from scratch
- Abort — stop and let me review the existing chain first
Wait for the user to choose before proceeding.
2c. If Existing Features Found (Retrofit Mode)
If routes beyond GET /healthz and static assets exist, activate retrofit mode.
Impact analysis — map the route protection rules (from Step 4) against the existing routes:
| Route pattern | Handler | Current Auth | Will Become | Impact |
|---|---|---|---|---|
GET /dashboard |
internal/dashboard/handler.go |
None | Authenticated | Breaking — will redirect to login |
GET /users |
internal/user/handler.go |
Manual check | ADMIN role |
Review — has ad-hoc auth, may conflict |
GET /items/{id}/row (htmx) |
internal/item/handler.go |
None | Authenticated | Breaking — fragment will return HX-Redirect |
GET /about |
internal/page/handler.go |
None | Public | No impact |
Present this table to the user and ask:
Retrofit impact analysis:
[impact table]
Breaking changes — these routes currently work without auth and will start redirecting or returning 403 once the chain is applied.
Review items have existing ad-hoc auth that may conflict with or duplicate the new logic.
Options:
- Proceed — I'll adjust the rules to minimize breakage and generate a migration checklist
- Adjust rules — let me customize which routes stay public before proceeding
- Abort — let me review the existing code first
Wait for the user to choose before proceeding.
2d. Ad-hoc Auth Consolidation Plan
List each file with ad-hoc auth and its pattern:
File Current Pattern Recommendation internal/user/handler.goreads cookie + manual role check Remove — RequireAuthandRequireRolehandle itinternal/order/service.goif userID == ""guardKeep — services are also called from jobs/CLI that bypass HTTP internal/dashboard/handler.goredirect if no cookie Remove — the chain redirects
Important: only requests that enter through the mux pass the chain. Business logic in
internal/<feature>/service.go that is also reached from a background job, a CLI command, or a
message consumer does not pass through it — keep the explicit authorization check there.
Ownership checks ("this order belongs to this user") are business rules, not middleware, and
always stay in the service. Flag this distinction clearly in the plan.
2e. Generate Migration Checklist
If in retrofit mode, create a technical task following the standard TT-XXX naming convention:
- Read existing files in
docs/technical_tasks/to determine the next availableTT-XXXID (zero-padded, 3 digits — e.g. ifTT-003.mdis the highest, the next isTT-004) - Create
docs/technical_tasks/TT-XXX-middleware-retrofit.mdusing the template fromnexa-claude-core/skills/technical-task/templates/technical-task.mdwith:- Task ID:
TT-XXX(the assigned numeric ID) - Task Name: Middleware Retrofit — Consolidate Ad-hoc Auth
- Category: Cleanup
- Goal: Adapt existing routes and handlers to the new middleware chain, removing redundant ad-hoc auth checks and updating tests
- Status: Approved
- Acceptance Criteria: one checklist item per file that needs updating, grouped by change type:
- Remove redundant auth — handlers where the chain now does what the code did manually
- Wrap with RequireRole — routes whose handler checked a role by hand
- Keep explicit auth — services reachable outside HTTP, and ownership checks
- Update tests — tests that now need an authenticated session in their setup
- Review conflicts — files with auth logic that may conflict with the new behavior
- Affected Areas: every file identified in the retrofit analysis
- Dependencies: None
- Task ID:
Do not apply the migration changes in this skill. The checklist is implemented via
/implement TT-XXX-middleware-retrofit (using the assigned ID) as a follow-up step.
Step 3: Confirm Auth Strategy with User
If an auth library is already in go.mod, lead with it:
I found
<library>in go.mod. Should I build the middleware around it?If not, choose an alternative:
Otherwise present:
Authentication strategy — choose one:
- Server-side sessions in PostgreSQL —
github.com/alexedwards/scs/v2with itspgxstore. Opaque random session token in the cookie, session data in asessionstable, instant revocation (logout, role change), no secret to manage. Recommended for a server-rendered templ + htmx app.- Signed-cookie sessions (stdlib only) — session payload (user ID, roles, expiry) in a cookie signed with HMAC-SHA256 (
crypto/hmac) usingSESSION_SECRET. No dependency and no session table, but no server-side revocation before expiry.- External OpenID Connect provider —
github.com/coreos/go-oidc/v3+golang.org/x/oauth2for login, then option 1 or 2 to hold the local session.
Wait for the user to choose before proceeding. For option 1, the sessions table needs a
migration — tell the user to run /db-migration for it if it does not exist.
Step 4: Define Route Protection Rules
Ask the user to confirm or adjust the defaults:
Route protection rules (adjust as needed):
Path Rule Enforced by /(exact)Public auth.PublicPaths/healthzPublic auth.PublicPaths/static/Public auth.PublicPaths/login,/signupPublic, redirect if signed in auth.PublicPaths+auth.GuestOnlyat registration/auth/Public auth.PublicPaths/admin/Signed in + ADMINroleauth.RequireRole("ADMIN")at registrationeverything else Signed in (fail closed) auth.RequireAuthShould I proceed with these defaults, or do you want to customize them?
Incorporate the user's adjustments.
Step 5: Build the Infrastructure
Create the files below. Consult the context7 MCP server again if any API is unclear.
5a. internal/web/routes.go — the chain
NewHandler(deps Deps) http.Handler registers every route on one http.ServeMux and returns it
wrapped in the chain. Every caller that needs the application's HTTP handler — internal/app,
/integration-test, /playwright-test — builds it through this one signature:
type Deps struct {
Config config.Config
Pool *pgxpool.Pool
}
func NewHandler(deps Deps) http.Handler {
mux := http.NewServeMux()
queries := db.New(deps.Pool)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.Handle("GET /static/", http.FileServerFS(staticFS))
// Guest-only and role-protected routes are wrapped where they are registered:
// mux.Handle("GET /login", auth.GuestOnly(http.HandlerFunc(login.Show)))
// mux.Handle("GET /admin/", auth.RequireRole("ADMIN")(admin))
// ... feature routes using queries ...
var h http.Handler = mux
h = auth.RequireAuth(h)
h = sessionLoader(h) // the chosen library's middleware, e.g. scs LoadAndSave
h = http.NewCrossOriginProtection().Handler(h)
h = SecurityHeaders(deps.Config.Env == "production")(h)
h = Recover(h)
h = LogRequests(mux)(h)
return h
}
Static assets live in internal/web/static/ and are embedded next to the router in
internal/web/static.go (//go:embed cannot reach a parent directory, so the assets must sit
under the embedding package):
//go:embed static
var staticFS embed.FS
The resulting chain, outermost first:
- Request log (
LogRequests,internal/web/middleware.go) — outermost, so it records every response: a recovered panic's 500, a CSRF 403, an auth redirect - Recover (
internal/web/middleware.go) - Security headers (
SecurityHeaders,internal/web/headers.go) - CSRF —
http.NewCrossOriginProtection().Handler(...). It rejects cross-origin unsafe requests usingSec-Fetch-Site/Origin, so templ forms and htmx requests need no token - Session loading — the chosen library's middleware (e.g. scs
LoadAndSave) - Auth (
auth.RequireAuth) - the mux, where
auth.GuestOnlyandauth.RequireRolewrap individual routes
Update internal/app/app.go so the server's Handler is
web.NewHandler(web.Deps{Config: cfg, Pool: pool}) instead of the placeholder mux.
auth.RequireAuth follows this composition pattern:
- Load the session once (
loadSession). On an error (store unreachable, decode or signature failure) log at error and treat the request as anonymous - A valid session → store it in the request context, call next
- Anonymous on a path in
PublicPaths→ log at debug, call next - Anonymous on any other path → log a warning with the path, redirect to login — never call next
auth.GuestOnly(next): a signed-in user is redirected to DefaultLoginRedirect (log at debug);
anyone else reaches next.
auth.RequireRole(roles ...string) func(http.Handler) http.Handler: if the context session
lacks every listed role, log a warning with path, user ID, required and actual roles, and
respond 403. With no session at all it responds like step 4 of RequireAuth — it fails closed
even if the route was mistakenly made public.
Redirects and htmx. A 303 See Other to /login on an htmx request would swap the login
page into a fragment. When the request has HX-Request: true, respond 401 with the header
HX-Redirect: /login instead (for a failed role check, respond 403 with an error fragment).
Plain requests get http.Redirect(w, r, LoginPath, http.StatusSeeOther).
5b. internal/auth/session.go
type Session struct { UserID string; Roles []string; ExpiresAt time.Time }FromContext(ctx context.Context) (*Session, bool)— the sessionRequireAuthstored;falsefor an anonymous request. Handlers use only this — they never read the cookie or the store again(s *Session) HasRole(roles ...string) bool— nil-safe; true if the session holds any of the rolesloadSession(r *http.Request) (*Session, error)— unexported. Returnsnil, nilwhen there is no session or it has expired, and an error for a store, decode, or signature failure. It never panics on a garbage cookie- Login/logout helpers the chosen library needs (e.g.
Login(ctx, userID, roles)that renews the session token to prevent fixation,Logout(ctx))
5c. internal/auth/rules.go
Route and auth constants:
PublicPaths—[]stringof paths that need no session. An entry ending in/matches that prefix; any other entry matches the path exactly. It is a slice, checked in order, so the result is deterministicDefaultLoginRedirect— where to go after login (e.g./dashboard)LoginPath— the login page path
5d. internal/web/headers.go
SecurityHeaders(production bool) func(http.Handler) http.Handler sets, on every response:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'— with a comment that it is a baseline to tune per projectX-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=()Strict-Transport-Security: max-age=31536000; includeSubDomains— only whenproduction(cfg.Env == "production")
htmx under this CSP. htmx is vendored at internal/web/static/htmx.min.js. Add to the
shared layout (internal/web/layout/layout.templ, package layout — feature views import it,
and internal/web imports the features, so the layout cannot live in package web):
<meta name="htmx-config" content='{"includeIndicatorStyles":false,"allowEval":false,"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"422","swap":true},{"code":"[45]..","swap":false,"error":true}]}'>
responseHandling makes htmx swap 422 responses. htmx 2 does not swap any 4xx by default, so
without this entry a form re-rendered with validation errors (the /implement pattern) never
appears on the page. Confirm the setting against the installed htmx version with context7.
includeIndicatorStyles:false stops htmx injecting an inline <style> (blocked by
style-src 'self') — put the .htmx-indicator rules in internal/web/static/app.css.
allowEval:false matches script-src 'self': do not use hx-on:* attributes or js: values;
write behaviour in a static .js file.
5e. Logging — log/slog
There is no custom logger. internal/app/app.go already sets a JSON slog default, with debug
enabled outside production. Auth middleware logs with slog.DebugContext / WarnContext /
ErrorContext and these attributes on every record: component ("auth"), method, path.
Events:
| Event | Level | Additional attributes |
|---|---|---|
| Public path — anonymous | DEBUG |
— |
| Guest-only — redirected | DEBUG |
user_id, redirect_to |
| Session missing | WARN |
redirect_to |
| Session expired | WARN |
user_id (if known), expired_at |
| Session malformed | WARN |
error (the decode error message, never the value) |
| Signature invalid (signed cookies) | ERROR |
error |
| Role check failed | WARN |
user_id, required_roles, actual_roles |
| Session store error | ERROR |
error |
| Authenticated — allowed | DEBUG |
user_id |
LogRequests(mux *http.ServeMux) func(http.Handler) http.Handler in internal/web/middleware.go
logs one INFO record per request: method, path, pattern, status, duration_ms. Take
pattern from _, pattern := mux.Handler(r), not from r.Pattern: the mux sets r.Pattern on
the request it receives, and session loading passes a copy (r.WithContext), so an outer
middleware always reads it empty.
Security rule: never log session IDs, cookie values, tokens, secrets, or full
Authorization headers. Log user IDs and error messages only.
5f. Panic Recovery / Server Error Tracking — internal/web/middleware.go
Recover(next http.Handler) http.Handler:
defer+recover(); re-panichttp.ErrAbortHandler(the stdlib uses it to abort a response)- Logs at
ERRORwitherror,stack(debug.Stack()),method,path - Responds
500with a generic message (no stack trace to the client) —LogRequests, outside it, records that 500 - A comment marks where to forward errors to an external provider (e.g. Sentry, Datadog)
Handlers that return an error they cannot handle log it the same way and respond 500 — they do
not panic on purpose. http.Server.ErrorLog is already routed to slog by internal/app/app.go.
Step 6: Install Dependencies
Install the packages for the chosen strategy (go get ..., then go mod tidy). Always ask the
user for confirmation first. The stdlib signed-cookie strategy needs none.
Step 7: Update Environment Variables
Check .env, .env.dev, and .env.example (if present) and add the variables the chosen
strategy needs:
SESSION_SECRET— signed-cookie sessions only: at least 32 random bytes (openssl rand -base64 32). Add aSessionSecretfield and a missing-value check inConfig.Validate. The scs strategy stores an opaque random token and needs no secretBASE_URL— used for absolute redirects and OIDC callbacks- Provider-specific variables for OIDC (
OIDC_ISSUER_URL,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET)
Add each new variable as a field in internal/config/config.go. Comment out variables that need
user-provided values with a # TODO: prefix. If .env.example exists, add the names there
without values.
Step 8: Write Unit Tests
Test pure functions with clear inputs and outputs, using stdlib testing and
net/http/httptest, table-driven.
| Source File | Test File |
|---|---|
internal/web/headers.go |
internal/web/headers_test.go |
internal/auth/session.go |
internal/auth/session_test.go |
Do not create a test for rules.go — it is static data; a test that the slice contains
specific values breaks on every intentional change and catches no bug.
Do not unit-test the assembled chain in routes.go or slog output — the routing behaviour
is validated end to end by /playwright-test, and slog is the standard library.
8a. headers_test.go
Wrap a no-op handler with SecurityHeaders, serve with httptest.NewRecorder, and assert:
- All required headers are present with the expected values
Strict-Transport-Securityis present whenproductionis trueStrict-Transport-Securityis absent whenproductionis false
8b. session_test.go
The setup depends on the Step 3 strategy (for scs, wrap the test handler in LoadAndSave with
an in-memory store; for signed cookies, sign test payloads with a test secret). Test:
loadSessionreturns the session for a valid cookieloadSessionreturnsnil, nilwhen no cookie is present and for an expired sessionloadSessionreturnsniland an error for a tampered signature / unknown session tokenloadSessiondoes not panic on a garbage cookie valueFromContextreturns whatRequireAuthstored, andfalsefor an anonymous requestHasRole— true when any required role is held, false otherwise, false on anilsession
Step 9: Verify
- Build:
go tool templ generate && sqlc generate && go build ./... && go vet ./... - Confirm
internal/app/app.goservesweb.NewHandler(web.Deps{Config: cfg, Pool: pool})and thathttp.NewCrossOriginProtectionis in the chain - Confirm
/static/is public, no route is registered outsideinternal/web/routes.go, and no handler checks a role by hand - Run all existing tests:
- Unit tests —
go test ./...(includes the Step 8 tests) - Integration tests — if any
*_integration_test.goexists:go test -tags=integration ./... - End-to-end tests — if
e2e/holds*_test.gofiles:go test -tags=e2e ./e2e/... - If any test fails, fix it before proceeding. In retrofit mode, failures likely mark routes in the migration checklist — note them in the summary, never skip or suppress the tests
- Unit tests —
- Describe a manual smoke test to the user (with
go run ./cmd/devrunning):curl -i localhost:8080/dashboard→303to/logincurl -i -H 'HX-Request: true' localhost:8080/dashboard→401withHX-Redirect: /logincurl -i localhost:8080/→200with the security headers presentcurl -i -X POST -H 'Sec-Fetch-Site: cross-site' localhost:8080/login→403
Step 10: Update CLAUDE.md
Append a ## Web Middleware section to the target project's CLAUDE.md.
- If
CLAUDE.mddoes not exist, create it - If a
## Web Middlewaresection already exists (check for<!-- NEXA_WEB_MIDDLEWARE_CONFIGURED -->), ask the user whether to overwrite or skip - Append the following section (fill in the actual values from the setup):
## Web Middleware
<!-- NEXA_WEB_MIDDLEWARE_CONFIGURED -->
- Auth strategy: [chosen strategy, e.g. scs server-side sessions in PostgreSQL]
- Chain (outermost first), built by `web.NewHandler` in `internal/web/routes.go`: LogRequests → Recover → SecurityHeaders → CrossOriginProtection → session load → auth.RequireAuth → mux
- Logging: `log/slog` JSON (configured in `internal/app/app.go`), `component=auth` for auth events
### Auth (`internal/auth/`)
- `session.go` — `FromContext(ctx)`, `(*Session).HasRole(roles...)`, login/logout helpers
- `rules.go` — `PublicPaths`, `DefaultLoginRedirect`, `LoginPath`
- `middleware.go` — `RequireAuth` (whole mux, fail closed), `GuestOnly` and `RequireRole` (per route)
### Web (`internal/web/`)
- `headers.go` — `SecurityHeaders(production)` (CSP, X-Frame-Options, HSTS in production, etc.)
- `middleware.go` — `LogRequests(mux)`, `Recover`
- `static/` — embedded assets (htmx, app.css), served at `/static/`
### Route protection rules
| Path | Rule |
|---------------------|-----------------------------------|
| [actual paths and rules from the setup] |
### Middleware conventions for implementation
- Register every route in `internal/web/routes.go`; signed-in by default — add a path to `auth.PublicPaths` deliberately
- Role-protected routes: `mux.Handle(pattern, auth.RequireRole("ROLE")(h))` on the registration line; never check roles in a handler
- Read the user with `auth.FromContext(r.Context())`; ownership checks and services reachable outside HTTP keep explicit authorization
- htmx: unauthenticated htmx requests get `401` + `HX-Redirect`; do not use `hx-on:*` or `js:` values (CSP forbids eval)
- CSRF is handled by `http.CrossOriginProtection` — no tokens in forms
- Security headers are set only in `web.SecurityHeaders`
- The `/implement` skill can assume auth infrastructure exists
Do not remove or modify any other content in CLAUDE.md.
Step 11: Summary
## Web Middleware Created
### Go Version & Conventions
- Go version: <go.mod go directive>
- Chain: internal/web/routes.go (web.NewHandler)
- CSRF: http.CrossOriginProtection
### Auth Strategy
<chosen strategy>
### Files Created/Updated
| File | Purpose |
|-----------------------------------|-------------------------------------------|
| internal/web/routes.go | ServeMux, route registration, chain |
| internal/web/static.go | Embedded static assets |
| internal/web/middleware.go | Request logging and panic recovery |
| internal/web/headers.go | Security headers (including CSP) |
| internal/app/app.go | Serves web.NewHandler |
| internal/auth/session.go | Session loading and context helpers |
| internal/auth/rules.go | Public paths and auth constants |
| internal/auth/middleware.go | RequireAuth, GuestOnly, RequireRole |
| internal/web/headers_test.go | Unit tests for security headers |
| internal/auth/session_test.go | Unit tests for session helpers |
### Route Protection
| Path | Rule |
|---------------|-----------------------------------|
| ... | ... |
### Environment Variables
| Variable | Status |
|---------------|-----------------------------------|
| ... | Added / Already exists / TODO |
### Error Handling & Observability
- The chain fails closed — session store errors make the request anonymous, and protected paths redirect to login
- Auth decisions produce structured slog JSON records with method, path, and error context
- Debug records are suppressed in production
- Panics are recovered, logged with stack and request context, and answered with 500
### Test Results
- Unit tests: X passed, Y failed
- Integration tests: X passed, Y failed (or N/A if none exist)
- End-to-end tests: X passed, Y failed (or N/A if no e2e tests exist)
- [If any failures, list them and explain whether they are pre-existing or caused by the changes]
### What This Enables for Feature Implementation
- `auth.FromContext(r.Context())` returns the signed-in user in every handler
- Protected routes redirect to login (htmx requests get HX-Redirect)
- Role-based routes enforce access with `auth.RequireRole` at registration
- CSRF protection and security headers apply to every response
- The `/implement` skill can assume auth infrastructure exists
### Retrofit (if applicable)
- Mode: Greenfield / Retrofit
- Existing routes affected: N
- Breaking changes: N files
- Ad-hoc auth to consolidate: N files
- Migration checklist: docs/technical_tasks/TT-XXX-middleware-retrofit.md
### Next Steps
- Implement the login/signup/logout handlers as a use case with `/implement`
- If in retrofit mode: run `/implement TT-XXX-middleware-retrofit` to adapt existing code
- Fill in `# TODO:` env vars (OIDC, production secrets)
- Tune the Content-Security-Policy for the project
- Run `/implement` for your next use case