Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
113 lines
4.2 KiB
Python
113 lines
4.2 KiB
Python
"""Tokens admin API (phase 79, task 02).
|
|
|
|
The admin surface for the issued access tokens (owner-locked A4):
|
|
generate a named token (the plaintext is shown **exactly once**, in the
|
|
201 body), list tokens (display fields only — no plaintext, no hashes),
|
|
and revoke one (idempotent). The whole router sits behind
|
|
:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the
|
|
:mod:`app.api.doc_drafts` pattern): tokens are admin-only, so anonymous
|
|
callers get 403 on every route — and once the token-auth login lands
|
|
(task 03), a token *user* stays 403 here too (only the admin manages
|
|
tokens).
|
|
|
|
Routes (all under ``/api`` via the ``main`` registration):
|
|
``POST /api/tokens`` (201 ``TokenCreated`` — the ONE response shape
|
|
that carries the plaintext ``token``), ``GET /api/tokens``
|
|
(``TokenList`` — newest first, secret-free), ``POST
|
|
/api/tokens/{token_id}/revoke`` (204, idempotent; unknown id → 404
|
|
``token not found``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core import tokens as token_service
|
|
from app.core.auth import require_admin
|
|
from app.db import get_db
|
|
from app.models import ApiToken
|
|
from app.schemas import TokenCreated, TokenCreateRequest, TokenList, TokenListItem
|
|
|
|
router = APIRouter(
|
|
prefix="/tokens",
|
|
tags=["tokens"],
|
|
dependencies=[Depends(require_admin)], # phase 79: the token admin surface is admin-only
|
|
)
|
|
|
|
|
|
@router.post("", response_model=TokenCreated, status_code=201)
|
|
def create_token(
|
|
payload: TokenCreateRequest,
|
|
db: Session = Depends(get_db), # noqa: B008
|
|
) -> TokenCreated:
|
|
"""Generate one named token (201).
|
|
|
|
The ``token`` field of this response is the ONE AND ONLY moment the
|
|
plaintext exists on the wire (A4): the row stores the SHA-256 hash
|
|
of the full token string, and no other response shape — in
|
|
particular the list — ever carries it. ``label`` is the hand-out
|
|
name, display-only and NOT unique (two tokens may share a label).
|
|
Blank/over-long labels are a 422 from the schema (the house
|
|
``ValueError`` pattern — fail loud at the boundary).
|
|
"""
|
|
row, plaintext = token_service.create_token(db, payload.label)
|
|
db.commit() # the service flushes; the endpoint owns the commit
|
|
db.refresh(row) # pulls the server-default created_at
|
|
return TokenCreated(
|
|
id=row.id, label=row.label, token=plaintext, created_at=row.created_at
|
|
)
|
|
|
|
|
|
@router.get("", response_model=TokenList)
|
|
def list_tokens(
|
|
db: Session = Depends(get_db), # noqa: B008
|
|
) -> TokenList:
|
|
"""All tokens, newest first (``created_at desc, id desc`` tiebreak).
|
|
|
|
Secret-free by construction: :class:`~app.schemas.TokenListItem` has
|
|
no ``token`` and no ``token_hash`` field — the list never carries a
|
|
credential in either form. ``revoked`` is derived from
|
|
``revoked_at is not None``; ``last_used_at`` stays null until the
|
|
token is first used (task 03 stamps it on ``POST /api/token-auth``)
|
|
"""
|
|
rows = (
|
|
db.execute(
|
|
select(ApiToken).order_by(ApiToken.created_at.desc(), ApiToken.id.desc())
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
items = [
|
|
TokenListItem(
|
|
id=row.id,
|
|
label=row.label,
|
|
created_at=row.created_at,
|
|
last_used_at=row.last_used_at,
|
|
revoked=row.revoked_at is not None,
|
|
)
|
|
for row in rows
|
|
]
|
|
return TokenList(tokens=items)
|
|
|
|
|
|
@router.post("/{token_id}/revoke", status_code=204)
|
|
def revoke_token(
|
|
token_id: uuid.UUID,
|
|
db: Session = Depends(get_db), # noqa: B008
|
|
) -> Response:
|
|
"""Revoke one token (204) — idempotent.
|
|
|
|
Already-revoked → still 204 with NO re-stamp (the original
|
|
``revoked_at`` — the revocation time — is preserved; the service
|
|
only stamps when unset). Unknown id → 404 ``token not found`` (one
|
|
message for every unknown id). Revocation takes effect immediately:
|
|
the holder's next request is refused (the task-03 live check).
|
|
"""
|
|
if not token_service.revoke(db, token_id):
|
|
raise HTTPException(status_code=404, detail="token not found")
|
|
db.commit()
|
|
return Response(status_code=204)
|