Tasks: Public API Endpoint Authentication
Input: Design documents from /specs/018-public-api-auth/
Prerequisites: plan.md, spec.md, research.md, data-model.md, contracts/guest-token.md, quickstart.md
Tests: Every phase below includes dedicated test tasks — mandatory per project constitution §III (Docker-only execution via make test-backend/make test-frontend; not optional even though not explicitly requested in the spec).
Phase ordering note: Spec priorities are US1=P1, US2=P1, US3=P2, but they cannot ship independently of each other the way a normal priority order implies. Attaching the new guard to any router simultaneously (a) rejects tokenless requests (US1) and (b) is the only way to prove a guest token actually grants access (US2's Independent Test) — the two are one mechanism, not two. Per research.md §9, gating a router without the guest-token mechanism already existing would 401 the current production frontend for every anonymous visitor. Phases below are therefore ordered by dependency, mirroring the same pattern 017-exception-handling-guideline used for its own forced-together stories: Phase 3 = US2 (the mechanism — issuance/refresh endpoints + the guard function, proven in isolation), Phase 4 = US1 (the mechanism applied to every in-scope router + frontend wiring — the outcome, deployed atomically with Phase 3), Phase 5 = US3 (regression verification that existing logged-in flows are untouched). This is called out again in the Dependencies section.
Format: [ID] [P?] [Story] Description
- [P]: Can run in parallel (different files, no dependencies)
- [Story]: Which user story this task belongs to (US1, US2, US3)
Phase 1: Setup
Purpose: Confirm the one new technical capability this feature needs (backend-side JWT signing) actually works in this environment before building on it.
- [x] T001 Confirm
python-jose'sjwt.encodeworks in thebackendservice's resolved environment — verify withdocker compose run --rm backend uv run python -c "from jose import jwt; print(jwt.encode({'a': 1}, 'x', algorithm='HS256'))"(per Constitution: Docker-only execution; noteuv runis required — plainpythoninside this container does not see theuv-managed environment)
Phase 2: Foundational (Blocking Prerequisites)
Purpose: The shared guest-claim vocabulary and verification logic every subsequent phase builds on. Nothing else in this feature has anything meaningful to test without this.
⚠️ CRITICAL: No user story work can begin until this phase is complete
- [x] T002 Add guest-claim helpers to
backend/services/auth_service.py:compute_guest_id(request: Request) -> str(extract/adapt the existingsha256(ip + user_agent)[:16]logic currently inline inbackend/routers/chat.py::_guest_identity, per research.md §3),create_guest_access_token(guest_id: str) -> str({"tier": "guest", "guest_id", "token_use": "access", "exp": now+1h}, signed withNEXTAUTH_SECRET/HS256 viajose.jwt.encode),create_guest_refresh_token(guest_id: str) -> str(same shape,"token_use": "refresh",exp=now+30d) — per data-model.md §1–2 - [x] T003 [P] Add
backend/schemas/guest.pywithGuestTokenPairOut(access_token: str,refresh_token: str,expires_in: int),GuestAccessTokenOut(access_token: str,expires_in: int), andGuestRefreshRequest(refresh_token: str) Pydantic models — per contracts/guest-token.md - [x] T004 Add
require_any_tokentobackend/auth/guards.py, following the existing_require_admin_impl/require_admintwo-part pattern: decode the bearer token withNEXTAUTH_SECRET; accept if the payload has aroleclaim (existing real user/admin token, exactly what_require_user_implalready accepts) OR has"tier": "guest"andtoken_useis absent or"access"; otherwiseraise UnauthorizedError(...)— including whentoken_use == "refresh"(research.md §2) — depends on T002 - [x] T005 [P] Unit tests for T002's claim helpers in
backend/tests/test_auth_service.py(new or extend if it exists):create_guest_access_token/create_guest_refresh_tokenproduce the documented claim shapes and expiry deltas (1h / 30d);compute_guest_idis deterministic for the same ip+user-agent and differs for a different ip or user-agent - [x] T006 [P] Unit tests for
require_any_tokeninbackend/tests/test_guards.py(extend existing file): accepts a real user token, accepts a real admin token, accepts a guest access token, rejects a guest refresh token (token_use == "refresh"), rejects a garbage/malformed token, rejects a missing token, rejects an expired guest access token — depends on T004
Checkpoint: Foundation ready — guest-claim vocabulary and the accept/reject decision exist and are tested in isolation; no router is wired to any of this yet, so there is no production behavior change so far.
Phase 3: User Story 2 - Anonymous site visitors keep browsing without registering (Priority: P1, built first — see phase-ordering note above)
Goal: A caller with no account can obtain a valid guest token pair with zero credentials, and that token is proven to grant access — the mechanism spec.md US1's gate depends on existing first.
Independent Test: Request a guest token pair from the backend with no prior login, then present the access token to a require_any_token-gated route and confirm it succeeds — verifiable without any production router having been migrated yet (T009 mounts a throwaway test route, mirroring the pattern 017-exception-handling-guideline's test_exception_handlers.py already uses for isolated handler testing).
Tests for User Story 2
- [x] T007 [P] [US2] Test
POST /auth/guestinbackend/tests/test_auth.py(extend existing file): returns200withaccess_token/refresh_token/expires_in; decoding both tokens shows the sameguest_idclaim; requires noAuthorizationheader - [x] T008 [P] [US2] Test
POST /auth/guest/refreshinbackend/tests/test_auth.py: a valid, non-expired refresh token →200with a new access token carrying the sameguest_id; an expired refresh token, a malformed token, and an access token presented where a refresh token is expected all →401via the standardErrorResponseshape (contracts/guest-token.md) - [x] T009 [US2] Independent-test-style integration test (new
backend/tests/test_guest_token_flow.py): mount a throwaway@app.get("/__test/guest-gated")route behindDepends(require_any_token)(same pattern astest_exception_handlers.py's/__test/raise/{category}); full round trip — callPOST /auth/guest, use the returned access token against the throwaway route →200; call it with no token →401; call it with the refresh token →401— depends on T004, T010, T011
Implementation for User Story 2
- [x] T010 [US2] Implement
POST /auth/guestinbackend/routers/auth.py: no request body, callscompute_guest_id(request)+ T002's token helpers, returnsGuestTokenPairOut— depends on T002, T003 - [x] T011 [US2] Implement
POST /auth/guest/refreshinbackend/routers/auth.py: takesGuestRefreshRequest, decodes the refresh token (reject viaUnauthorizedErrorif malformed/expired/wrongtoken_use, reusingrequire_any_token's style of check rather than duplicating it — factor the shared decode step if natural), mints a new access token with the sameguest_id, returnsGuestAccessTokenOut— depends on T002, T003 - [x] T012 [P] [US2] Frontend: extend
frontend/lib/providers/guest-mode-provider.tsx(or add a sibling provider colocated with it) to acquire a guest token pair viaPOST /auth/guest(throughapiFetch) wheneveruseSession()resolves to no authenticated user and no cached pair exists; store the pair insessionStorage(consistent with this provider's existing storage choice); expose the current guest access token through context — depends on T010 - [x] T013 [US2] Frontend: silent refresh in the same provider — on the guest access token nearing/reaching its 1h expiry (or on a
401from a call made with it), callPOST /auth/guest/refresh; if that also fails (expired refresh token), fall back to T012's full re-issuance — all transparent to the visitor (spec.md User Story 2, Scenarios 3–4) — depends on T012 - [x] T014 [P] [US2] Frontend unit tests (Vitest) for the provider in
frontend/tests/unit/: acquires a token when unauthenticated, reuses the cached token within its lifetime, does not acquire one when a real session exists, refreshes on expiry, falls back to full re-issuance when refresh also fails
Checkpoint: A caller with no account can obtain and use a working guest token; the frontend can acquire/refresh one transparently. No production router is gated yet — still zero behavior change for real traffic.
Phase 4: User Story 1 - External API consumer without a token can no longer read data (Priority: P1) 🎯 MVP outcome
Goal: Every endpoint enumerated in spec.md FR-001 actually rejects a request with no valid token, and the existing frontend keeps working for every anonymous/guest/logged-in flow because it now attaches the token from Phase 3.
Independent Test: Call each in-scope endpoint with no Authorization header and confirm 401; call it again with a guest token and confirm it still succeeds (spec.md Acceptance Scenarios 1–3).
Tests for User Story 1
- [x] T015 [US1] Full-audit regression test in
backend/tests/test_error_response_audit.py(extend, mirroring017-exception-handling-guideline's equivalent audit task): parameterized across every route identified in T017's audit, asserting401with no token and success with a guest token - [x] T016 [P] [US1] Test in
backend/tests/test_chat_router.py(extend):/chat/completionsand/chat/quotanow401with no token; with a guest token, the guest tier's rate-limit key comes from the token'sguest_idclaim (not the retired__rag_gidcookie/ip-hash); existing per-tier limits (DAILY_LIMIT_GUEST/DAILY_LIMIT_USER) are unchanged
Implementation for User Story 1
- [x] T017 [US1] Produce a short audit as
specs/018-public-api-auth/router-audit.md: one row per endpoint in the routers named in spec.md FR-001 (articles.py,graph.py,tags.py's read-only endpoints,topics.py'sGET /topics,weekly_reports.py,languages.py,chat.py), confirming it currently has no auth dependency and recording thatrequire_any_tokenwill be added — do this before T018–T023 so they have a concrete checklist (mirrors017-exception-handling-guideline'srouter-audit.mdprecedent) - [x] T018 [US1] Apply
require_any_tokentobackend/routers/articles.py's public endpoints per T017's audit (GET /articles,GET /source-categories, the 3filters/*endpoints,GET /articles/{article_id},POST /articles/{article_id}/view) —POST /admin/articles/flush-view-countsalready requiresrequire_adminand is out of scope — depends on T004, T017 - [x] T019 [P] [US1] Apply
require_any_tokento both endpoints inbackend/routers/graph.py— depends on T004, T017 - [x] T020 [P] [US1] Apply
require_any_tokentobackend/routers/tags.py's read-only endpoints (GET /tag-groups,GET /tag-groups/{group_id}) — every other endpoint in this router already requiresrequire_admin/require_userand is untouched — depends on T004, T017 - [x] T021 [P] [US1] Apply
require_any_tokentoGET /topicsinbackend/routers/topics.py— write endpoints already requirerequire_adminand are untouched — depends on T004, T017 - [x] T022 [P] [US1] Apply
require_any_tokento all four endpoints inbackend/routers/weekly_reports.py— depends on T004, T017 - [x] T023 [P] [US1] Apply
require_any_tokentoGET /languagesinbackend/routers/languages.py— depends on T004, T017 - [x] T024 [US1] Migrate
backend/routers/chat.py: addrequire_any_tokento/chat/completionsand/chat/quota; extend_parse_identity(or the identity-resolution path) to also recognize"tier": "guest"tokens and readguest_iddirectly from the decoded claim; delete_guest_identity()and all__rag_gidcookie get/set code (research.md §7, spec.md FR-007) — depends on T004, T002 - [x] T025 [US1] Frontend: add
token?: string+ the existingauthHeader(token)/authHeaders(token)pattern (already used bylib/api/scraper-settings.ts,lib/api/auth.ts) to every currently-tokenless call site for an in-scope endpoint (lib/api/articles.ts, the graph call site,lib/api/tags.ts's read functions,lib/api/topics.ts's list function, the weekly-reports call site, the languages call site, the chat call sites), sourcing the token from T012's provider (the real NextAuth session token when logged in, otherwise the guest access token) — depends on T012 - [x] T026 [US1] Update the OpenAPI
responses=documentation on every endpoint touched by T018–T024 to include401viaerror_responses()(from017-exception-handling-guideline'sbackend/schemas/error.py), consistent with that feature's own OpenAPI-documentation task — depends on T018–T024
Checkpoint: Every endpoint in FR-001's scope now rejects tokenless requests, and the frontend transparently keeps every existing anonymous/guest/logged-in flow working. This phase MUST deploy together with Phase 3 (research.md §9) — this is the MVP.
Phase 5: User Story 3 - Existing logged-in users and admins are unaffected (Priority: P2)
Goal: Prove the blanket "require a token" change added nothing on top of the existing require_admin/require_user checks — no re-authentication, no new prompt, no role-logic change.
Independent Test: Call an in-scope endpoint and an already-require_admin-gated endpoint with an existing real user/admin token and confirm both succeed exactly as before this feature (spec.md Acceptance Scenarios).
Tests for User Story 3
- [x] T027 [P] [US3] Regression sweep in
backend/tests/test_error_response_audit.py(extend further, or a newbackend/tests/test_guest_auth_regression.py): an existing real user JWT and an existing real admin JWT succeed unchanged on (a) every endpoint newly gated in Phase 4 and (b) every endpoint that already requiredrequire_admin/require_userbefore this feature — confirmingroleis still read only frommodels.auth.User.rolevia the unchanged decode path, with no new check introduced - [x] T028 [P] [US3] Frontend test (Vitest, extend T014's suite): the provider from T012 never attempts guest-token acquisition when
useSession()reports an authenticated user — no extra network call, no visible change for logged-in sessions
Checkpoint: All three user stories delivered — mechanism (US2), applied consistently (US1), verified not to regress existing sessions (US3).
Phase 6: Polish & Cross-Cutting Concerns
Purpose: Verification sweep once all stories are integrated.
- [x] T029 Run
make test-backendandmake test-frontend(Docker-only, Constitution §III) and confirm all new and existing tests pass, including the T015 full-audit regression test - [x] T030 [P] Manually run the
curlverification steps inquickstart.mdagainst a locally runningdocker compose upstack - [x] T031 Update
CLAUDE.md's "Backend Routers" table: change the auth column forarticles.py,graph.py,languages.py,weekly_reports.py, and the read-only rows oftags.py/topics.pyfrom "Public" to reflect the newrequire_any_tokenrequirement (guest or logged-in) — keeps the doc accurate per this repo's established convention of documenting real router behavior - [x] T032 [P] Run
make uml-backendto confirm the new guard/service functions don't break the auto-generated architecture diagram's layer classification (Constitution Principle VIII)
Dependencies & Execution Order
Phase Dependencies
- Setup (Phase 1): No dependencies
- Foundational (Phase 2): Depends on Setup — BLOCKS everything else (T002's claim helpers and T004's guard are imported by every later phase)
- US2 (Phase 3): Depends on Foundational. Independently testable/deliverable on its own via a throwaway test route (T009) — no production router changes yet.
- US1 (Phase 4): Depends on Foundational AND on US2 (Phase 3) being complete — US1's routers rely on guest tokens already being obtainable, or the current production frontend's anonymous visitors would be locked out the moment a router is gated. This is the one deliberate cross-story dependency in this feature (documented in the phase-ordering note above), mirroring
017-exception-handling-guideline's US3→US1 precedent. - US3 (Phase 5): Depends on US1 (Phase 4) — there is nothing to regression-test against until the newly-gated endpoints exist. Verification-only; introduces no new production code.
- Polish (Phase 6): Depends on Phases 3, 4, and 5 all being complete.
Parallel Opportunities
- T005 and T006 (independent test files, both depend only on T002/T004) — parallel.
- T007 and T008 (independent test functions in the same file but additive) — parallel-safe.
- T019–T023 (disjoint router files, each only depends on T004 + T017's audit) — parallel.
- T014 (frontend provider tests) can start once T012/T013 land, independent of backend Phase 4 progress.
- T027 and T028 (backend regression sweep vs. frontend regression test, disjoint files) — parallel.
Parallel Example: Phase 4 (US1)
# After T017's audit and T004 (guard) are done, gate the disjoint routers together:
Task: "Apply require_any_token to backend/routers/graph.py"
Task: "Apply require_any_token to backend/routers/tags.py's read-only endpoints"
Task: "Apply require_any_token to GET /topics in backend/routers/topics.py"
Task: "Apply require_any_token to backend/routers/weekly_reports.py"
Task: "Apply require_any_token to GET /languages in backend/routers/languages.py"Implementation Strategy
MVP Scope
The MVP for this feature is Phase 2 + Phase 3 + Phase 4 together (not Phase 3 alone) — the spec's P1 outcome (US1, "external consumer can no longer read data for free") is only safe to ship once the mechanism (US2) that keeps legitimate anonymous/guest traffic working already exists. Phase 5 (US3, regression verification) is required before calling the MVP done, but adds no new production behavior.
Incremental Delivery
- Setup + Foundational (Phase 1–2) → guest-claim vocabulary and guard exist and are unit-tested — no visible change yet
- US2 (Phase 3) → guest-token mechanism exists and is independently verified correct (via a throwaway test route) — still no production router gated
- US1 (Phase 4) → mechanism applied to every in-scope router + frontend wiring → MVP: external consumers without a token are refused; existing anonymous/guest/logged-in flows keep working — Phase 3 and Phase 4 deploy together, not as separate releases
- US3 (Phase 5) → regression verification, safe to land immediately after Phase 4
- Polish (Phase 6) → verification sweep, doc accuracy, UML regeneration
Implementation Notes (discovered during T001–T032)
Three real, pre-existing or newly-introduced bugs were found and fixed along the way — none were separately-numbered tasks, but all were necessary for the feature to actually satisfy its own FRs:
POST /admin/articles/flush-view-countswas never actually admin-gated.backend/routers/articles.pyimportedrequire_admininside the function body but never wired it as aDepends(...)— a pre-existing dead-import bug, unrelated to this feature but discovered while auditingarticles.pyfor T017/T018. Fixed by wiring the already-imported dependency;backend/tests/integration/test_article_view_counts.py's flush tests updated to pass an admin token accordingly.- Missing-
Authorization-header requests bypassed theErrorResponsecontract.backend/auth/guards.py'sbearer = HTTPBearer()(defaultauto_error=True) rejected a totally-missing header itself, before any guard function ran, producing FastAPI's own{"detail": "Not authenticated"}shape instead of the{"error": {...}}contract spec.md FR-008 requires. Fixed by switching toHTTPBearer(auto_error=False)and havingrequire_admin/require_user/require_any_tokenraiseUnauthorizedErrorthemselves on aNonetoken. require_userwould have silently accepted a guest token._require_user_implonly checkedexp, never that the token actually represented a real user — a guest token (nosubclaim) would pass it and then crash downstream (e.g.backend/routers/user.py's_get_user_iddoingUUID(user["sub"])) with a rawKeyErrorinstead of a clean 401. Fixed by rejectingpayload.get("tier") == "guest"in_require_user_impl, per FR-003's "must continue to be refused" requirement.
Frontend implementation centralizes token attachment in apiFetch (frontend/lib/api/client.ts) via a small module-level store (frontend/lib/auth-token-store.ts) that AuthTokenProvider keeps in sync, rather than threading a token param through every lib/api/*.ts call site individually — every existing call site that doesn't already set its own Authorization header benefits automatically. This is a deliberate refinement over research.md §8's original "extend the per-call-site pattern" framing, chosen because nearly every endpoint now needs a token (unlike when only a few admin endpoints did).