Identity behind a stable seam — a session shape where signed-out is a state rather than a 401, one guard, and gates that are UX and never security.
How identity moves through the app. A cross-cutting law like data.md, and deliberately NOT a provider choice: the template ships a mock identity provider behind a stable seam, and a real project swaps the provider without touching anything that consumes the seam. The living example is /auth/protected (guarded) + /login.
01
Legislate the seam, not the provider. Auth stacks (NextAuth, Clerk, 自研 JWT, enterprise SSO) differ wildly behind the line and identically in front of it: a session shape, a cookie, a guard, a gate. Those four are pinned here; the mock behind /api/auth is scaffolding a real project deletes (src/app/api/auth/users.ts — that one file IS the provider).
02
src/app/api/auth/contract.ts, per the data law: zod schemas + keys, one module both sides import. User { id, name, email, role }, SessionResponse { user: User | null } — signed-out is a state, not an error: /api/auth/me returns { user: null } with a 200, so session reads never trip retries or error boundaries.
yb-session, httpOnly + sameSite=lax + path=/. httpOnly means the client NEVER reads the cookie — /api/auth/me is its only window into the session. The mock stores a bare user id; a real adapter stores a signed/encrypted token and verifies it. That value is the only thing that changes.
src/middleware.ts redirects unauthenticated hits on guarded routes to /login?next=<path>, and signed-in hits on /login back in. In this template only /auth/protected is guarded so the demo site stays browsable; a real app widens config.matcher — one line — and guards by default, opting public routes out rather than private routes in.
Roles are ranked: viewer < member < admin (ROLE_RANK). A gate names the MINIMUM: useCan("member") / <Can min="member" fallback={…}>. Gates suspend via useSession, so they live inside an AsyncBoundary.
03
useSession() / useSignIn() / useSignOut() (src/components/auth/use-session.ts) ride the data-law pipeline: sign-in/out toast, failures carry Retry, the session key invalidates. Navigation after either is the call site's job (mutate(vars, { onSuccess: () => router.push(…) })).(app) group ((auth)/login) — no shell, chromeless survival kit (logo home link + theme toggle).04
Role gates are UX, not security. <Can> decides what renders; the server decides what's allowed — every real API verifies the session and the role again. Corollaries: never trust next redirects beyond same-origin paths (the middleware checks startsWith("/")); never put secrets in client-readable storage; the middleware's cookie-presence check is a mock convenience — real adapters verify the token there.
05
users.ts + the three route handlers with the provider's login/logout/session endpoints (or its SDK routes)./api/auth/me returning SessionResponse — adapt the provider's session into the contract shape.useSession / Can / SESSION_COOKIE stands.06
/me for "signed out" (breaks retries and boundaries).<Can> as authorization for a server effect.next param redirect.