# Phase 79 — API tokens: admin-issued access to the app (only shared chats stay open) **Source:** `TODO.md` L5 — "Add api tokens that the admin can generate and hand out so people can log in to use the app. The only thing that should be accessible without an API token is shared chats. The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it." **Story:** n/a (TODO-derived — extends the phase-16 single-admin auth, `.agents/user_stories/admin-auth.md`) **Context:** `app/core/auth.py` (the SessionMiddleware cookie session, `require_admin`, `sign_in`/`sign_out`, `ADMIN_SESSION_KEY`), `app/api/auth.py` (`/api/login`, `/api/logout`, `/api/whoami` — `WhoamiResponse{authenticated, role: "admin"|"anonymous"}`), `app/models.py` (SQLAlchemy 2.0 mapped-column models — `SavedChat` is the last one) + `alembic/versions/` (latest is `0011_doc_drafts.py` — the format to mirror), `app/api/chat.py` (`POST /api/chat` — public today), `app/api/suggestions.py` (public today), `app/api/docs.py` (`GET /api/documents/content` — the phase-16 soft rule: deliberately public), `app/schemas.py` (`LoginRequest`, `WhoamiResponse`, …), `frontend/assets/header.js` (the single `/api/whoami` call site — `fetchIsAdmin()` returns `authenticated === true`; the admin-link reveal; the sign-out binding), `frontend/index.html` (the shell — `#app-nav`, the `#view-*` sections, the sign-in/out links, `#main`), `frontend/document.html` + `frontend/assets/document.js` (the document viewer — `fetchIsAdmin` gates the admin-only edit affordance; the viewer itself is public today), `tests/e2e/auth_helpers.py` (the real-form `login` helper), `tests/integration/test_auth_api.py` (pins the phase-16 contract — "viewer stays public (soft rule) and `POST /api/chat` still streams" — that soft rule is SUPERSEDED by this phase). ## Objective The admin can generate named API tokens and hand them out; a token holder signs in at the in-app gate and uses the app — chat, suggestion chips, cited documents. The ONLY anonymous content is the shared chats (plus the login/infra endpoints the gate itself needs). Every existing admin-only surface stays admin-only. ## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3) - **A3 confirmed — token-user scope:** a token user (role `user`) may: `POST /api/chat`, `GET /api/suggestions`, `GET /api/documents/content`, `GET /api/whoami`, `POST /api/logout`. Admin-only UNCHANGED: the docs list/import/sync, tuning, git sources, doc drafts, and the saved-chats list/save/share/delete (saved chats have no per-user attribution — token users get NO History view; only the admin sees saved chats). - **A4 confirmed — token shape & lifecycle:** `bor_` + 32 hex chars (`secrets.token_hex(16)`); only the SHA-256 hex digest of the FULL token string is stored (unique index) — the plaintext is returned EXACTLY ONCE at creation. `revoked_at` set = dead, and revocation is enforced IMMEDIATELY on the user's next request (the session stores the token id; `require_user` live-checks the row is unrevoked — no server-side session store is added, just a PK lookup). - **A5 confirmed — the gate:** an in-app token-entry overlay on the shell + the same inline gate on `document.html`; `login.html` (admin password) and `shared.html` (anonymous) are UNCHANGED. The entered token is cached in `localStorage["bor.token"]` and silently re-sent to `POST /api/token-auth` on every page load (a failed silent re-auth — revoked token — drops the key and shows the gate). Sign out clears the key. `/api/config` stays public (the gate UI itself needs the branding). - **Auth error semantics:** an unauthenticated (or revoked) caller to a `require_user` endpoint gets 401 `{"detail": "authentication required"}` — 401, not 403 (there is no higher privilege that would unblock them); `require_admin` keeps its 403 `admin only`. `POST /api/token-auth` failures (malformed / unknown / revoked) all get ONE generic 401 `{"detail": "invalid token"}` (no enumeration — the phase-16 pattern). - **`whoami` shape:** `WhoamiResponse{authenticated: bool, role: "admin"|"user"|"anonymous"}` — `authenticated` is true for admin AND user; ALL UI gating switches from `authenticated` to `role === "admin"` (the frontend change is owned by task 05). An admin-signed-in session keeps working exactly as today (a browser that holds BOTH an admin and a user session reports admin; `sign_out` clears everything — one session dict, one logout). ## Design (shared by all tasks — the executor reads this, not the chat) - **Model — `api_tokens` (migration `0012_api_tokens.py`):** `id` UUID PK (uuid4 default); `label` String(120) NOT NULL (the hand-out name, e.g. "alice" — display-only, no index, not unique); `token_hash` String(64) NOT NULL UNIQUE (the sha256 hex digest of the full `bor_…` string — the `documents.content_hash` String(64) precedent); `created_at` TIMESTAMPTZ NOT NULL server-default now; `last_used_at` TIMESTAMPTZ NULL; `revoked_at` TIMESTAMPTZ NULL. - **Service — `app/core/tokens.py` (new):** `generate_token() -> str` (`"bor_" + secrets.token_hex(16)`); `hash_token(token) -> str` (sha256 hexdigest of the FULL token — hashing the full string, not the suffix, so a stripped prefix can never collide); `create_token(db, label) -> tuple[ApiToken, str]` (returns the row + the plaintext exactly once — the row only ever carries the hash); `find_active_by_token(db, token) -> ApiToken | None` (hash → `token_hash ==` lookup → `revoked_at IS NULL`); `mark_used(tok)` (bump `last_used_at` to now — the caller commits); `revoke(db, token_id) -> bool` (set `revoked_at` when not already — False when the row is missing). Module docstring: the lookup is by HASH (a unique-index hit) — sha256's pre-image resistance means there is no token-enumeration or timing surface beyond the DB lookup (the contrast with `check_password`'s constant-time compare is documented, not replicated — there is nothing to compare in constant time here, only to look up). - **Admin API — `app/api/tokens.py` (new router, `tags=["tokens"]`, router-level `dependencies=[Depends(require_admin)]` — the `doc_drafts.py` pattern):** `POST /tokens` body `TokenCreateRequest{label}` → 201 `TokenCreated{id, label, token, created_at}` — the ONLY response that ever carries the plaintext; `GET /tokens` → `TokenList{tokens: [TokenListItem{id, label, created_at, last_used_at, revoked: bool}]}` newest-first (no hashes, no plaintext); `POST /tokens/{id}/revoke` → 204, idempotent (already-revoked → still 204; unknown id → 404 `token not found`). Registered in `app/main.py` with the other API routers (before the static mount). - **Auth API — `app/api/auth.py`:** new `POST /token-auth` (PUBLIC — it is the login): body `TokenAuthRequest{token}` → `find_active_by_token` → miss → 401 `invalid token`; hit → `mark_used` + commit + `session[USER_SESSION_KEY] = True` + `session[USER_TOKEN_ID_KEY] = str(token.id)` → 204. `whoami` reports the three roles. `logout` is unchanged (its `session.clear()` already wipes both roles). - **`require_user` (in `app/core/auth.py`):** `def require_user(request: Request, db: Session = Depends(get_db))` — admin key set → pass; `user` key set → fetch the `ApiToken` row by `user_token_id` (PK hit) — row missing OR `revoked_at` set → pop BOTH user keys from the session + raise 401 `authentication required`; else pass; neither key → 401 same detail. Applied to exactly three endpoints: `POST /api/chat` (`app/api/chat.py`), `GET /api/suggestions` (`app/api/suggestions.py`), `GET /api/documents/content` (`app/api/docs.py` — update its docstring: the phase-16 "deliberately PUBLIC soft rule" is SUPERSEDED — the shared chats page is now the anonymous surface). Everything else: unchanged. - **Public list (the only anonymous access — the owner's sentence):** `/api/health`, `/api/config`, `/api/whoami`, `/api/login`, `/api/token-auth`, `/api/shared/` (JSON snapshot) + the `/shared/` page + `shared.html`, the static assets, and the page documents themselves (`login.html`, `document.html`, the shell — the documents load; their GATED DATA does not: the shell shows the gate, `document.html` shows its inline gate). - **Frontend gate (task 05):** new `frontend/assets/token-gate.js` (module) exposing `mountGate(lockRoot, onAuthed)`: at call — (1) if `localStorage["bor.token"]` exists → `POST /api/token-auth` with it (silent; on failure remove the key — it may have been revoked — and fall through); (2) `fetchWhoami()` → `user` or `admin` → `onAuthed()` (the gate never shows); `anonymous` → show the gate AND `lockRoot.inert = true` (the shell passes `#main`; `document.html` passes its content wrapper) + focus the token input. Submit → token-auth → 204 → `localStorage.setItem("bor.token", …)` → whoami → user → hide the gate (`hidden` + `inert` on the gate — the ship-hidden pattern), `lockRoot.inert = false`, `onAuthed()`. 401 → `#auth-gate-error` (`role="alert"`) visible, input cleared + re-focused. All `localStorage` access in try/catch (private mode → the gate still works, caching is a no-op — the fail-silence storage contract). `header.js`: the single whoami now caches the FULL `{authenticated, role}` in one module promise (`fetchWhoami()`); `fetchIsAdmin()` becomes `fetchWhoami().then(w => w.role === "admin")` — SAME single request, all existing callers keep working; `initSharedHeader()` switches its admin variable to `role === "admin"` (byte-identical behavior for admin/anonymous; a `user` gets: sign-in hidden, sign-out visible, all admin nav links hidden, steering panel removed — the anonymous branch); the sign-out binding gains `localStorage.removeItem("bor.token")` (try/catch, before the reload). - **Gate markup (shell — `index.html`):** body-level `