## Request path

## Callstack walkthrough

1
src/app/providers/AuthProvider.tsx :22-48

On mount, the React provider issues a `GET /api/session` with `credentials: 'include'` so the `fw_sid` cookie rides along. The response either hydrates `currentUser` into context or leaves it `null`, which the router treats as "show the sign-in screen".

show source

    // src/app/providers/AuthProvider.tsx
    export function AuthProvider({ children }: Props) {
      const [user, setUser] = useState<User | null>(null);

      useEffect(() => {
        fetch('/api/session', { credentials: 'include' })
          .then(r => r.ok ? r.json() : null)
          .then(setUser);
      }, []);

      return <AuthCtx.Provider value={{ user }}>{children}</AuthCtx.Provider>;
    }

2
src/server/routes/session.ts :9-27

The route itself is thin: it just returns whatever `req.ctx.session` the middleware attached. If the middleware short-circuited with a 401, this handler never runs — so there's no auth logic duplicated here.

show source

    // src/server/routes/session.ts
    router.get('/session', verifyToken, (req, res) => {
      const { session } = req.ctx;
      res.json({
        id:    session.userId,
        email: session.email,
        role:  session.role,
        exp:   session.expiresAt,
      });
    });

3
src/middleware/auth.ts :14-31

This is the trust boundary. `verifyToken` reads the signed `fw_sid` cookie, asks `SessionStore` to resolve it, and either populates `req.ctx.session` or responds 401. Every protected route in the app is mounted behind this function, so changing its behaviour changes auth globally.

show source

    // src/middleware/auth.ts
    export async function verifyToken(req, res, next) {
      const raw = req.signedCookies['fw_sid'];
      if (!raw) return res.status(401).end();

      const session = await SessionStore.get(raw);
      if (!session || session.expiresAt < Date.now()) {
        return res.status(401).end();
      }

      req.ctx = { session };
      next();
    }

4
src/lib/sessionStore.ts :8-52

`SessionStore` is a small read-through cache: it checks an in-process LRU first, then falls back to Postgres. Writes (`create`, `revoke`) always go straight to the DB and invalidate the cache entry so other workers don't serve a stale session.

show source

    // src/lib/sessionStore.ts
    const cache = new LRU<string, Session>({ max: 5000, ttl: 60_000 });

    export const SessionStore = {
      async get(id: string) {
        const hit = cache.get(id);
        if (hit) return hit;
        const row = await db.one(SELECT_SESSION, [id]);
        if (row) cache.set(id, row);
        return row ?? null;
      },
      /* create, revoke, touch ... */
    };

5
db/migrations/004_sessions.sql :1-18

The `sessions` table is keyed on a random 32-byte id (the cookie value) with a covering index on `user_id` for "sign out everywhere". Expiry is enforced both here (`expires_at`) and again in the middleware as defence in depth.

show source

    -- db/migrations/004_sessions.sql
    create table sessions (
      id          text primary key,
      user_id     uuid not null references users(id),
      created_at  timestamptz default now(),
      expires_at  timestamptz not null,
      ip          inet,
      user_agent  text
    );
    create index sessions_user_id_idx on sessions(user_id);

### Key files

- src/middleware/auth.ts Single entry point for request authentication.
- src/lib/sessionStore.ts LRU + Postgres session lookup; only DB caller.
- src/server/routes/session.ts Returns the current session to the client.
- src/server/routes/login.ts Exchanges credentials for a cookie via SessionStore.create.
- src/app/providers/AuthProvider.tsx Client-side context that mirrors the server session.
- db/migrations/004_sessions.sql Schema for the sessions table and indexes.

### Gotchas

- The LRU in `SessionStore` is per-process. Revoking a session only clears the local cache — other workers may serve it for up to 60s until their TTL lapses.
- `verifyToken` compares `expiresAt` against `Date.now()`, but the column is `timestamptz`. The driver returns a `Date`, so the comparison works, but don't refactor it to a raw string without adjusting the check.