docs(08): create phase 8 plan — 8 plans, 3 waves (stack upgrade + backend decomposition)

Wave 0: CR-01/02/03 test stubs + api/schemas.py (CloudConnectionOut migration)
Wave 1: useToastStore stub + Phase 7.1 frontend completion
Wave 2 (parallel): api/admin/, api/documents/, api/auth/ package splits + client.js decomposition + PERF-01 deps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-08 14:59:27 +02:00
co-authored by Claude Sonnet 4.6
parent c20a5d8913
commit 25e568973f
12 changed files with 3109 additions and 16 deletions
+25 -1
View File
@@ -553,7 +553,31 @@ _Started: 2026-06-07_
2. `import * as api from '../api/client.js'` in any existing consumer file resolves all previously-exported names without modification to that consumer file
3. No Pydantic model definition appears more than once across `api/admin/`, `api/documents/`, and `api/auth/` sub-packages — shared types live in a dedicated module
4. `PERF-01` packages are installed and `npm list` confirms `vue@^3.5.x`, `vite@^6.x`, `@vueuse/core@^14.x`, `sortablejs`, `@tailwindcss/forms`, `rollup-plugin-visualizer` are present
**Plans**: TBD
**Plans**: 8 plans (3 waves)
**Wave 0** — Foundation (parallel)
- [ ] 08-01-PLAN.md — CR-01/02/03 test stubs (3 xfail stubs in test_auth.py) + Wave 0 scaffolds for regression detection
- [ ] 08-02-PLAN.md — `api/schemas.py` creation + `CloudConnectionOut` migration from admin.py (MUST precede admin split)
**Wave 1** *(blocked on Wave 0)* — Phase 7.1 completion (frontend only — backend already implemented)
- [ ] 08-03-PLAN.md — `useToastStore` stub (`frontend/src/stores/toast.js`) + CR test promotion + SettingsAccountTab.vue + TotpEnrollment.vue inline toast replacement
**Wave 2** *(blocked on Wave 1)* — Backend decomposition + frontend (parallel)
- [ ] 08-04-PLAN.md — Split `api/admin.py``api/admin/` package: users.py, quotas.py, ai.py, shared.py, __init__.py (CODE-01)
- [ ] 08-05-PLAN.md — Split `api/documents.py``api/documents/` package: upload.py, crud.py, content.py, shared.py, __init__.py (CODE-02)
- [ ] 08-06-PLAN.md — Split `api/auth.py``api/auth/` package: tokens.py, totp.py, password.py, sessions.py, shared.py, __init__.py (CODE-03)
- [ ] 08-07-PLAN.md — Frontend `client.js` decomposition: utils.js + 7 domain modules + barrel rewrite (CODE-04)
- [ ] 08-08-PLAN.md — PERF-01 dependency bump + tailwind/vite config wiring + requirements.txt exact pinning (PERF-01, D-17)
**Cross-cutting constraints:**
- Sub-routers MUST have NO prefix on `APIRouter()` — only package `__init__.py` carries the prefix (Plans 04, 05, 06)
- `api/schemas.py` must exist before admin split — `api/cloud.py` imports `CloudConnectionOut` from admin (Plan 02 before Plan 04)
- `request()` moves to `utils.js` for circular-import avoidance; `client.js` re-exports it — zero consumer changes (Plan 07)
- Every admin sub-module handler retains `Depends(get_current_admin)` explicitly — never omit (Plans 04)
---
+15 -15
View File
@@ -2,14 +2,14 @@
gsd_state_version: 1.0
milestone: v0.2
milestone_name: Phases
current_phase: Phase 8 (context gathered)
status: planning
last_updated: "2026-06-07"
last_activity: 2026-06-07 — Phase 8 context gathered
current_phase: "Phase 8: Stack Upgrade & Backend Decomposition"
status: planned
last_updated: "2026-06-08T00:00:00.000Z"
last_activity: 2026-06-08 — Phase 8 planned (8 plans, 3 waves)
progress:
total_phases: 17
completed_phases: 13
total_plans: 65
total_plans: 73
completed_plans: 65
percent: 76
---
@@ -17,22 +17,22 @@ progress:
# Project State
**Project:** DocuVault
**Status:** Phase 8 context gathered — ready to plan
**Status:** Phase 8 planned — ready to execute
**Current Phase:** Phase 8: Stack Upgrade & Backend Decomposition
**Last Updated:** 2026-06-07
**Last Updated:** 2026-06-08
## Current Position
Phase: Phase 8 (context gathered, not started)
Phase: Phase 8 (planned, 8 plans ready)
Plan: —
Status: Context captured — 8 areas discussed, 17 decisions locked
Last activity: 2026-06-07 — Phase 8 context gathered
Status: Ready to execute — 8 plans across 3 waves
Last activity: 2026-06-08 — Phase 8 planned
## Phase Status
| Phase | Requirements | Status |
|-------|-------------|--------|
| 8. Stack Upgrade & Backend Decomposition | PERF-01, CODE-01, CODE-02, CODE-03, CODE-04, CODE-08 | Not started |
| 8. Stack Upgrade & Backend Decomposition | PERF-01, CODE-01, CODE-02, CODE-03, CODE-04, CODE-08 | Planned (8 plans) |
| 9. Admin Panel Rearchitecture | ADMIN-08..12, CODE-06, CODE-09 | Not started |
| 10. UX & Interaction | UX-01..14, CODE-05 | Not started |
| 11. Visual Design, Responsive Layout & Cleanup | VISUAL-01..04, RESP-01..05, CODE-07, PERF-02, PERF-03 | Not started |
@@ -43,7 +43,7 @@ Last activity: 2026-06-07 — Phase 8 context gathered
|---|---|
| Phases complete | 0 / 4 |
| Requirements mapped | 40 / 40 |
| Plans written | 0 |
| Plans written | 8 |
| Plans complete | 0 |
## Accumulated Context
@@ -82,7 +82,7 @@ _Updated at each phase transition._
| Field | Value |
|---|---|
| Last session | 2026-06-07v0.2 roadmap created; 4 phases defined |
| Next action | /gsd:plan-phase 8 |
| Last session | 2026-06-08Phase 8 planned; 8 plans created |
| Next action | /gsd:execute-phase 8 |
| Pending decisions | None |
| Resume file | .planning/ROADMAP.md — Phase 8 detail section |
| Resume file | .planning/phases/08-stack-upgrade-backend-decomposition/ |
@@ -0,0 +1,129 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 01
type: execute
wave: 0
depends_on: []
files_modified:
- backend/tests/test_auth.py
autonomous: true
requirements: [CR-01, CR-02, CR-03]
tags: [tests, session-revocation, wave-0]
must_haves:
truths:
- "Three new pytest tests exist for CR-01, CR-02, CR-03 as xfail stubs"
- "Stubs name the exact behavior they cover so executor of plan 08-03 can promote them"
- "Running pytest -v shows three new tests with status xfail (not error, not pass)"
artifacts:
- path: "backend/tests/test_auth.py"
provides: "Three new xfail test stubs for session revocation on privilege change"
contains: "test_change_password_revokes_other_sessions"
key_links:
- from: "backend/tests/test_auth.py"
to: "backend/api/auth.py change_password / enable_totp / disable_totp"
via: "test exercises real handler via httpx.AsyncClient"
pattern: "client.post\\(\"/api/auth/(change-password|totp/enable|totp)\""
---
<objective>
Create Wave 0 test stubs (xfail) for CR-01, CR-02, CR-03. These tests will be promoted to passing in plan 08-03 after the `useToastStore` stub and frontend wiring are complete. The backend implementation for all three is ALREADY in place (verified in RESEARCH.md §"Wave 1: Session Revocation"); these stubs lock the expected behavior contract before any refactoring touches `api/auth.py`.
Purpose: Anti-regression Nyquist scaffold — when plan 08-06 splits `api/auth.py` into `api/auth/` package, these promoted tests guarantee the session revocation logic still works through the new module structure.
Output: Three xfail-marked tests added to `backend/tests/test_auth.py`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-VALIDATION.md
@backend/api/auth.py
@backend/services/auth.py
@backend/tests/test_auth.py
@backend/tests/conftest.py
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add three xfail stubs for CR-01/CR-02/CR-03 to test_auth.py</name>
<files>backend/tests/test_auth.py</files>
<read_first>
- backend/tests/test_auth.py (read the full file to discover existing fixtures, login helper pattern, and how authenticated requests are issued)
- backend/tests/conftest.py (locate `auth_user` fixture, `client` fixture, and `auth_limiter` reset hook)
- backend/api/auth.py lines 478-540 (change_password — confirm response shape includes `sessions_revoked`)
- backend/api/auth.py lines 579-636 (enable_totp — confirm response shape)
- backend/api/auth.py lines 641-682 (disable_totp — confirm response shape)
- backend/services/auth.py lines 250-273 (revoke_all_refresh_tokens signature with skip_token_hash)
</read_first>
<behavior>
- Test `test_change_password_revokes_other_sessions`: register user, log in twice to obtain TWO refresh-token rows in DB (call them session A and session B). Using session A's access token, POST `/api/auth/change-password` with the correct current password and a new valid password. Assert: response 200, body contains `"sessions_revoked": 1` (session B revoked, session A preserved via `skip_token_hash`); session A's refresh token is still usable on POST `/api/auth/refresh`; session B's refresh token fails on POST `/api/auth/refresh` with 401.
- Test `test_enable_totp_revokes_other_sessions`: register user, log in twice (sessions A and B). Using session A, POST `/api/auth/totp/setup` to obtain a `provisioning_uri`, derive a valid TOTP code via `pyotp.TOTP(secret).now()`, POST `/api/auth/totp/enable` with that code. Assert: response 200, body contains `"sessions_revoked": 1`, session B refresh fails 401, session A refresh succeeds.
- Test `test_disable_totp_revokes_other_sessions`: register user, enable TOTP, then log in twice with TOTP (sessions A and B). Using session A, DELETE `/api/auth/totp` with the current TOTP code. Assert: response 200, body contains `"sessions_revoked": 1`, session B refresh fails 401, session A refresh succeeds.
</behavior>
<action>
Append three test functions to `backend/tests/test_auth.py`, each decorated with `@pytest.mark.xfail(reason="Wave 0 stub — promoted to passing in 08-03", strict=False)`. Use the existing test patterns in the file (httpx.AsyncClient async fixtures, `auth_user` fixture from conftest, `await client.post(...)`). The three function names MUST be exactly:
- `async def test_change_password_revokes_other_sessions(client, db_session)`
- `async def test_enable_totp_revokes_other_sessions(client, db_session)`
- `async def test_disable_totp_revokes_other_sessions(client, db_session)`
Inside each test body, write the full assertion logic per the `<behavior>` block — do NOT leave them as bare `pass` stubs. The tests SHOULD pass right now (backend already implemented per RESEARCH.md), but `strict=False` xfail allows either xpassed or xfailed without erroring the suite. Plan 08-03 will remove the `@pytest.mark.xfail` decorator and confirm they pass cleanly. Use `pyotp.TOTP(secret).now()` for TOTP code generation; import pyotp at the top of the test file if not already imported. For the login-twice pattern, use distinct `User-Agent` headers on each login to ensure separate refresh-token rows; capture `response.cookies['refresh_token']` for each session.
</action>
<verify>
<automated>cd backend && pytest tests/test_auth.py::test_change_password_revokes_other_sessions tests/test_auth.py::test_enable_totp_revokes_other_sessions tests/test_auth.py::test_disable_totp_revokes_other_sessions --tb=no -q</automated>
</verify>
<acceptance_criteria>
- `grep -c "def test_change_password_revokes_other_sessions" backend/tests/test_auth.py` returns 1
- `grep -c "def test_enable_totp_revokes_other_sessions" backend/tests/test_auth.py` returns 1
- `grep -c "def test_disable_totp_revokes_other_sessions" backend/tests/test_auth.py` returns 1
- `grep -c "pytest.mark.xfail" backend/tests/test_auth.py` increased by at least 3 vs. pre-change baseline
- Pytest output for these three test IDs shows status `XPASS` or `XFAIL` — never `ERROR` and never `FAILED`
- Body of each test asserts `data["sessions_revoked"] == 1` (not `>= 1`, not `is not None`)
- Body of each test verifies the OTHER session's refresh token returns 401 on `/api/auth/refresh`
- Body of each test verifies the CURRENT session's refresh token returns 200 on `/api/auth/refresh`
</acceptance_criteria>
<done>Three xfail-marked tests appended to test_auth.py with full assertion logic exercising the documented CR-01/02/03 contracts; pytest collects and runs them without error.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → POST /api/auth/change-password | Authenticated user requests password change; backend must revoke all OTHER refresh tokens |
| client → POST /api/auth/totp/enable | Authenticated user enables TOTP; backend must revoke all OTHER refresh tokens |
| client → DELETE /api/auth/totp | Authenticated user disables TOTP; backend must revoke all OTHER refresh tokens |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-01-01 | Tampering | Test fixture isolation | mitigate | Each test creates its own user via `auth_user` fixture; tests do not share session state |
| T-08-01-02 | Repudiation | xfail strict mode | mitigate | `strict=False` permits XPASS without erroring; plan 08-03 removes the marker and runs strict |
| T-08-01-SC | Supply Chain | pytest, pyotp | accept | Already pinned in requirements.txt; this plan adds no new packages |
</threat_model>
<verification>
- `cd backend && pytest tests/test_auth.py --tb=no -q` shows three new tests with xpassed/xfailed status (no errors)
- Full backend suite still green: `cd backend && pytest -v` — zero new failures vs. baseline
- File diff shows only additions to `backend/tests/test_auth.py` (no other files touched)
</verification>
<success_criteria>
- Three new xfail tests exist with the exact names listed
- Each test body contains real assertion logic (not `pass` or `pytest.skip`)
- Pytest collects all three without error
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-01-SUMMARY.md` when done. Include: which fixtures were used, any helper functions added, the exact xfail decorator reason string, and the pre-/post-stub `pytest --co` count.
</output>
@@ -0,0 +1,172 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 02
type: execute
wave: 0
depends_on: []
files_modified:
- backend/api/schemas.py
- backend/api/cloud.py
autonomous: true
requirements: [CODE-08]
tags: [shared-schemas, cross-package, prerequisite, wave-0]
must_haves:
truths:
- "backend/api/schemas.py exists and defines CloudConnectionOut with field_validator coerce_id_to_str"
- "backend/api/cloud.py imports CloudConnectionOut from api.schemas (not api.admin)"
- "All cloud endpoints continue to return the same JSON shape as before"
- "CloudConnectionOut definition appears exactly once across the entire backend tree"
artifacts:
- path: "backend/api/schemas.py"
provides: "Cross-package Pydantic response models (D-10 destination for shared schemas)"
contains: "class CloudConnectionOut"
- path: "backend/api/cloud.py"
provides: "Cloud endpoint router updated to import from api.schemas"
contains: "from api.schemas import CloudConnectionOut"
key_links:
- from: "backend/api/cloud.py"
to: "backend/api/schemas.py"
via: "import statement at module top"
pattern: "from api.schemas import CloudConnectionOut"
- from: "backend/api/admin.py"
to: "backend/api/schemas.py"
via: "import statement to be added in plan 08-04 admin split"
pattern: "from api.schemas import CloudConnectionOut"
---
<objective>
Create the new `backend/api/schemas.py` module and move `CloudConnectionOut` into it. Update `backend/api/cloud.py` to import from the new location. This MUST happen BEFORE plan 08-04 splits `api/admin.py` — otherwise the admin-split plan would break `cloud.py`'s `from api.admin import CloudConnectionOut` import.
Purpose: Eliminate the cross-package coupling between `api/cloud.py` and `api/admin.py` (RESEARCH.md §"Pitfall 3"). Establishes the `api/schemas.py` module that plan 08-04 will continue populating with any models discovered to be shared.
Output: New `backend/api/schemas.py` with `CloudConnectionOut`; one updated import in `backend/api/cloud.py`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@backend/api/admin.py
@backend/api/cloud.py
<interfaces>
<!-- Key definition to move (from backend/api/admin.py lines ~198-223). -->
<!-- After this plan, this class lives ONLY in backend/api/schemas.py. -->
class CloudConnectionOut(BaseModel):
id: str
provider: str
display_name: str
status: str
connected_at: datetime
server_url: Optional[str] = None
connection_username: Optional[str] = None
model_config = {"from_attributes": True}
@field_validator("id", mode="before")
@classmethod
def coerce_id_to_str(cls, v) -> str:
return str(v)
<!-- The import currently in backend/api/cloud.py line 35: -->
from api.admin import CloudConnectionOut
<!-- becomes: -->
from api.schemas import CloudConnectionOut
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create backend/api/schemas.py with CloudConnectionOut</name>
<files>backend/api/schemas.py</files>
<read_first>
- backend/api/admin.py lines 198-223 (current CloudConnectionOut definition — must be copied verbatim including the field_validator)
- backend/api/__init__.py (confirm it exists as a package marker; do not modify)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md section "backend/api/schemas.py" (exact pattern template)
</read_first>
<action>
Create `backend/api/schemas.py` as a new file. Add a `from __future__ import annotations` header. Add a module docstring: `"""Cross-package Pydantic response models. Models here are used by 2+ API packages and cannot live in a single package without creating circular imports (D-10, RESEARCH.md Pitfall 3)."""`. Add imports: `from datetime import datetime`, `from typing import Optional`, `from pydantic import BaseModel, field_validator`. Then define `class CloudConnectionOut(BaseModel)` with the exact field set from `backend/api/admin.py` lines ~198-223 (per D-10): `id: str`, `provider: str`, `display_name: str`, `status: str`, `connected_at: datetime`, `server_url: Optional[str] = None`, `connection_username: Optional[str] = None`, `model_config = {"from_attributes": True}`, and the `@field_validator("id", mode="before") coerce_id_to_str` classmethod that returns `str(v)`. Add a class-level docstring noting SEC-08: `credentials_enc` deliberately excluded, moved from `api/admin.py`, used by `api/cloud.py` and `api/admin/`. Do NOT remove the class from `api/admin.py` in this task — task 2 handles the import switch in cloud.py, and plan 08-04 will delete the original definition during the admin split.
</action>
<verify>
<automated>cd backend && python -c "from api.schemas import CloudConnectionOut; obj = CloudConnectionOut.model_validate({'id': 'abc-123', 'provider': 'google_drive', 'display_name': 'Test', 'status': 'ACTIVE', 'connected_at': '2026-06-07T00:00:00'}); assert obj.id == 'abc-123' and obj.provider == 'google_drive'"</automated>
</verify>
<acceptance_criteria>
- File `backend/api/schemas.py` exists
- `grep -c "^class CloudConnectionOut" backend/api/schemas.py` returns 1
- `grep -c "coerce_id_to_str" backend/api/schemas.py` returns 1
- `grep -c "from_attributes" backend/api/schemas.py` returns 1
- `python -c "from api.schemas import CloudConnectionOut"` from inside `backend/` exits 0 with no output
- All seven fields (id, provider, display_name, status, connected_at, server_url, connection_username) are present per `grep -c " [a-z_]*: " backend/api/schemas.py` ≥ 7
</acceptance_criteria>
<done>schemas.py exists, importable, CloudConnectionOut validates a sample dict, original admin.py class is still in place (deletion deferred to plan 08-04).</done>
</task>
<task type="auto">
<name>Task 2: Switch backend/api/cloud.py import to api.schemas</name>
<files>backend/api/cloud.py</files>
<read_first>
- backend/api/cloud.py line 35 (current `from api.admin import CloudConnectionOut`)
- backend/api/cloud.py top imports block (confirm no other re-exports from `api.admin` exist)
</read_first>
<action>
In `backend/api/cloud.py`, replace the single line `from api.admin import CloudConnectionOut` with `from api.schemas import CloudConnectionOut`. Do not change any other imports or any code below. After this edit, run the full test suite to confirm cloud endpoints still respond with the identical JSON shape (existing `tests/test_cloud.py` covers admin response surface; if not, the broader `pytest -v` smoke covers the admin list-cloud-connections endpoint too).
</action>
<verify>
<automated>cd backend && grep -c "from api.admin import CloudConnectionOut" api/cloud.py; cd backend && grep -c "from api.schemas import CloudConnectionOut" api/cloud.py; cd backend && pytest tests/test_cloud.py -x -v 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `grep -c "from api.admin import CloudConnectionOut" backend/api/cloud.py` returns 0
- `grep -c "from api.schemas import CloudConnectionOut" backend/api/cloud.py` returns 1
- `cd backend && pytest tests/test_cloud.py -x` exits 0
- `cd backend && pytest tests/test_admin.py -x -k "cloud"` exits 0 (admin endpoints that serialize CloudConnectionOut continue to work because the original definition in admin.py is still untouched at this point)
</acceptance_criteria>
<done>cloud.py imports CloudConnectionOut from api.schemas; tests for cloud endpoints and any admin endpoints that surface cloud connections continue to pass.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| api/cloud.py → api/schemas.py | Module-load-time import; no runtime data crosses this boundary other than a class reference |
| Admin list-cloud-connections endpoint → CloudConnectionOut serialization | SEC-08 requires `credentials_enc` to be deliberately excluded from this model |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-02-01 | Information Disclosure | CloudConnectionOut schema | mitigate | Field list must NOT include `credentials_enc`; field set is restricted to the 7 fields documented in PATTERNS.md |
| T-08-02-02 | Tampering | id coercion | mitigate | Preserve `@field_validator("id", mode="before") coerce_id_to_str` so UUID→str conversion behavior is byte-identical to the old definition |
| T-08-02-03 | Denial of Service | Duplicate class definitions | mitigate | Plan 08-04 removes the old definition from api/admin.py during the admin split; until then both definitions coexist but only the schemas.py version is imported by cloud.py |
| T-08-02-SC | Supply Chain | No new packages | accept | This plan installs zero new packages |
</threat_model>
<verification>
- `cd backend && pytest tests/test_cloud.py tests/test_admin.py -x` — zero failures
- `grep -rn "from api.admin import CloudConnectionOut" backend/` returns no matches (cloud.py was the only consumer)
- `grep -rn "from api.schemas import CloudConnectionOut" backend/` returns exactly one match (cloud.py)
- `backend/api/admin.py` still contains the original `class CloudConnectionOut` definition (deletion deferred to plan 08-04)
</verification>
<success_criteria>
- `backend/api/schemas.py` exists with `CloudConnectionOut` defined correctly
- `backend/api/cloud.py` imports from `api.schemas`
- Cloud and admin tests continue to pass
- No other file is modified
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-02-SUMMARY.md` when done. Include: confirmation that the original class in admin.py is left untouched, the exact `pytest tests/test_cloud.py tests/test_admin.py -v` summary line, and a note that plan 08-04 owns the deletion of the old admin.py definition.
</output>
@@ -0,0 +1,228 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 03
type: execute
wave: 1
depends_on: [08-01]
files_modified:
- frontend/src/stores/toast.js
- frontend/src/components/settings/SettingsAccountTab.vue
- frontend/src/components/auth/TotpEnrollment.vue
- backend/tests/test_auth.py
autonomous: true
requirements: [CR-01, CR-02, CR-03]
tags: [phase-7.1, toast-stub, session-revocation, frontend]
must_haves:
truths:
- "frontend/src/stores/toast.js exists, exports useToastStore, exposes show(message, type, duration)"
- "show() is a silent no-op in this phase (Phase 10 will render); calling it never throws"
- "SettingsAccountTab.vue calls toastStore.show('Other sessions have been terminated.', 'success') when data.sessions_revoked > 0 in both changePassword and disableTotp handlers"
- "TotpEnrollment.vue calls toastStore.show('Other sessions have been terminated.', 'success') when data.sessions_revoked > 0 in confirmEnrollment handler"
- "Inline sessionRevokedToast ref + setTimeout + inline toast template blocks are removed from both components"
- "CR-01, CR-02, CR-03 tests run as plain passing tests (xfail decorator removed)"
artifacts:
- path: "frontend/src/stores/toast.js"
provides: "Pinia toast store stub with the show() contract Phase 10 must honor"
contains: "export const useToastStore"
- path: "frontend/src/components/settings/SettingsAccountTab.vue"
provides: "Refactored to use toastStore.show() in changePassword + disableTotp handlers"
contains: "toastStore.show"
- path: "frontend/src/components/auth/TotpEnrollment.vue"
provides: "Refactored to use toastStore.show() in confirmEnrollment handler"
contains: "toastStore.show"
- path: "backend/tests/test_auth.py"
provides: "Three session-revocation tests promoted from xfail to passing"
contains: "test_change_password_revokes_other_sessions"
key_links:
- from: "SettingsAccountTab.vue changePassword handler"
to: "useToastStore.show"
via: "function call when data.sessions_revoked > 0"
pattern: "toastStore\\.show\\(['\"]Other sessions have been terminated"
- from: "TotpEnrollment.vue confirmEnrollment handler"
to: "useToastStore.show"
via: "function call when data.sessions_revoked > 0"
pattern: "toastStore\\.show\\(['\"]Other sessions have been terminated"
---
<objective>
Complete the absorbed Phase 7.1 work. The backend code for CR-01/CR-02/CR-03 is already implemented in `backend/api/auth.py` (RESEARCH.md confirmed lines 518, 615, 662). This plan: (1) creates the `useToastStore` Pinia stub per the UI-SPEC.md contract, (2) replaces the inline `sessionRevokedToast` ref + `setTimeout` pattern in `SettingsAccountTab.vue` and `TotpEnrollment.vue` with `toastStore.show(...)` calls, (3) removes the inline toast HTML blocks from both components, and (4) promotes the three CR xfail stubs from plan 08-01 to passing tests.
Purpose: Ship CR-01/CR-02/CR-03 to the user-visible behavior contract that the UI-SPEC.md locks (toast on `sessions_revoked > 0`), with the call signature Phase 10 must honor without modifying any Phase 8 call site.
Output: New toast store, refactored two Vue components, promoted three backend tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-UI-SPEC.md
@frontend/src/stores/topics.js
@frontend/src/components/settings/SettingsAccountTab.vue
@frontend/src/components/auth/TotpEnrollment.vue
@backend/tests/test_auth.py
<interfaces>
<!-- The locked store API contract from UI-SPEC.md §"useToastStore API Contract": -->
toastStore.show(message: string, type: 'success'|'error'|'info' = 'success', duration: number = 4000): void
<!-- Trigger map from UI-SPEC.md §"Sessions-Revoked Notification — Interaction Contract": -->
SettingsAccountTab.vue / changePassword() → on data.sessions_revoked > 0 → toastStore.show('Other sessions have been terminated.', 'success')
SettingsAccountTab.vue / disableTotp() → on data.sessions_revoked > 0 → toastStore.show('Other sessions have been terminated.', 'success')
TotpEnrollment.vue / confirmEnrollment() → on data.sessions_revoked > 0 → toastStore.show('Other sessions have been terminated.', 'success')
<!-- Existing inline state to remove (from grep): -->
SettingsAccountTab.vue: lines 6, 17, 211, 226-227, 263-264 (sessionRevokedToast ref + setTimeout + inline HTML block)
TotpEnrollment.vue: lines 6, 15, 149, 175-176 (sessionRevokedToast ref + setTimeout + inline HTML block)
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create useToastStore Pinia stub at frontend/src/stores/toast.js</name>
<files>frontend/src/stores/toast.js</files>
<read_first>
- frontend/src/stores/topics.js (simplest existing Pinia store — copy the setup-store style with defineStore + named function exports)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-UI-SPEC.md §"useToastStore API Contract" (exact signature, default values, "MUST NOT" rules)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/stores/toast.js" (full stub template)
</read_first>
<behavior>
- Importing `useToastStore` from `'../stores/toast.js'` resolves successfully
- Calling `useToastStore().show('hello')` returns `undefined` and does not throw
- Calling `useToastStore().show('hello', 'error')` returns `undefined` and does not throw
- Calling `useToastStore().show('hello', 'success', 8000)` returns `undefined` and does not throw
- The store does not render any DOM element (verified visually — Phase 10 implements rendering)
</behavior>
<action>
Create `frontend/src/stores/toast.js`. Use the setup-store style (`defineStore('toast', () => { ... })`). Define a single function `show(message, type = 'success', duration = 4000)` with positional parameters only (the UI-SPEC explicitly forbids object-argument shape `show({message, type})`). The function body is empty (no-op stub). Return `{ show }` so the store exposes the method. Add a top-of-file docstring (JS comment block) stating: Phase 7.1 STUB — Phase 10 (UX-10) fills in the full implementation; the signature `show(message, type, duration)` is locked and Phase 10 must honor it without modifying Phase 8 call sites. Default values match UI-SPEC.md: `type = 'success'` (NOT `'info'`), `duration = 4000`. Export as named export: `export const useToastStore = defineStore(...)`. Do NOT import or depend on any component.
</action>
<verify>
<automated>cd frontend && node -e "import('./src/stores/toast.js').then(m => { const s = m.useToastStore; if (typeof s !== 'function') throw new Error('useToastStore not a function'); console.log('ok'); })" 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- File `frontend/src/stores/toast.js` exists
- `grep -c "export const useToastStore" frontend/src/stores/toast.js` returns 1
- `grep -c "defineStore('toast'" frontend/src/stores/toast.js` returns 1
- `grep -c "function show(message, type = 'success', duration = 4000)" frontend/src/stores/toast.js` returns 1
- `cd frontend && npm test -- --run stores/toast 2>/dev/null || true` does not throw an import error
- The file contains NO `import` of any Vue component or DOM API
</acceptance_criteria>
<done>toast.js exists with the exact UI-SPEC signature; the module imports cleanly; show() is a silent no-op.</done>
</task>
<task type="auto">
<name>Task 2: Refactor SettingsAccountTab.vue and TotpEnrollment.vue to use toastStore</name>
<files>frontend/src/components/settings/SettingsAccountTab.vue, frontend/src/components/auth/TotpEnrollment.vue</files>
<read_first>
- frontend/src/components/settings/SettingsAccountTab.vue (full file — current inline toast HTML at lines 5-25, `sessionRevokedToast` ref at line 211, setTimeouts in changePassword at 225-228 and disableTotp at 261-264)
- frontend/src/components/auth/TotpEnrollment.vue (full file — current inline toast HTML at lines 5-23, `sessionRevokedToast` ref at line 149, setTimeout in confirmEnrollment at 174-177)
- frontend/src/stores/toast.js (the stub created in task 1)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-UI-SPEC.md §"Sessions-Revoked Notification — Interaction Contract" (message copy locked exactly: 'Other sessions have been terminated.')
</read_first>
<action>
For `frontend/src/components/settings/SettingsAccountTab.vue`:
1. Delete the inline `<div v-if="sessionRevokedToast" ...>...</div>` template block (lines ~5-25 — the fixed-position toast).
2. Delete the line `const sessionRevokedToast = ref(false)` (~line 211).
3. In the `changePassword` handler (~lines 220-230), replace `sessionRevokedToast.value = true; setTimeout(() => { sessionRevokedToast.value = false }, 5000)` with `toastStore.show('Other sessions have been terminated.', 'success')`. Keep the surrounding `if (data.sessions_revoked > 0) { ... }` guard.
4. In the `disableTotp` handler (~lines 258-267), apply the same replacement.
5. Add `import { useToastStore } from '../../stores/toast.js'` at the top with the other imports.
6. Add `const toastStore = useToastStore()` near the other store/ref declarations in `<script setup>`.
7. If `ref` is no longer used anywhere else in the file, remove it from the `vue` import; otherwise leave the import untouched.
For `frontend/src/components/auth/TotpEnrollment.vue`:
1. Delete the inline `<div v-if="sessionRevokedToast" ...>...</div>` template block (lines ~5-23).
2. Delete the line `const sessionRevokedToast = ref(false)` (~line 149).
3. In the `confirmEnrollment` handler (~lines 170-180), replace the `sessionRevokedToast.value = true; setTimeout(...)` block with `toastStore.show('Other sessions have been terminated.', 'success')`. Keep the surrounding `if (data.sessions_revoked > 0) { ... }` guard.
4. Add `import { useToastStore } from '../../stores/toast.js'` at the top.
5. Add `const toastStore = useToastStore()` near the other store/ref declarations.
6. Same `ref` cleanup rule as above.
The message string MUST be exactly `'Other sessions have been terminated.'` (matches UI-SPEC.md and the previously displayed inline copy). The `type` argument MUST be `'success'` (not `'info'`). Do NOT pass a `duration` argument — let the default 4000ms apply.
Update any existing Vitest tests (`SettingsAccountTab.test.js`, `TotpEnrollment.test.js`) ONLY if they explicitly assert on `sessionRevokedToast` or the inline DOM block. Mock `useToastStore` via `vi.mock('../../stores/toast.js', () => ({ useToastStore: () => ({ show: vi.fn() }) }))` and assert the mock was called with the locked message string. If tests already pass after the refactor without changes, do not touch them.
</action>
<verify>
<automated>cd frontend && npm test 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `grep -c "sessionRevokedToast" frontend/src/components/settings/SettingsAccountTab.vue` returns 0
- `grep -c "sessionRevokedToast" frontend/src/components/auth/TotpEnrollment.vue` returns 0
- `grep -c "toastStore.show('Other sessions have been terminated\\.', 'success')" frontend/src/components/settings/SettingsAccountTab.vue` returns 2 (changePassword + disableTotp)
- `grep -c "toastStore.show('Other sessions have been terminated\\.', 'success')" frontend/src/components/auth/TotpEnrollment.vue` returns 1 (confirmEnrollment)
- `grep -c "from '../../stores/toast.js'" frontend/src/components/settings/SettingsAccountTab.vue` returns 1
- `grep -c "from '../../stores/toast.js'" frontend/src/components/auth/TotpEnrollment.vue` returns 1
- `grep -c "setTimeout" frontend/src/components/settings/SettingsAccountTab.vue` returns 0 (the only setTimeouts in this file were for the toast)
- `cd frontend && npm test` exits 0
</acceptance_criteria>
<done>Both components removed the inline ref/setTimeout/HTML, both wire to toastStore.show with the locked copy, frontend test suite passes.</done>
</task>
<task type="auto">
<name>Task 3: Promote three CR test stubs in test_auth.py from xfail to passing</name>
<files>backend/tests/test_auth.py</files>
<read_first>
- backend/tests/test_auth.py (find the three xfail-decorated tests added in plan 08-01: test_change_password_revokes_other_sessions, test_enable_totp_revokes_other_sessions, test_disable_totp_revokes_other_sessions)
</read_first>
<action>
For each of the three tests added in plan 08-01, remove the `@pytest.mark.xfail(reason="Wave 0 stub — promoted to passing in 08-03", strict=False)` decorator line. Leave the function bodies untouched (the assertion logic was already complete in plan 08-01). Run the three tests in strict mode to confirm they pass against the existing backend implementation. Do not modify any other test or any production code.
</action>
<verify>
<automated>cd backend && pytest tests/test_auth.py::test_change_password_revokes_other_sessions tests/test_auth.py::test_enable_totp_revokes_other_sessions tests/test_auth.py::test_disable_totp_revokes_other_sessions -x -v</automated>
</verify>
<acceptance_criteria>
- `grep -B1 "def test_change_password_revokes_other_sessions" backend/tests/test_auth.py | grep -c "xfail"` returns 0
- `grep -B1 "def test_enable_totp_revokes_other_sessions" backend/tests/test_auth.py | grep -c "xfail"` returns 0
- `grep -B1 "def test_disable_totp_revokes_other_sessions" backend/tests/test_auth.py | grep -c "xfail"` returns 0
- Pytest output shows all three test IDs with status `PASSED` (not XFAIL, not XPASS, not SKIPPED)
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline
</acceptance_criteria>
<done>Three xfail decorators removed; three tests pass strictly; full backend suite green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Vue component → Pinia toast store | In-process function call; no untrusted input crosses |
| Backend response (sessions_revoked) → frontend toast trigger | Backend value is already validated server-side; frontend only uses the boolean test `> 0` |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-03-01 | Spoofing | Toast message injection | mitigate | Message string is a literal in the call sites, not user-controlled; Phase 10 contract specifies plain text only (no HTML) |
| T-08-03-02 | Repudiation | Audit log for revoked sessions | mitigate | Backend (api/auth.py) already writes audit log with `metadata_={"sessions_revoked": revoked}` for all three handlers; this plan does not alter audit behavior |
| T-08-03-03 | Information Disclosure | Toast leaks session count | accept | UI shows generic "Other sessions have been terminated."; the exact count is not revealed to the UI |
| T-08-03-04 | Tampering | xfail strict=False masks regression | mitigate | Decorator removed in task 3; subsequent pytest runs are strict |
| T-08-03-SC | Supply Chain | pinia, vue, pytest unchanged | accept | No new packages added |
</threat_model>
<verification>
- `cd frontend && npm test` — zero failures
- `cd backend && pytest tests/test_auth.py -v` — three new tests show PASSED (not XPASS / XFAIL)
- Visual smoke (recommended for executor, not gating): `cd frontend && npm run dev`, change password while logged in on two browsers; the second browser session should be revoked (this is backend behavior — Phase 10 will make the toast visible)
- `grep -rn "sessionRevokedToast" frontend/src/` returns no matches
</verification>
<success_criteria>
- toast.js stub exists with the UI-SPEC signature
- Both components wire to toastStore.show with the locked message
- All three CR tests pass strictly (no xfail)
- Full backend + frontend test suites pass
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-03-SUMMARY.md` when done. Include: exact final test output for the three CR tests, confirmation that no other components were touched, and a flagged forward-reference to Phase 10 (UX-10) that the toast store contract is locked.
</output>
@@ -0,0 +1,282 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 04
type: execute
wave: 2
depends_on: [08-02, 08-03]
files_modified:
- backend/api/admin/__init__.py
- backend/api/admin/users.py
- backend/api/admin/quotas.py
- backend/api/admin/ai.py
- backend/api/admin/shared.py
- backend/api/admin.py
- backend/services/ai_config.py
autonomous: true
requirements: [CODE-01, CODE-08]
tags: [backend-decomposition, admin, sub-router]
must_haves:
truths:
- "backend/api/admin/ is a Python package (has __init__.py) with sub-modules users.py, quotas.py, ai.py, shared.py"
- "All admin endpoints respond on the same URL paths as before (no doubled segments, no missing routes)"
- "Every admin sub-router handler explicitly injects _admin: User = Depends(get_current_admin)"
- "CloudConnectionOut is defined exactly once in the codebase (in api/schemas.py); the old definition in admin.py is deleted along with admin.py"
- "No sub-router declares a prefix on APIRouter(); only api/admin/__init__.py carries prefix=/api/admin"
- "validate_provider_id helper lives in services/ai_config.py; both SystemAiConfigUpdate and TestConnectionRequest call it"
- "Old backend/api/admin.py monolith file is deleted (replaced by the package)"
- "main.py import `from api.admin import router as admin_router` continues to work because api/admin/__init__.py re-exports router"
artifacts:
- path: "backend/api/admin/__init__.py"
provides: "Router aggregator with prefix=/api/admin; includes users_router, quotas_router, ai_router"
contains: "router = APIRouter(prefix"
- path: "backend/api/admin/users.py"
provides: "User CRUD + AI per-user config + create_system_topic handlers"
contains: "async def list_users"
- path: "backend/api/admin/quotas.py"
provides: "Per-user quota GET + PATCH handlers"
contains: "async def get_user_quota"
- path: "backend/api/admin/ai.py"
provides: "System AI config GET/PUT, test-connection, models endpoints"
contains: "async def get_ai_config"
- path: "backend/api/admin/shared.py"
provides: "_user_to_dict helper shared between users.py and quotas.py"
contains: "def _user_to_dict"
- path: "backend/services/ai_config.py"
provides: "validate_provider_id helper migrated from inline router validators per D-11"
contains: "def validate_provider_id"
key_links:
- from: "backend/main.py"
to: "backend/api/admin/__init__.py"
via: "from api.admin import router as admin_router"
pattern: "from api.admin import router as admin_router"
- from: "backend/api/admin/__init__.py"
to: "backend/api/admin/{users,quotas,ai}.py"
via: "include_router calls"
pattern: "router\\.include_router"
- from: "backend/api/admin/users.py and quotas.py"
to: "backend/api/admin/shared.py"
via: "from api.admin.shared import _user_to_dict"
pattern: "from api.admin.shared import"
---
<objective>
Decompose the 934-line `backend/api/admin.py` monolith into a focused Python package `backend/api/admin/` containing `users.py`, `quotas.py`, `ai.py` per locked decision D-05. Move shared helpers to `shared.py`. Migrate the duplicated `provider_must_be_known` validator to `services/ai_config.py` per D-11. Delete the old monolith. Zero URL changes, zero behavior changes — `pytest tests/test_admin.py -x` must pass identically before and after.
Purpose: CODE-01 (decomposition) + CODE-08 (single definition of the `provider_must_be_known` rule, plus the already-completed `CloudConnectionOut` migration from plan 08-02).
Output: `backend/api/admin/` package; one new helper in `services/ai_config.py`; `backend/api/admin.py` monolith file deleted.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@backend/api/admin.py
@backend/api/folders.py
@backend/api/cloud.py
@backend/api/schemas.py
@backend/services/ai_config.py
@backend/ai/provider_config.py
@backend/main.py
@CLAUDE.md
<interfaces>
Endpoint to sub-module map (locked per RESEARCH.md §"Recommended sub-module assignment"):
users.py: list_users (GET /users), create_user (POST /users), update_user_status (PATCH /users/{id}/status),
initiate_password_reset (POST /users/{id}/password-reset), update_ai_config (PATCH /users/{id}/ai-config),
delete_user (DELETE /users/{id}), create_system_topic (POST /topics)
quotas.py: get_user_quota (GET /users/{id}/quota), update_user_quota (PATCH /users/{id}/quota)
ai.py: get_ai_config_models (GET /ai-config/models), test_ai_connection (POST /ai-config/test-connection),
get_ai_config (GET /ai-config), update_system_ai_config (PUT /ai-config)
Pydantic model to file map:
users.py: UserCreate, UserStatusUpdate, UserAiConfigUpdate, UserDeleteConfirm, SystemTopicCreate
quotas.py: QuotaUpdate
ai.py: SystemAiConfigUpdate, TestConnectionRequest
Helper map:
shared.py: _user_to_dict (used by users.py and quotas.py)
ai.py: _ai_config_to_dict (used only by ai.py — keeps it local)
New helper to add (D-11 migration) in backend/services/ai_config.py:
def validate_provider_id(v: str) -> str:
"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""
if v not in PROVIDER_DEFAULTS:
raise ValueError(f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}")
return v
Package aggregator pattern for backend/api/admin/__init__.py:
from fastapi import APIRouter
from api.admin.users import router as users_router
from api.admin.quotas import router as quotas_router
from api.admin.ai import router as ai_router
router = APIRouter(prefix="/api/admin", tags=["admin"])
router.include_router(users_router)
router.include_router(quotas_router)
router.include_router(ai_router)
Sub-router pattern in each sub-module (D-04 — NO prefix on the sub-router):
router = APIRouter() # NO prefix — parent __init__.py carries it
@router.get("/users") # becomes /api/admin/users via parent
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add validate_provider_id helper to services/ai_config.py (D-11 migration)</name>
<files>backend/services/ai_config.py</files>
<read_first>
- backend/services/ai_config.py (current contents — confirm PROVIDER_DEFAULTS is already imported at line 34)
- backend/api/admin.py lines 147-179 (current inline validators in SystemAiConfigUpdate and TestConnectionRequest)
- backend/ai/provider_config.py (confirm PROVIDER_DEFAULTS keys)
</read_first>
<action>
Add a new top-level function `validate_provider_id(v: str) -> str` to `backend/services/ai_config.py`. The function MUST: (a) take a single string argument, (b) raise `ValueError(f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}")` if `v not in PROVIDER_DEFAULTS`, (c) otherwise return `v` unchanged. Place the function after the existing `load_provider_config` helpers, before any HKDF helpers. PROVIDER_DEFAULTS is already imported at line 34 of the file. Add a single-line docstring: `"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""`. Do not modify any existing function in this file.
</action>
<verify>
<automated>cd backend && python -c "from services.ai_config import validate_provider_id; assert validate_provider_id('openai') == 'openai'; thrown=False
try:
validate_provider_id('not-a-provider')
except ValueError as e:
assert 'Unknown provider_id' in str(e); thrown=True
assert thrown; print('ok')"</automated>
</verify>
<acceptance_criteria>
- `grep -c "^def validate_provider_id" backend/services/ai_config.py` returns 1
- Running `python -c "from services.ai_config import validate_provider_id; validate_provider_id('openai')"` from `backend/` exits 0
- Running `python -c "from services.ai_config import validate_provider_id; validate_provider_id('xxx')"` from `backend/` exits non-zero with `ValueError`
- No existing function in `backend/services/ai_config.py` was modified (verify by reading the file)
</acceptance_criteria>
<done>validate_provider_id function exists, raises ValueError per CLAUDE.md service-layer rule, importable.</done>
</task>
<task type="auto">
<name>Task 2: Create backend/api/admin/ package — shared.py, users.py, quotas.py, ai.py, __init__.py</name>
<files>backend/api/admin/__init__.py, backend/api/admin/shared.py, backend/api/admin/users.py, backend/api/admin/quotas.py, backend/api/admin/ai.py</files>
<read_first>
- backend/api/admin.py lines 1-934 (full source — every endpoint, every Pydantic model, every helper)
- backend/api/folders.py (analog sub-router structure, ownership check pattern, error handling style)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md sections for `backend/api/admin/*` (exact import lists, sub-router declaration, auth guard, ValueError-to-HTTPException bridge)
- CLAUDE.md §"Backend: shared module map" (must not violate; helpers go in deps/utils.py / services/auth.py / api/admin/shared.py — never duplicated)
</read_first>
<action>
This task creates the package `backend/api/admin/` and ALL five files in one logical unit. Execute in this order so the package is in a valid state on disk after each sub-step:
(A) Create `backend/api/admin/shared.py` first. Add `from __future__ import annotations`. Import `User`, `SystemSettings` from `db.models`. Define `_user_to_dict(user: User) -> dict` exactly per the body at `backend/api/admin.py` lines 75-91 (copy verbatim — same field set, same `created_at.isoformat()` handling, same docstring). The `_ai_config_to_dict` helper does NOT go in shared.py — it goes in ai.py because only ai.py uses it.
(B) Create `backend/api/admin/users.py`. Header: `from __future__ import annotations`. Imports per PATTERNS.md §"backend/api/admin/users.py" plus `from api.admin.shared import _user_to_dict`. Declare `router = APIRouter()` with NO prefix (D-04). Move these Pydantic models verbatim from `backend/api/admin.py`: `UserCreate` (lines 96-107), `UserStatusUpdate` (109-111), `UserAiConfigUpdate` (124-127), `UserDeleteConfirm` (190-195), `SystemTopicCreate` (182-187). Move these handler functions verbatim including decorators from `backend/api/admin.py`: `list_users` (228-243), `create_user` (245-316), `update_user_status` (318-383), `initiate_password_reset` (385-415), `update_ai_config` (494-534 — the per-user one), `delete_user` (536-637 — confirm exact end line by reading source), `create_system_topic` (639-662). Every handler keeps its `_admin: User = Depends(get_current_admin)` injection — never omit.
(C) Create `backend/api/admin/quotas.py`. Header + imports per PATTERNS.md §"backend/api/admin/quotas.py", plus `from api.admin.shared import _user_to_dict`. `router = APIRouter()` no prefix. Move `QuotaUpdate` Pydantic model (lines 113-121). Move handlers `get_user_quota` (417-439) and `update_user_quota` (441-492). Both inject `_admin: User = Depends(get_current_admin)`.
(D) Create `backend/api/admin/ai.py`. Header + imports per PATTERNS.md §"backend/api/admin/ai.py". Add `from services.ai_config import encrypt_api_key, load_provider_config_by_id, validate_provider_id` (note the new `validate_provider_id` from task 1). `router = APIRouter()` no prefix. Move `_ai_config_to_dict` helper (lines 58-73) here as a module-level private helper (not in shared.py — only ai.py uses it). Move Pydantic models `SystemAiConfigUpdate` (129-155) and `TestConnectionRequest` (157-179). In both models, replace the inline `provider_must_be_known` validator body with `return validate_provider_id(v)` — the validator becomes a one-line call-through to the service-layer function (D-11). Move handlers `get_ai_config_models` (664-725), `test_ai_connection` (727-784), `get_ai_config` (786-824), `update_system_ai_config` (826-934). Every handler injects `_admin: User = Depends(get_current_admin)`.
(E) Create `backend/api/admin/__init__.py`. Contents per PATTERNS.md §"backend/api/admin/__init__.py": import APIRouter from fastapi, import `router as users_router`, `router as quotas_router`, `router as ai_router` from the three sub-modules, declare `router = APIRouter(prefix="/api/admin", tags=["admin"])`, call `router.include_router(users_router)`, `router.include_router(quotas_router)`, `router.include_router(ai_router)`. The `__init__.py` does ONLY router aggregation — no helpers, no models, no logic (Pitfall 2 prevention).
Do NOT delete `backend/api/admin.py` yet — that happens in task 3 after URL regression passes. Do NOT modify `backend/main.py` — the existing `from api.admin import router as admin_router` will continue to work after task 3 because the package's `__init__.py` exports `router`.
IMPORTANT URL regression: After creating the package, Python's import resolution prefers the package (directory with `__init__.py`) over the module (admin.py file) ONLY if both exist at the same level — but here they collide. To avoid an import ambiguity error, ensure the package directory is created and immediately rename `backend/api/admin.py` to `backend/api/admin_OLD_REMOVE_IN_TASK_3.py` as part of the same atomic edit. This sidelines the monolith without deleting it so task 3 can confirm tests pass and then delete the renamed file.
</action>
<verify>
<automated>cd backend && python -c "from api.admin import router; print('admin routes:', len(router.routes))" && python -c "from api.admin.users import router as r; print('users routes:', len(r.routes))" && python -c "from api.admin.quotas import router as r; print('quotas routes:', len(r.routes))" && python -c "from api.admin.ai import router as r; print('ai routes:', len(r.routes))"</automated>
</verify>
<acceptance_criteria>
- Files exist: `backend/api/admin/__init__.py`, `backend/api/admin/shared.py`, `backend/api/admin/users.py`, `backend/api/admin/quotas.py`, `backend/api/admin/ai.py`
- `grep -v '^#' backend/api/admin/__init__.py | grep -c 'router = APIRouter(prefix="/api/admin"'` returns 1
- `grep -v '^#' backend/api/admin/users.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/admin/quotas.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/admin/ai.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -c "Depends(get_current_admin)" backend/api/admin/users.py` ≥ 7 (one per handler)
- `grep -c "Depends(get_current_admin)" backend/api/admin/quotas.py` ≥ 2
- `grep -c "Depends(get_current_admin)" backend/api/admin/ai.py` ≥ 4
- `grep -c "return validate_provider_id(v)" backend/api/admin/ai.py` returns 2 (SystemAiConfigUpdate + TestConnectionRequest)
- `cd backend && python -c "from api.admin import router; assert len(router.routes) >= 13"` exits 0 (total: 7 users + 2 quotas + 4 ai = 13)
- `cd backend && python -c "from main import app; routes = [r.path for r in app.routes]; assert '/api/admin/users' in routes" 2>&1 | tail -3` shows no AssertionError
</acceptance_criteria>
<done>Package exists with all five files; sub-router count totals 13; no sub-router carries a prefix; main.py imports still resolve; admin.py monolith is renamed but not yet deleted.</done>
</task>
<task type="auto">
<name>Task 3: Run URL regression suite, then delete the old monolith</name>
<files>backend/api/admin.py</files>
<read_first>
- backend/tests/test_admin.py (full file — confirm test surface)
- backend/api/admin_OLD_REMOVE_IN_TASK_3.py (the renamed monolith from task 2)
</read_first>
<action>
Run `cd backend && pytest tests/test_admin.py tests/test_cloud.py tests/test_admin_ai_config.py -x -v` to confirm every admin URL still responds correctly (URLs unchanged, response shapes unchanged, auth guards still effective). If ANY test fails, do NOT delete the renamed monolith — diagnose, fix, and re-run. Only after all admin + admin_ai_config + cloud tests pass: delete `backend/api/admin_OLD_REMOVE_IN_TASK_3.py` (the renamed monolith from task 2). The old file is now superseded by the package. Re-run the full backend suite `cd backend && pytest -v` to confirm no other tests regressed.
</action>
<verify>
<automated>cd backend && pytest tests/test_admin.py tests/test_cloud.py tests/test_admin_ai_config.py -x -v 2>&1 | tail -15 && test ! -f backend/api/admin.py && test ! -f backend/api/admin_OLD_REMOVE_IN_TASK_3.py && echo "old monolith deleted"</automated>
</verify>
<acceptance_criteria>
- `test ! -f backend/api/admin.py` exits 0 (monolith deleted)
- `test ! -f backend/api/admin_OLD_REMOVE_IN_TASK_3.py` exits 0 (renamed monolith also deleted)
- `test -d backend/api/admin` exits 0 (package directory exists)
- `cd backend && pytest tests/test_admin.py -x` exits 0
- `cd backend && pytest tests/test_cloud.py -x` exits 0 (because plan 08-02 already migrated CloudConnectionOut to api/schemas.py)
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline before this plan
- `grep -rn "class CloudConnectionOut" backend/` returns exactly one match — `backend/api/schemas.py`
- `grep -rn "^class UserCreate" backend/api/admin/` returns exactly one match (in users.py); same single-definition check for `QuotaUpdate`, `SystemAiConfigUpdate`, `TestConnectionRequest`
</acceptance_criteria>
<done>Old admin.py file deleted; admin/cloud/ai-config test suites pass; full backend suite green; each Pydantic model defined exactly once.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Unauthenticated/regular user → admin endpoints | All sub-router handlers must enforce `get_current_admin` to prevent privilege escalation |
| Admin endpoint → CloudConnectionOut serialization | SEC-08: `credentials_enc` must remain absent from the schema after the move |
| Admin endpoint → response body | T-02-27 / SEC-07: `_user_to_dict` must continue to exclude `password_hash`, `credentials_enc`, `totp_secret`, document content |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-04-01 | Spoofing/Elevation of Privilege | Admin sub-router auth gate | mitigate | Every handler in users.py / quotas.py / ai.py declares `_admin: User = Depends(get_current_admin)`. Acceptance criterion counts these injections. |
| T-08-04-02 | Tampering | Sub-router prefix doubling | mitigate | Sub-routers declare `router = APIRouter()` with NO prefix per D-04; only `__init__.py` carries `prefix="/api/admin"`. Acceptance criterion greps for this. |
| T-08-04-03 | Information Disclosure | `_user_to_dict` field set | mitigate | Helper copied verbatim from admin.py lines 75-91; existing tests covering admin user list endpoint guard against accidental field inclusion. |
| T-08-04-04 | Denial of Service | Circular import via `__init__.py` | mitigate | `__init__.py` does ONLY aggregation; `_user_to_dict` lives in `shared.py` not `__init__.py` (Pitfall 2). |
| T-08-04-05 | Tampering | Validator duplication | mitigate | `provider_must_be_known` migrated to `services/ai_config.py` as `validate_provider_id`; both Pydantic models call the same service helper (D-11). |
| T-08-04-06 | Information Disclosure | Old admin.py left in place | mitigate | Task 3 deletes the file only after URL regression tests pass; renamed file as an intermediate state cannot be imported because no code imports from `api.admin_OLD_REMOVE_IN_TASK_3`. |
| T-08-04-SC | Supply Chain | No new packages | accept | This plan installs zero new packages. |
</threat_model>
<verification>
- `cd backend && pytest tests/test_admin.py tests/test_admin_ai_config.py tests/test_cloud.py -x -v` — zero failures
- `cd backend && pytest -v` — full suite zero failures
- `grep -rn "from api.admin import CloudConnectionOut" backend/` returns no matches
- `grep -rn "class CloudConnectionOut" backend/` returns exactly one match (api/schemas.py)
- `cd backend && python -c "from main import app; admin_paths = sorted({r.path for r in app.routes if r.path.startswith('/api/admin')}); print('\\n'.join(admin_paths))"` lists at minimum: `/api/admin/users`, `/api/admin/users/{user_id}/status`, `/api/admin/users/{user_id}/quota`, `/api/admin/users/{user_id}/ai-config`, `/api/admin/users/{user_id}/password-reset`, `/api/admin/users/{user_id}`, `/api/admin/topics`, `/api/admin/ai-config`, `/api/admin/ai-config/models`, `/api/admin/ai-config/test-connection`
</verification>
<success_criteria>
- `backend/api/admin/` package with 5 files
- `backend/api/admin.py` deleted
- All admin URL paths unchanged
- CODE-08: CloudConnectionOut + provider_must_be_known single-defined
- All tests pass
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-04-SUMMARY.md` when done. Include: (a) full list of admin paths emitted by `app.routes` before vs. after (must be identical), (b) exact admin/cloud/ai-config test counts pre vs. post, (c) confirmation that `_admin: User = Depends(get_current_admin)` count matches the number of handlers per module.
</output>
@@ -0,0 +1,219 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 05
type: execute
wave: 2
depends_on: [08-03]
files_modified:
- backend/api/documents/__init__.py
- backend/api/documents/upload.py
- backend/api/documents/crud.py
- backend/api/documents/content.py
- backend/api/documents/shared.py
- backend/api/documents.py
autonomous: true
requirements: [CODE-02, CODE-08]
tags: [backend-decomposition, documents, sub-router]
must_haves:
truths:
- "backend/api/documents/ is a Python package with sub-modules upload.py, crud.py, content.py, shared.py and __init__.py"
- "All document endpoints respond on the same URL paths as before"
- "Every document endpoint enforces get_current_user (regular user) and the ownership assertion `resource.user_id == current_user.id`"
- "_CLOUD_PROVIDERS frozenset and shared Pydantic models live in shared.py, not duplicated across sub-modules"
- "No sub-router declares a prefix; only api/documents/__init__.py carries prefix=/api/documents"
- "Old backend/api/documents.py monolith is deleted"
- "main.py import `from api.documents import router as documents_router` continues to work"
artifacts:
- path: "backend/api/documents/__init__.py"
provides: "Router aggregator with prefix=/api/documents; includes upload_router, crud_router, content_router"
contains: "router = APIRouter(prefix"
- path: "backend/api/documents/upload.py"
provides: "Presigned URL + direct upload + confirm handlers (POST /upload-url, POST /upload, POST /{id}/confirm)"
contains: "async def request_upload_url"
- path: "backend/api/documents/crud.py"
provides: "List/get/patch/delete + re-classify endpoint per D-08"
contains: "async def list_documents"
- path: "backend/api/documents/content.py"
provides: "Range-aware content streaming endpoint + _parse_range helper"
contains: "async def stream_document_content"
- path: "backend/api/documents/shared.py"
provides: "UploadUrlRequest, DocumentPatch Pydantic models, _CLOUD_PROVIDERS constant"
contains: "_CLOUD_PROVIDERS = frozenset"
key_links:
- from: "backend/main.py"
to: "backend/api/documents/__init__.py"
via: "from api.documents import router as documents_router"
pattern: "from api.documents import router as documents_router"
- from: "backend/api/documents/upload.py and crud.py"
to: "backend/api/documents/shared.py"
via: "from api.documents.shared import _CLOUD_PROVIDERS, UploadUrlRequest, DocumentPatch"
pattern: "from api.documents.shared import"
---
<objective>
Decompose the 852-line `backend/api/documents.py` monolith into the focused package `backend/api/documents/` per locked D-06 (4 sub-modules) and D-08 (re-classify endpoint placement is researcher/planner choice — placed in `crud.py` because it operates on an existing document with the same ownership-check pattern as get/patch/delete). Zero URL changes, zero behavior changes.
Purpose: CODE-02 (decomposition) + CODE-08 (single-definition discipline for `UploadUrlRequest`, `DocumentPatch`, `_CLOUD_PROVIDERS`).
Output: `backend/api/documents/` package; `backend/api/documents.py` monolith deleted.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@backend/api/documents.py
@backend/api/folders.py
@backend/main.py
@CLAUDE.md
<interfaces>
Endpoint to sub-module map (locked per RESEARCH.md §"api/documents.py → api/documents/ package"):
upload.py: request_upload_url (POST /upload-url, line 93-135), upload_document (POST /upload, line 137-296),
confirm_upload (POST /{id}/confirm, line 298-404)
crud.py: list_documents (GET "", line 406-529), get_document (GET /{id}, line 531-576),
patch_document (PATCH /{id}, line 578-630), delete_document (DELETE /{id}, line 632-705),
classify_document (POST /{id}/classify, line 707-742) — placed here per D-08
content.py: stream_document_content (GET /{id}/content, line 765 onwards) + _parse_range helper (line 744-760)
Pydantic model + constant to file map:
shared.py: _CLOUD_PROVIDERS frozenset (line 59), UploadUrlRequest (line 66-69), DocumentPatch (line 71-88)
Package aggregator pattern for backend/api/documents/__init__.py:
from fastapi import APIRouter
from api.documents.upload import router as upload_router
from api.documents.crud import router as crud_router
from api.documents.content import router as content_router
router = APIRouter(prefix="/api/documents", tags=["documents"])
router.include_router(upload_router)
router.include_router(crud_router)
router.include_router(content_router)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create backend/api/documents/ package — shared.py, upload.py, crud.py, content.py, __init__.py</name>
<files>backend/api/documents/__init__.py, backend/api/documents/shared.py, backend/api/documents/upload.py, backend/api/documents/crud.py, backend/api/documents/content.py</files>
<read_first>
- backend/api/documents.py lines 1-852 (full source — every endpoint, every Pydantic model, every helper, every import)
- backend/api/folders.py (ownership assertion pattern: `if doc is None or doc.user_id != current_user.id: raise HTTPException(status_code=404, ...)`)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md sections for `backend/api/documents/*` (exact import lists, sub-router declaration, ownership assertion pattern)
- CLAUDE.md §"Key Architectural Rules" (atomic quota UPDATE pattern, ownership checks; preserved verbatim)
</read_first>
<action>
This task creates the package and ALL five files. Execute in this order so the package is in a valid state on disk after each sub-step:
(A) Create `backend/api/documents/shared.py`. Add `from __future__ import annotations`. Import `from typing import Optional` and `from pydantic import BaseModel, field_validator`. Move the constant `_CLOUD_PROVIDERS = frozenset({"google_drive", "onedrive", "nextcloud", "webdav"})` from `documents.py` line 59. Move `UploadUrlRequest` (line 66-69) and `DocumentPatch` (line 71-88) verbatim, including the `filename_no_path_separators` validator which is a security validator at the API boundary (RESEARCH.md confirms it stays in the Pydantic model — D-11 analysis).
(B) Create `backend/api/documents/upload.py`. Header: `from __future__ import annotations`. Imports per PATTERNS.md §"backend/api/documents/upload.py" plus `from api.documents.shared import UploadUrlRequest, _CLOUD_PROVIDERS`. `router = APIRouter()` — NO prefix (D-04). Move handler functions verbatim including decorators: `request_upload_url` (lines 93-135), `upload_document` (lines 137-296), `confirm_upload` (lines 298-404). Every handler injects `current_user: User = Depends(get_current_user)` and asserts ownership on any pre-existing document lookup using the pattern `if doc is None or doc.user_id != current_user.id: raise HTTPException(status_code=404, ...)`.
(C) Create `backend/api/documents/crud.py`. Header + imports per PATTERNS.md §"backend/api/documents/crud.py" plus `from api.documents.shared import DocumentPatch, _CLOUD_PROVIDERS`. `router = APIRouter()` — NO prefix. Move handlers verbatim: `list_documents` (406-529), `get_document` (531-576), `patch_document` (578-630), `delete_document` (632-705), `classify_document` (707-742) — placed here per D-08. Each handler injects `current_user: User = Depends(get_current_user)` and includes the ownership assertion.
(D) Create `backend/api/documents/content.py`. Header: `from __future__ import annotations`. Imports per PATTERNS.md §"backend/api/documents/content.py" (FastAPI APIRouter, Depends, HTTPException, Request; FastAPI responses StreamingResponse; SQLAlchemy AsyncSession; deps.auth.get_current_user; deps.db.get_db). `router = APIRouter()` — NO prefix. Move `_parse_range(range_header: str, file_size: int)` helper verbatim from line 744-760 as a private module-level function. Move `stream_document_content` handler verbatim from line 765 onwards. The handler injects `current_user: User = Depends(get_current_user)` and asserts ownership before serving bytes (DOC-04 / SEC-04).
(E) Create `backend/api/documents/__init__.py`. Contents per PATTERNS.md §"backend/api/documents/__init__.py": import APIRouter from fastapi, import `router as upload_router`, `router as crud_router`, `router as content_router` from the three sub-modules, declare `router = APIRouter(prefix="/api/documents", tags=["documents"])`, call `router.include_router(upload_router)`, `router.include_router(crud_router)`, `router.include_router(content_router)`. ONLY aggregation — no helpers, no models, no logic.
To avoid Python's package-vs-module name collision, immediately after creating the package directory, rename `backend/api/documents.py` to `backend/api/documents_OLD_REMOVE_IN_TASK_2.py`. Task 2 verifies tests pass and then deletes the renamed file.
The route registration order matters: `crud.py` registers `GET ""` (matches `/api/documents`), and `crud.py` registers `GET /{doc_id}` which could shadow `upload.py`'s `POST /upload-url` if route precedence is wrong. FastAPI matches by exact path + method, so HTTP-method differentiation prevents collision; verify by listing routes after include.
</action>
<verify>
<automated>cd backend && python -c "from api.documents import router; paths = sorted({(r.path, list(r.methods)[0] if r.methods else '') for r in router.routes}); print('\n'.join(f'{m} {p}' for p, m in paths))"</automated>
</verify>
<acceptance_criteria>
- Files exist: `backend/api/documents/__init__.py`, `backend/api/documents/shared.py`, `backend/api/documents/upload.py`, `backend/api/documents/crud.py`, `backend/api/documents/content.py`
- `grep -v '^#' backend/api/documents/__init__.py | grep -c 'router = APIRouter(prefix="/api/documents"'` returns 1
- `grep -v '^#' backend/api/documents/upload.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/documents/crud.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/documents/content.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -c "_CLOUD_PROVIDERS = frozenset" backend/api/documents/shared.py` returns 1
- `grep -rn "_CLOUD_PROVIDERS = frozenset" backend/api/documents/` returns exactly one match (in shared.py)
- `grep -c "Depends(get_current_user)" backend/api/documents/upload.py` ≥ 3
- `grep -c "Depends(get_current_user)" backend/api/documents/crud.py` ≥ 5
- `grep -c "Depends(get_current_user)" backend/api/documents/content.py` ≥ 1
- `grep -c "doc.user_id != current_user.id\\|user_id != current_user.id" backend/api/documents/crud.py` ≥ 3 (ownership checks on get/patch/delete and classify)
- `cd backend && python -c "from api.documents import router; assert len(router.routes) >= 9"` exits 0 (3 upload + 5 crud + 1 content = 9)
</acceptance_criteria>
<done>Package exists with all 5 files; sub-router count totals 9; no sub-router carries a prefix; ownership checks preserved; monolith renamed (not yet deleted).</done>
</task>
<task type="auto">
<name>Task 2: Run URL regression suite, then delete the old monolith</name>
<files>backend/api/documents.py</files>
<read_first>
- backend/tests/test_documents.py (full file — confirms which endpoints are exercised)
- backend/api/documents_OLD_REMOVE_IN_TASK_2.py (renamed monolith from task 1)
</read_first>
<action>
Run `cd backend && pytest tests/test_documents.py -x -v` to confirm every document URL still responds correctly (URLs unchanged, response shapes unchanged, ownership 404s preserved, quota behavior preserved). If ANY test fails, do NOT delete the renamed monolith — diagnose, fix, and re-run. Only after `tests/test_documents.py` and `tests/test_shares.py` (because shares.py uses Document lookups) both pass, delete `backend/api/documents_OLD_REMOVE_IN_TASK_2.py`. Re-run the full backend suite `cd backend && pytest -v` to confirm no other tests regressed.
</action>
<verify>
<automated>cd backend && pytest tests/test_documents.py tests/test_shares.py -x -v 2>&1 | tail -15 && test ! -f backend/api/documents.py && test ! -f backend/api/documents_OLD_REMOVE_IN_TASK_2.py && echo "old monolith deleted"</automated>
</verify>
<acceptance_criteria>
- `test ! -f backend/api/documents.py` exits 0
- `test ! -f backend/api/documents_OLD_REMOVE_IN_TASK_2.py` exits 0
- `test -d backend/api/documents` exits 0
- `cd backend && pytest tests/test_documents.py -x` exits 0
- `cd backend && pytest tests/test_shares.py -x` exits 0
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline
- `grep -rn "^class UploadUrlRequest" backend/api/documents/` returns exactly one match (in shared.py)
- `grep -rn "^class DocumentPatch" backend/api/documents/` returns exactly one match (in shared.py)
</acceptance_criteria>
<done>Old documents.py file deleted; full backend suite green; Pydantic models defined exactly once.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Regular user → document endpoints | Every handler enforces `get_current_user` AND `resource.user_id == current_user.id` to prevent IDOR (DOC-04 / SEC-04) |
| Browser → MinIO presigned PUT URL | Generated server-side scoped to `{user_id}/{document_id}/{uuid4}`; never includes admin or other user paths |
| Range-header → byte offset arithmetic | `_parse_range` must reject malformed input; copied verbatim from monolith |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-05-01 | Spoofing/Elevation | IDOR on document endpoints | mitigate | All handlers copied verbatim including `current_user.id` ownership assertion. Acceptance criterion grep counts ≥ 3 ownership checks in crud.py. |
| T-08-05-02 | Tampering | Sub-router prefix doubling | mitigate | All three sub-routers declare `router = APIRouter()` with NO prefix; only `__init__.py` carries `prefix="/api/documents"`. |
| T-08-05-03 | Information Disclosure | DocumentPatch.filename validator | mitigate | `filename_no_path_separators` validator preserved verbatim in shared.py (path traversal defense). |
| T-08-05-04 | Tampering | Atomic quota UPDATE invariant | mitigate | `confirm_upload` and `delete_document` copied verbatim — atomic `UPDATE quotas SET used_bytes = used_bytes + $delta WHERE …` pattern preserved (CLAUDE.md non-negotiable). |
| T-08-05-05 | Denial of Service | Circular import via `__init__.py` | mitigate | `__init__.py` does ONLY aggregation; shared models in `shared.py`. |
| T-08-05-SC | Supply Chain | No new packages | accept | This plan installs zero new packages. |
</threat_model>
<verification>
- `cd backend && pytest tests/test_documents.py tests/test_shares.py -x -v` — zero failures
- `cd backend && pytest -v` — full suite zero failures
- `cd backend && python -c "from main import app; doc_paths = sorted({r.path for r in app.routes if r.path.startswith('/api/documents')}); print('\\n'.join(doc_paths))"` lists at minimum: `/api/documents`, `/api/documents/upload-url`, `/api/documents/upload`, `/api/documents/{doc_id}`, `/api/documents/{doc_id}/confirm`, `/api/documents/{doc_id}/classify`, `/api/documents/{doc_id}/content`
</verification>
<success_criteria>
- `backend/api/documents/` package with 5 files
- `backend/api/documents.py` deleted
- All document URL paths unchanged, response shapes unchanged
- Every handler preserves ownership assertion
- All tests pass
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-05-SUMMARY.md` when done. Include: (a) full list of document paths emitted by `app.routes` before vs. after (must be identical), (b) `tests/test_documents.py` test count, (c) confirmation that the `_CLOUD_PROVIDERS` constant exists exactly once in the package.
</output>
@@ -0,0 +1,273 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 06
type: execute
wave: 2
depends_on: [08-03]
files_modified:
- backend/api/auth/__init__.py
- backend/api/auth/tokens.py
- backend/api/auth/totp.py
- backend/api/auth/password.py
- backend/api/auth/shared.py
- backend/api/auth.py
autonomous: true
requirements: [CODE-03, CODE-08, CR-01, CR-02, CR-03]
tags: [backend-decomposition, auth, sub-router, session-revocation]
must_haves:
truths:
- "backend/api/auth/ is a Python package with sub-modules tokens.py, totp.py, password.py, shared.py and __init__.py"
- "All auth endpoints respond on the same URL paths as before, including rate-limit decorators"
- "limiter is defined in api/auth/shared.py and re-exported from api/auth/__init__.py so `from api.auth import limiter` continues to work in main.py and tests/conftest.py"
- "change_password (in password.py), enable_totp (in totp.py), and disable_totp (in totp.py) preserve the existing revoke_all_refresh_tokens(skip_token_hash=...) call exactly — CR-01/CR-02/CR-03 tests promoted in plan 08-03 must still pass"
- "No sub-router declares a prefix; only api/auth/__init__.py carries prefix=/api/auth"
- "Old backend/api/auth.py monolith is deleted"
- "Test file imports `from api.auth import limiter as auth_limiter` (conftest.py, test_auth_api.py, test_auth_totp.py, test_totp_replay.py, test_security_headers.py) continue to work without modification"
artifacts:
- path: "backend/api/auth/__init__.py"
provides: "Router aggregator with prefix=/api/auth; includes tokens_router, totp_router, password_router; re-exports limiter"
contains: "router = APIRouter(prefix"
- path: "backend/api/auth/tokens.py"
provides: "register, login, refresh, logout, logout-all, me, me/quota, me/preferences handlers"
contains: "async def login"
- path: "backend/api/auth/totp.py"
provides: "totp/setup, totp/enable, totp DELETE (disable) handlers"
contains: "async def enable_totp"
- path: "backend/api/auth/password.py"
provides: "change-password, password-reset, password-reset/confirm handlers"
contains: "async def change_password"
- path: "backend/api/auth/shared.py"
provides: "Limiter instance, request/response Pydantic models, _set_refresh_cookie, _user_dict helpers"
contains: "limiter = Limiter"
key_links:
- from: "backend/main.py"
to: "backend/api/auth/__init__.py"
via: "from api.auth import limiter as auth_limiter AND from api.auth import router as auth_router"
pattern: "from api.auth import (limiter as auth_limiter|router as auth_router)"
- from: "backend/tests/conftest.py and 4 other test files"
to: "backend/api/auth/__init__.py"
via: "from api.auth import limiter as auth_limiter"
pattern: "from api.auth import limiter as auth_limiter"
- from: "password.py change_password handler"
to: "services.auth.revoke_all_refresh_tokens"
via: "skip_token_hash=skip_hash argument"
pattern: "revoke_all_refresh_tokens\\(.*skip_token_hash"
---
<objective>
Decompose the 825-line `backend/api/auth.py` monolith into the focused package `backend/api/auth/` per locked D-07 (4 sub-modules: tokens, totp, password, plus shared.py for cross-module helpers; module names mirror `services/auth.py` logical groupings). The Limiter instance must remain importable as `from api.auth import limiter` to avoid touching 5 test files plus `main.py`. The CR-01/CR-02/CR-03 session-revocation behavior (already implemented and now covered by passing tests from plan 08-03) MUST continue to work unchanged.
Purpose: CODE-03 (decomposition) + CODE-08 (single definition of auth Pydantic request models in `shared.py`) while preserving CR-01/CR-02/CR-03 production behavior and its passing test coverage.
Output: `backend/api/auth/` package; `backend/api/auth.py` monolith deleted; all auth + CR + rate-limit + security-header tests still pass.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@backend/api/auth.py
@backend/services/auth.py
@backend/main.py
@backend/tests/conftest.py
@CLAUDE.md
<interfaces>
Endpoint to sub-module map (locked per RESEARCH.md §"api/auth.py → api/auth/ package"):
tokens.py: register (POST /register, line 110-188), login (POST /login, line 190-323),
refresh_token (POST /refresh, line 325-387), logout (POST /logout, line 389-422),
logout_all (POST /logout-all, line 424-449),
get_me (GET /me, line 451-457), get_my_quota (GET /me/quota, line 459-475),
get_my_preferences (GET /me/preferences, line 790-806),
update_my_preferences (PATCH /me/preferences, line 808 onwards)
totp.py: totp_setup (GET /totp/setup, line 557-577), enable_totp (POST /totp/enable, line 579-639),
disable_totp (DELETE /totp, line 641-685)
password.py: change_password (POST /change-password, line 477-540),
password_reset_request (POST /password-reset, line 687-720),
password_reset_confirm (POST /password-reset/confirm, line 722-778)
Pydantic model + helper map (shared.py):
shared.py: limiter (the slowapi Limiter instance, line 46),
RegisterRequest (line 51-55), LoginRequest (line 57-63), ChangePasswordRequest (line 65-69),
TotpEnableRequest (line 542-544), PasswordResetRequest (line 546-548),
PasswordResetConfirmRequest (line 550-554), PreferencesUpdate (line 780-787),
_set_refresh_cookie (line 72-94), _user_dict (line 96-105)
Package aggregator pattern for backend/api/auth/__init__.py — note the limiter re-export:
from fastapi import APIRouter
from api.auth.tokens import router as tokens_router
from api.auth.totp import router as totp_router
from api.auth.password import router as password_router
from api.auth.shared import limiter # re-exported so `from api.auth import limiter` still works
router = APIRouter(prefix="/api/auth", tags=["auth"])
router.include_router(tokens_router)
router.include_router(totp_router)
router.include_router(password_router)
Critical: 5 production/test files import `limiter`:
- backend/main.py line 21
- backend/tests/conftest.py line 222
- backend/tests/test_auth_api.py line 99
- backend/tests/test_auth_totp.py line 98
- backend/tests/test_totp_replay.py line 76
- backend/tests/test_security_headers.py line 72
None of these files may be modified by this plan — the `__init__.py` re-export keeps the import path stable.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create backend/api/auth/ package — shared.py, tokens.py, totp.py, password.py, __init__.py</name>
<files>backend/api/auth/__init__.py, backend/api/auth/shared.py, backend/api/auth/tokens.py, backend/api/auth/totp.py, backend/api/auth/password.py</files>
<read_first>
- backend/api/auth.py lines 1-825 (full source — every endpoint, every Pydantic model, every helper, every import including hashlib for skip_hash computation, time for Redis nbf updates)
- backend/services/auth.py (confirm `revoke_all_refresh_tokens(session, user_id, skip_token_hash=None)` signature; copy the calling pattern verbatim)
- backend/main.py lines 20-30 and 246-260 (confirm `from api.auth import limiter as auth_limiter` and `app.state.limiter = auth_limiter` wiring)
- backend/tests/conftest.py line 220-225 (auth_limiter reset hook)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md sections for `backend/api/auth/*` (exact import lists, rate-limit decorator pattern, ValueError-to-HTTPException bridge, session revocation pattern)
- CLAUDE.md §"Login token hardening" (must preserve: ES256, 15-min access TTL, JTI claim handling, fgp validation, refresh token rotation behavior in tokens.py)
</read_first>
<action>
This task creates the package and ALL five files. Execute in this order so the package is in a valid state on disk after each sub-step:
(A) Create `backend/api/auth/shared.py` first because every other sub-module imports from it. Add `from __future__ import annotations`. Imports: `from typing import Optional`, `from fastapi import Response`, `from pydantic import BaseModel, EmailStr`, `from slowapi import Limiter`, `from config import settings`, `from deps.utils import get_client_ip`. Then declare `limiter = Limiter(key_func=get_client_ip)` (line 46 of monolith). Move these Pydantic models verbatim from monolith: `RegisterRequest` (51-55), `LoginRequest` (57-63 — keep `remember_me: bool = False` field), `ChangePasswordRequest` (65-69), `TotpEnableRequest` (542-544), `PasswordResetRequest` (546-548), `PasswordResetConfirmRequest` (550-554), `PreferencesUpdate` (780-787). Move helpers verbatim: `_set_refresh_cookie(response, raw_token, remember_me=False)` (72-94 — note the remember_me-aware max_age branching from Phase 7.3) and `_user_dict(user)` (96-105).
(B) Create `backend/api/auth/tokens.py`. Header: `from __future__ import annotations`. Imports per PATTERNS.md §"backend/api/auth/tokens.py" plus `from api.auth.shared import limiter, RegisterRequest, LoginRequest, PreferencesUpdate, _set_refresh_cookie, _user_dict`. `router = APIRouter()` — NO prefix (D-04). Move handlers verbatim INCLUDING the `@limiter.limit("10/minute")` decorators: `register` (110-188), `login` (190-323), `refresh_token` (325-387), `logout` (389-422), `logout_all` (424-449), `get_me` (451-457), `get_my_quota` (459-475), `get_my_preferences` (790-806), `update_my_preferences` (808 onwards). The ES256 + JTI + fgp + Redis user_nbf logic in `login`/`refresh_token` is preserved verbatim (CLAUDE.md non-negotiable).
(C) Create `backend/api/auth/totp.py`. Header + imports per PATTERNS.md §"backend/api/auth/totp.py" plus `from api.auth.shared import limiter, TotpEnableRequest, _set_refresh_cookie`. `router = APIRouter()` — NO prefix. Move handlers verbatim: `totp_setup` (557-577), `enable_totp` (579-639), `disable_totp` (641-685). The CR-02 (enable_totp) and CR-03 (disable_totp) skip-hash + revoke_all_refresh_tokens + sessions_revoked logic is preserved verbatim — these handlers are covered by passing tests from plan 08-03, which this plan MUST not break. Copy `hashlib` and `time` imports as needed at the top.
(D) Create `backend/api/auth/password.py`. Header + imports per PATTERNS.md §"backend/api/auth/password.py" plus `from api.auth.shared import limiter, ChangePasswordRequest, PasswordResetRequest, PasswordResetConfirmRequest`. `router = APIRouter()` — NO prefix. Move handlers verbatim: `change_password` (477-540), `password_reset_request` (687-720), `password_reset_confirm` (722-778). The CR-01 (change_password) skip-hash + revoke_all_refresh_tokens + sessions_revoked + Redis user_nbf logic is preserved verbatim from monolith lines 514-540.
(E) Create `backend/api/auth/__init__.py`. Contents:
from fastapi import APIRouter
from api.auth.tokens import router as tokens_router
from api.auth.totp import router as totp_router
from api.auth.password import router as password_router
from api.auth.shared import limiter # re-export
router = APIRouter(prefix="/api/auth", tags=["auth"])
router.include_router(tokens_router)
router.include_router(totp_router)
router.include_router(password_router)
The `from api.auth.shared import limiter` line makes `from api.auth import limiter` work for `main.py` and the 5 test files (because module attribute access resolves through `__init__.py`).
To avoid the package-vs-module name collision, immediately after creating the package directory, rename `backend/api/auth.py` to `backend/api/auth_OLD_REMOVE_IN_TASK_2.py`. Task 2 verifies tests pass and deletes the renamed file.
</action>
<verify>
<automated>cd backend && python -c "from api.auth import router, limiter; print('auth routes:', len(router.routes)); print('limiter type:', type(limiter).__name__)"</automated>
</verify>
<acceptance_criteria>
- Files exist: `backend/api/auth/__init__.py`, `backend/api/auth/shared.py`, `backend/api/auth/tokens.py`, `backend/api/auth/totp.py`, `backend/api/auth/password.py`
- `grep -v '^#' backend/api/auth/__init__.py | grep -c 'router = APIRouter(prefix="/api/auth"'` returns 1
- `grep -v '^#' backend/api/auth/tokens.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/auth/totp.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/auth/password.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -c "limiter = Limiter" backend/api/auth/shared.py` returns 1
- `grep -c "from api.auth.shared import limiter" backend/api/auth/__init__.py` returns 1 (re-export)
- `grep -c "skip_token_hash=skip_hash" backend/api/auth/password.py` returns 1 (CR-01 preserved)
- `grep -c "skip_token_hash=skip_hash" backend/api/auth/totp.py` returns 2 (CR-02 enable_totp + CR-03 disable_totp preserved)
- `cd backend && python -c "from api.auth import limiter, router; assert callable(getattr(limiter, 'limit', None)); assert len(router.routes) >= 14"` exits 0 (9 tokens + 3 totp + 3 password = at minimum 14, likely 15)
</acceptance_criteria>
<done>Package exists with all 5 files; limiter re-exported from __init__.py; CR-01/02/03 skip_token_hash calls preserved; monolith renamed (not yet deleted).</done>
</task>
<task type="auto">
<name>Task 2: Run URL + CR regression suite, then delete the old monolith</name>
<files>backend/api/auth.py</files>
<read_first>
- backend/tests/test_auth.py (the three CR tests from plan 08-03)
- backend/tests/test_auth_api.py (rate-limit tests)
- backend/tests/test_auth_totp.py (TOTP integration tests)
- backend/tests/test_totp_replay.py (replay-prevention tests)
- backend/tests/test_security_headers.py (CSP/security headers tests)
- backend/api/auth_OLD_REMOVE_IN_TASK_2.py (renamed monolith)
</read_first>
<action>
Run the affected test files in sequence to confirm decomposition preserves: (1) URL paths, (2) rate-limit behavior (limiter import path), (3) CR-01/CR-02/CR-03 session revocation, (4) TOTP replay prevention, (5) CSP headers.
Command: `cd backend && pytest tests/test_auth.py tests/test_auth_api.py tests/test_auth_totp.py tests/test_totp_replay.py tests/test_security_headers.py -x -v`.
If ANY test fails, diagnose, fix the sub-module, and re-run. Special attention: if a test fails with `ImportError: cannot import name 'limiter' from 'api.auth'`, the `__init__.py` re-export line is missing or misspelled. If a CR test fails, compare the exact `skip_hash` computation block in `password.py` / `totp.py` against the original monolith — every byte must match.
Only after all five test files pass: delete `backend/api/auth_OLD_REMOVE_IN_TASK_2.py`. Re-run `cd backend && pytest -v` to confirm no other tests regressed.
</action>
<verify>
<automated>cd backend && pytest tests/test_auth.py tests/test_auth_api.py tests/test_auth_totp.py tests/test_totp_replay.py tests/test_security_headers.py -x -v 2>&1 | tail -20 && test ! -f backend/api/auth.py && test ! -f backend/api/auth_OLD_REMOVE_IN_TASK_2.py && echo "old monolith deleted"</automated>
</verify>
<acceptance_criteria>
- `test ! -f backend/api/auth.py` exits 0
- `test ! -f backend/api/auth_OLD_REMOVE_IN_TASK_2.py` exits 0
- `test -d backend/api/auth` exits 0
- `cd backend && pytest tests/test_auth.py -x` exits 0 (includes the three promoted CR tests)
- `cd backend && pytest tests/test_auth_api.py -x` exits 0
- `cd backend && pytest tests/test_auth_totp.py -x` exits 0
- `cd backend && pytest tests/test_totp_replay.py -x` exits 0
- `cd backend && pytest tests/test_security_headers.py -x` exits 0
- `cd backend && pytest tests/test_auth.py::test_change_password_revokes_other_sessions tests/test_auth.py::test_enable_totp_revokes_other_sessions tests/test_auth.py::test_disable_totp_revokes_other_sessions -v` shows all three as PASSED
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline
- `grep -rn "^class RegisterRequest" backend/api/auth/` returns exactly one match (shared.py)
- `grep -rn "^class LoginRequest" backend/api/auth/` returns exactly one match (shared.py)
- No file in `backend/tests/` was modified by this plan (test imports still use `from api.auth import limiter`)
</acceptance_criteria>
<done>Old auth.py deleted; full backend suite green; CR-01/02/03 tests still PASSED; rate-limit / TOTP / security headers tests all green; no test file touched.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Unauthenticated client → /api/auth/login, /api/auth/register, /api/auth/refresh | Rate-limited via `@limiter.limit("10/minute")`; limiter must be the SAME instance app.state.limiter references |
| Authenticated user → /api/auth/change-password, /api/auth/totp/enable, /api/auth/totp (DELETE) | CR-01/CR-02/CR-03 require `revoke_all_refresh_tokens` with `skip_token_hash` for the current session — preserved verbatim |
| Authenticated user → /api/auth/refresh | Refresh-token rotation + family-revocation on reuse — preserved verbatim from monolith |
| Authenticated user → /api/auth/logout-all | Revoke all sessions including current; sets Redis user_nbf — preserved verbatim |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-06-01 | Spoofing | limiter re-export | mitigate | `__init__.py` re-exports `limiter` from `shared.py` so the SAME Limiter instance is accessible via the legacy import path. Acceptance criterion greps for the re-export line. |
| T-08-06-02 | Repudiation | CR-01/CR-02/CR-03 audit log entries | mitigate | `write_audit_log(..., metadata_={"sessions_revoked": revoked}, ...)` calls in change_password / enable_totp / disable_totp copied verbatim; passing tests from plan 08-03 verify behavior end-to-end. |
| T-08-06-03 | Tampering | Refresh-token rotation logic | mitigate | `refresh_token` handler in tokens.py copied verbatim including JTI, fgp, family-revocation, Redis nbf. |
| T-08-06-04 | Information Disclosure | Sub-router prefix doubling | mitigate | All three sub-routers declare `router = APIRouter()` with NO prefix; only `__init__.py` carries `prefix="/api/auth"`. |
| T-08-06-05 | Denial of Service | Circular import via `__init__.py` | mitigate | `__init__.py` does ONLY router aggregation + limiter re-export; shared models in `shared.py`. |
| T-08-06-06 | Elevation of Privilege | Session revocation skip | mitigate | `skip_token_hash=skip_hash` argument preserved in all three CR handlers; grep acceptance criterion counts occurrences. |
| T-08-06-SC | Supply Chain | No new packages | accept | This plan installs zero new packages. |
</threat_model>
<verification>
- `cd backend && pytest tests/test_auth.py tests/test_auth_api.py tests/test_auth_totp.py tests/test_totp_replay.py tests/test_security_headers.py -x -v` — zero failures
- `cd backend && pytest -v` — full suite zero failures
- `cd backend && python -c "from main import app; auth_paths = sorted({r.path for r in app.routes if r.path.startswith('/api/auth')}); print('\\n'.join(auth_paths))"` lists at minimum: `/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`, `/api/auth/logout`, `/api/auth/logout-all`, `/api/auth/me`, `/api/auth/me/quota`, `/api/auth/me/preferences`, `/api/auth/totp/setup`, `/api/auth/totp/enable`, `/api/auth/totp`, `/api/auth/change-password`, `/api/auth/password-reset`, `/api/auth/password-reset/confirm`
- `cd backend && python -c "from main import app; from api.auth import limiter; assert app.state.limiter is limiter"` — confirms identity (same instance, not a copy)
</verification>
<success_criteria>
- `backend/api/auth/` package with 5 files
- `backend/api/auth.py` deleted
- All auth URL paths unchanged
- limiter still importable via `from api.auth import limiter` (5 test files + main.py unchanged)
- CR-01/CR-02/CR-03 tests still PASSED (not regressed by decomposition)
- All tests pass
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-06-SUMMARY.md` when done. Include: (a) full list of auth paths emitted by `app.routes` before vs. after (must be identical), (b) test counts for each of the 5 covered files, (c) confirmation that `app.state.limiter is api.auth.limiter` (identity check), (d) confirmation that no file under `backend/tests/` was modified.
</output>
@@ -0,0 +1,348 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 07
type: execute
wave: 2
depends_on: [08-03]
files_modified:
- frontend/src/api/utils.js
- frontend/src/api/documents.js
- frontend/src/api/auth.js
- frontend/src/api/admin.js
- frontend/src/api/folders.js
- frontend/src/api/shares.js
- frontend/src/api/cloud.js
- frontend/src/api/topics.js
- frontend/src/api/client.js
autonomous: true
requirements: [CODE-04, CODE-08]
tags: [frontend-decomposition, api-client, barrel-reexport]
must_haves:
truths:
- "frontend/src/api/utils.js exists, exports request(), exports fetchWithRetry() — the consolidated 401-retry helper"
- "Each of documents.js / auth.js / admin.js / folders.js / shares.js / cloud.js / topics.js exists and imports `request` from './utils.js'"
- "fetchWithRetry() consolidates the 3 blob-download patterns (adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent) into one helper"
- "client.js is reduced to a barrel re-export: `export * from './documents.js'` etc., plus `export { fetchWithRetry, request } from './utils.js'`"
- "Every function name from the old client.js is exported by exactly one domain module (no duplication, no missing names)"
- "Zero consumer files (35+) under frontend/src/stores/ frontend/src/components/ frontend/src/views/ are modified — all `import * as api from '../api/client.js'` and `import { name } from '../api/client.js'` continue to resolve"
- "Frontend test suite passes"
artifacts:
- path: "frontend/src/api/utils.js"
provides: "HTTP transport: request() with bearer injection + 401-refresh-retry, fetchWithRetry() for raw Response endpoints"
contains: "export async function request"
- path: "frontend/src/api/documents.js"
provides: "Document domain functions including fetchDocumentContent (now using fetchWithRetry)"
contains: "export function listDocuments"
- path: "frontend/src/api/auth.js"
provides: "Auth domain functions: login, register, refreshToken, logout, totp*, password*, preferences, quota"
contains: "export function login"
- path: "frontend/src/api/admin.js"
provides: "Admin domain functions including blob-download admin endpoints"
contains: "export function adminListUsers"
- path: "frontend/src/api/folders.js"
provides: "Folder domain functions"
contains: "export function listFolders"
- path: "frontend/src/api/shares.js"
provides: "Share domain functions"
contains: "export function createShare"
- path: "frontend/src/api/cloud.js"
provides: "Cloud connection domain functions including initiateOAuth"
contains: "export function listCloudConnections"
- path: "frontend/src/api/topics.js"
provides: "Topic domain functions"
contains: "export function listTopics"
- path: "frontend/src/api/client.js"
provides: "Barrel re-export — preserves zero-change consumer contract"
contains: "export * from './documents.js'"
key_links:
- from: "frontend/src/api/client.js"
to: "all 7 domain modules + utils.js"
via: "export * from"
pattern: "export \\* from './(documents|auth|admin|folders|shares|cloud|topics)\\.js'"
- from: "each domain module"
to: "frontend/src/api/utils.js"
via: "import { request } from './utils.js'"
pattern: "import \\{ request"
- from: "blob-download functions (admin.js, documents.js)"
to: "frontend/src/api/utils.js fetchWithRetry"
via: "import { fetchWithRetry } from './utils.js'"
pattern: "import \\{.*fetchWithRetry"
---
<objective>
Decompose the 635-line `frontend/src/api/client.js` monolith into 7 domain modules + `utils.js` (HTTP transport + 401-retry consolidation) per locked D-12/D-13/D-14. `client.js` is reduced to a barrel re-export so the 35+ consumer files do not require any edits. The 3 blob-download 401-retry copy-paste patterns are consolidated into a single `fetchWithRetry()` helper in `utils.js`.
Purpose: CODE-04 (decomposition) + CODE-08 (single definition of the auth+retry pattern instead of 3 copies).
Output: 1 new `utils.js`, 7 new domain modules, `client.js` reduced to ~12 lines.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@frontend/src/api/client.js
@CLAUDE.md
<interfaces>
Function to domain map (locked per RESEARCH.md §"Frontend API Client Decomposition" and PATTERNS.md §"frontend/src/api/*"):
utils.js:
request(path, options) — moved from client.js lines 11-57 to break circular dep
fetchWithRetry(url, options) — new consolidator for blob-download 401-retry
documents.js (consumers: stores/documents.js, DocumentCard.vue, DocumentView.vue, DocumentPreviewModal.vue, etc):
listDocuments, getDocument, deleteDocument, deleteDocumentRemoveOnly,
classifyDocument, getUploadUrl, confirmUpload, uploadToCloud,
fetchDocumentContent (refactored to use fetchWithRetry), getDocumentContentUrl
auth.js (consumers: stores/auth.js, SettingsAccountTab.vue, LoginView.vue, etc):
login, register, refreshToken, logout, logoutAll, getMe,
changePassword, totpSetup, totpEnable, totpDisable,
passwordResetRequest, passwordResetConfirm,
getMyPreferences, updateMyPreferences, getMyQuota
admin.js (consumers: AdminUsersTab.vue, AdminQuotasTab.vue, AdminAiConfigTab.vue, AuditLogTab.vue, etc):
adminListUsers, adminCreateUser, adminDeactivateUser, adminReactivateUser,
adminResetUserPassword, adminGetUserQuota, adminUpdateQuota, adminUpdateAiConfig,
adminDeleteUser, getAiConfig, saveAiConfig, testAiConnection, getAiModels,
adminListAuditLog, adminExportAuditLogCsv (refactored to use fetchWithRetry),
adminListDailyExports, adminDownloadDailyExport (refactored to use fetchWithRetry)
folders.js (consumers: stores/folders.js, FolderTreeItem.vue, AppSidebar.vue):
listFolders, createFolder, getFolder, renameFolder, deleteFolder, moveDocument
shares.js (consumers: SharedView.vue, ShareModal.vue):
createShare, updateSharePermission, listShares, deleteShare, getSharedWithMe
cloud.js (consumers: stores/cloudConnections.js, SettingsCloudTab.vue, CloudCredentialModal.vue, CloudProviderTreeItem.vue, CloudFolderTreeItem.vue):
listCloudConnections, disconnectCloud, connectWebDav, updateDefaultStorage,
getCloudFolders, initiateOAuth, getConnectionConfig
topics.js (consumers: stores/topics.js, SearchableModelSelect.vue is unrelated):
listTopics, createTopic, updateTopic, deleteTopic, suggestTopics
client.js (after refactor — barrel only):
export * from './documents.js'
export * from './auth.js'
export * from './admin.js'
export * from './folders.js'
export * from './shares.js'
export * from './cloud.js'
export * from './topics.js'
export { fetchWithRetry, request } from './utils.js'
Critical: All function names across these 8 files are unique — no `export *` collisions (RESEARCH.md §Pitfall 5 confirms).
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create utils.js with request() and fetchWithRetry()</name>
<files>frontend/src/api/utils.js</files>
<read_first>
- frontend/src/api/client.js lines 11-57 (current `request` function — copy verbatim)
- frontend/src/api/client.js lines 428-471 (adminExportAuditLogCsv blob pattern)
- frontend/src/api/client.js lines 492-529 (adminDownloadDailyExport blob pattern)
- frontend/src/api/client.js lines 552-581 (fetchDocumentContent blob pattern)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/api/utils.js" (full code template for both functions)
</read_first>
<action>
Create `frontend/src/api/utils.js`. Add a top-of-file JSDoc comment explaining: "HTTP transport + 401-retry consolidator. `request()` moved from client.js to break circular dependency (domain modules import request from here; client.js re-exports from domain modules). `fetchWithRetry()` consolidates 3 blob-download patterns. Security: Bearer from authStore memory only (CLAUDE.md)."
(A) Export `async function request(path, options = {})` — copy the body verbatim from `client.js` lines 11-57. Keep the lazy `await import('../stores/auth.js')` to avoid the Pinia bootstrap cycle. Keep the `noRefreshPaths` array as a local const inside the function (or as a module-level const above the function — either works, but match the original pattern from client.js).
(B) Export `async function fetchWithRetry(url, options = {}, _retry = false)` per PATTERNS.md §"frontend/src/api/utils.js" template. The function: lazy-imports `useAuthStore`, copies `headers`, injects `Authorization: Bearer ${authStore.accessToken}` if present, calls `fetch(url, { ...options, headers, credentials: 'include' })`, on 401-and-not-already-retried calls `authStore.refresh()` and recurses with `_retry=true`, on refresh failure clears `authStore.accessToken` + `authStore.user` and throws `'Session expired'`, otherwise returns the raw `Response` (caller decides how to consume it — text(), blob(), etc.).
Do NOT modify `client.js` in this task — task 9 owns the barrel rewrite.
</action>
<verify>
<automated>cd frontend && node -e "import('./src/api/utils.js').then(m => { if (typeof m.request !== 'function') throw new Error('request missing'); if (typeof m.fetchWithRetry !== 'function') throw new Error('fetchWithRetry missing'); console.log('ok'); }).catch(e => { console.error(e); process.exit(1); })"</automated>
</verify>
<acceptance_criteria>
- File `frontend/src/api/utils.js` exists
- `grep -c "export async function request" frontend/src/api/utils.js` returns 1
- `grep -c "export async function fetchWithRetry" frontend/src/api/utils.js` returns 1
- `grep -c "await import('../stores/auth.js')" frontend/src/api/utils.js` returns at least 1 (lazy import preserved)
- `grep -c "noRefreshPaths" frontend/src/api/utils.js` returns at least 1
- `grep -c "_retry" frontend/src/api/utils.js` returns at least 2 (request uses options._retry, fetchWithRetry uses positional _retry)
</acceptance_criteria>
<done>utils.js exists with both helpers, identifiable by export grep, module loads without error.</done>
</task>
<task type="auto">
<name>Task 2: Create domain modules documents.js, auth.js, topics.js</name>
<files>frontend/src/api/documents.js, frontend/src/api/auth.js, frontend/src/api/topics.js</files>
<read_first>
- frontend/src/api/client.js (full file — confirm exact function bodies and signatures for each function listed in the function-to-domain map in &lt;interfaces&gt;)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/api/documents.js" and §"frontend/src/api/auth.js"
</read_first>
<action>
For each file, write a small header docstring naming the domain (e.g., "Document API — listing, fetching, uploading, content streaming"). Add `import { request, fetchWithRetry } from './utils.js'` for documents.js (because fetchDocumentContent uses fetchWithRetry); for auth.js and topics.js use only `import { request } from './utils.js'`.
(A) `documents.js`: move these functions VERBATIM from client.js (preserve every parameter, every default value, every URL path, every body shape): `listDocuments`, `getDocument`, `deleteDocument`, `deleteDocumentRemoveOnly`, `classifyDocument`, `getUploadUrl`, `confirmUpload`, `uploadToCloud`, `getDocumentContentUrl`. Then refactor `fetchDocumentContent` to use `fetchWithRetry`: the new body becomes `export async function fetchDocumentContent(docId, options = {}) { const res = await fetchWithRetry('/api/documents/' + docId + '/content', options); if (!res.ok) throw new Error('Failed to fetch document content: ' + res.status); return res; }` — preserving the public contract (returns raw Response, throws on non-ok).
(B) `auth.js`: move these functions VERBATIM: `login`, `register`, `refreshToken`, `logout`, `logoutAll`, `getMe`, `changePassword`, `totpSetup`, `totpEnable`, `totpDisable`, `passwordResetRequest`, `passwordResetConfirm`, `getMyPreferences`, `updateMyPreferences`, `getMyQuota`.
(C) `topics.js`: move these functions VERBATIM: `listTopics`, `createTopic`, `updateTopic`, `deleteTopic`, `suggestTopics`.
Do NOT remove the functions from `client.js` yet — task 9 owns the barrel rewrite and deletion of the original bodies. This task only adds new files.
</action>
<verify>
<automated>cd frontend && node -e "Promise.all([import('./src/api/documents.js'), import('./src/api/auth.js'), import('./src/api/topics.js')]).then(([d, a, t]) => { ['listDocuments','getDocument','deleteDocument','classifyDocument','getUploadUrl','fetchDocumentContent'].forEach(n => { if (typeof d[n] !== 'function') throw new Error('documents.' + n + ' missing'); }); ['login','register','refreshToken','logout','getMe','changePassword','totpSetup','passwordResetRequest','getMyQuota'].forEach(n => { if (typeof a[n] !== 'function') throw new Error('auth.' + n + ' missing'); }); ['listTopics','createTopic','suggestTopics'].forEach(n => { if (typeof t[n] !== 'function') throw new Error('topics.' + n + ' missing'); }); console.log('ok'); }).catch(e => { console.error(e); process.exit(1); })"</automated>
</verify>
<acceptance_criteria>
- Files exist: `frontend/src/api/documents.js`, `frontend/src/api/auth.js`, `frontend/src/api/topics.js`
- `grep -c "^export function listDocuments\\|^export async function fetchDocumentContent" frontend/src/api/documents.js` returns at least 2
- `grep -c "import { request, fetchWithRetry } from './utils.js'" frontend/src/api/documents.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/auth.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/topics.js` returns 1
- `grep -c "^export function login\\|^export function register" frontend/src/api/auth.js` returns at least 2
- The node -e import check above prints `ok` (all named exports resolvable)
</acceptance_criteria>
<done>Three domain files exist with all expected exports; verified by node import.</done>
</task>
<task type="auto">
<name>Task 3: Create domain modules admin.js, folders.js, shares.js, cloud.js</name>
<files>frontend/src/api/admin.js, frontend/src/api/folders.js, frontend/src/api/shares.js, frontend/src/api/cloud.js</files>
<read_first>
- frontend/src/api/client.js (full file — confirm exact function bodies and signatures)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/api/admin.js" (blob refactor pattern using fetchWithRetry)
</read_first>
<action>
For each file, add a header docstring naming the domain. Import statements:
- admin.js: `import { request, fetchWithRetry } from './utils.js'` (audit-log CSV + daily-export use fetchWithRetry)
- folders.js, shares.js, cloud.js: `import { request } from './utils.js'`
(A) `admin.js`: move these functions VERBATIM from client.js: `adminListUsers`, `adminCreateUser`, `adminDeactivateUser`, `adminReactivateUser`, `adminResetUserPassword`, `adminGetUserQuota`, `adminUpdateQuota`, `adminUpdateAiConfig`, `adminDeleteUser`, `getAiConfig`, `saveAiConfig`, `testAiConnection`, `getAiModels`, `adminListAuditLog`, `adminListDailyExports`. Then refactor `adminExportAuditLogCsv` and `adminDownloadDailyExport` to use `fetchWithRetry` (per PATTERNS.md §"frontend/src/api/admin.js"). The new `adminExportAuditLogCsv(params = {})` body: build the URLSearchParams exactly as before, call `const res = await fetchWithRetry('/api/admin/audit-log/export?' + searchParams)`, on `!res.ok` throw `'Export failed: ' + res.status`, then call `res.text()`, create the Blob, trigger the download via temporary anchor click, revokeObjectURL on a setTimeout. The new `adminDownloadDailyExport(date)` body: `const res = await fetchWithRetry('/api/admin/audit-log/daily-exports/' + date)`, on `!res.ok` throw, then download the blob the same way. Drop the `_retry` boilerplate — fetchWithRetry handles it.
(B) `folders.js`: move these functions VERBATIM: `listFolders`, `createFolder`, `getFolder`, `renameFolder`, `deleteFolder`, `moveDocument`.
(C) `shares.js`: move these functions VERBATIM: `createShare`, `updateSharePermission`, `listShares`, `deleteShare`, `getSharedWithMe`.
(D) `cloud.js`: move these functions VERBATIM: `listCloudConnections`, `disconnectCloud`, `connectWebDav`, `updateDefaultStorage`, `getCloudFolders`, `initiateOAuth`, `getConnectionConfig`.
Do NOT remove the functions from `client.js` yet — task 4 owns the barrel rewrite.
</action>
<verify>
<automated>cd frontend && node -e "Promise.all([import('./src/api/admin.js'), import('./src/api/folders.js'), import('./src/api/shares.js'), import('./src/api/cloud.js')]).then(([ad, fo, sh, cl]) => { ['adminListUsers','adminCreateUser','adminUpdateQuota','getAiConfig','saveAiConfig','testAiConnection','adminListAuditLog','adminExportAuditLogCsv','adminDownloadDailyExport'].forEach(n => { if (typeof ad[n] !== 'function') throw new Error('admin.' + n + ' missing'); }); ['listFolders','createFolder','moveDocument'].forEach(n => { if (typeof fo[n] !== 'function') throw new Error('folders.' + n + ' missing'); }); ['createShare','updateSharePermission','getSharedWithMe'].forEach(n => { if (typeof sh[n] !== 'function') throw new Error('shares.' + n + ' missing'); }); ['listCloudConnections','initiateOAuth','getConnectionConfig'].forEach(n => { if (typeof cl[n] !== 'function') throw new Error('cloud.' + n + ' missing'); }); console.log('ok'); }).catch(e => { console.error(e); process.exit(1); })"</automated>
</verify>
<acceptance_criteria>
- Files exist: `frontend/src/api/admin.js`, `frontend/src/api/folders.js`, `frontend/src/api/shares.js`, `frontend/src/api/cloud.js`
- `grep -c "import { request, fetchWithRetry } from './utils.js'" frontend/src/api/admin.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/folders.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/shares.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/cloud.js` returns 1
- `grep -c "fetchWithRetry" frontend/src/api/admin.js` returns at least 2 (one each for adminExportAuditLogCsv and adminDownloadDailyExport)
- `grep -c "_retry" frontend/src/api/admin.js` returns 0 (boilerplate dropped — fetchWithRetry owns retry)
- `grep -c "^export function initiateOAuth" frontend/src/api/cloud.js` returns 1 (named import is used by SettingsCloudTab.vue)
- The node -e import check above prints `ok`
</acceptance_criteria>
<done>Four domain files exist with all expected exports; blob-download functions use fetchWithRetry; node import succeeds.</done>
</task>
<task type="auto">
<name>Task 4: Rewrite client.js as barrel re-export and run frontend tests</name>
<files>frontend/src/api/client.js</files>
<read_first>
- frontend/src/api/client.js (the current 635-line file — you are about to replace its contents)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/api/client.js (transport + barrel, request-response) — MODIFIED" (the full final-state template)
</read_first>
<action>
Replace the ENTIRE contents of `frontend/src/api/client.js` with this final form (preserves all previous exports via `export *`):
/**
* API client — barrel re-export.
*
* The HTTP transport (request) and 401-retry consolidator (fetchWithRetry) live in utils.js
* to avoid the circular import that would arise if domain modules imported request from here
* while this file re-exported from those same domain modules.
*
* All 35+ consumer files continue using one of:
* import * as api from '...api/client.js' — namespace pattern
* import { funcName } from '...api/client.js' — named import pattern
* without any changes.
*/
export * from './documents.js'
export * from './auth.js'
export * from './admin.js'
export * from './folders.js'
export * from './shares.js'
export * from './cloud.js'
export * from './topics.js'
export { fetchWithRetry, request } from './utils.js'
After writing the new file contents, run `cd frontend && npm test` to confirm the test suite passes. The frontend tests use the `import { ... } from '../api/client.js'` and `import * as api from '../api/client.js'` patterns — both must resolve every previously-exported name through the barrel. If any test fails with `TypeError: ... is not a function` or similar, the symptom is a missing or misspelled export in one of the domain modules — diagnose by re-checking the function-to-domain map.
After tests pass, smoke the dev build: `cd frontend && npm run build` (Vite production build). Vite will fail loudly if `export * from` produces ambiguous names or unresolved modules. If the build fails, fix and rerun.
</action>
<verify>
<automated>cd frontend && wc -l src/api/client.js && npm test 2>&1 | tail -20 && npm run build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- `wc -l < frontend/src/api/client.js` returns less than 25 (was 635; barrel is ~15-20 lines)
- `grep -c "^export \\* from " frontend/src/api/client.js` returns 7
- `grep -c "export { fetchWithRetry, request } from './utils.js'" frontend/src/api/client.js` returns 1
- `grep -c "async function request" frontend/src/api/client.js` returns 0 (request moved to utils.js)
- `grep -c "noRefreshPaths" frontend/src/api/client.js` returns 0 (lived inside request, now in utils.js)
- `cd frontend && npm test` exits 0
- `cd frontend && npm run build` exits 0
- `grep -rn "from '../api/client.js'\\|from '../../api/client.js'\\|from '../../../api/client.js'" frontend/src/stores/ frontend/src/components/ frontend/src/views/ 2>/dev/null | wc -l` returns same count as before this plan (35+ files; none modified)
- No file under `frontend/src/stores/`, `frontend/src/components/`, or `frontend/src/views/` is listed in git diff for this plan
</acceptance_criteria>
<done>client.js is a thin barrel; frontend tests pass; production build succeeds; zero consumer files modified.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → backend API | All requests carry Bearer token from authStore memory (never localStorage per CLAUDE.md) |
| Domain module → utils.js request | In-process import; no untrusted data crosses |
| fetchWithRetry → authStore.refresh on 401 | Refresh flow uses httpOnly cookie; on failure the in-memory access token is cleared |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-07-01 | Spoofing | Bearer token injection | mitigate | `request` body copied verbatim from client.js — preserves `if (authStore.accessToken) headers['Authorization'] = ...` and lazy import of authStore |
| T-08-07-02 | Tampering | Circular import producing undefined exports | mitigate | `request` lives in utils.js; client.js never re-exports request from itself; domain modules import from utils.js (PATTERNS.md §"Frontend domain module import line") |
| T-08-07-03 | Repudiation | 401-refresh-retry loop | mitigate | `_retry` flag is preserved in both `request` and `fetchWithRetry`; recursion is bounded |
| T-08-07-04 | Information Disclosure | Token stored in JS storage | mitigate | CLAUDE.md rule preserved: token from `authStore.accessToken` (memory only), never read from localStorage/sessionStorage |
| T-08-07-05 | Tampering | `export *` name collision | mitigate | RESEARCH.md §Pitfall 5 verified all current client.js function names are unique; acceptance criterion runs frontend tests + production build which would fail on collision |
| T-08-07-06 | Denial of Service | Consumer files break | mitigate | Barrel re-export preserves every named export; acceptance criterion verifies zero consumer files in stores/components/views were modified |
| T-08-07-SC | Supply Chain | No new npm packages | accept | This plan installs zero new packages |
</threat_model>
<verification>
- `cd frontend && npm test` — zero failures
- `cd frontend && npm run build` — succeeds
- `wc -l frontend/src/api/client.js` — under 25 lines
- `grep -rn "import.*from '../api/client.js'\\|import.*from '../../api/client.js'\\|import.*from '../../../api/client.js'" frontend/src/stores/ frontend/src/components/ frontend/src/views/` returns the same line count as before this plan
- `git diff --stat HEAD frontend/src/stores/ frontend/src/components/ frontend/src/views/` returns no entries
</verification>
<success_criteria>
- 8 new files in `frontend/src/api/` (utils.js + 7 domain modules)
- client.js reduced to ~15 lines
- Frontend tests + production build pass
- Zero consumer files modified (the entire point of the barrel pattern)
- fetchWithRetry consolidates 3 blob-download patterns (CODE-08)
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-07-SUMMARY.md` when done. Include: (a) line counts before/after for client.js and the 8 new files, (b) list of all consumer files (stores + components + views) confirmed untouched, (c) confirmation that the 3 old blob-download _retry boilerplate blocks were consolidated into the single `fetchWithRetry` helper.
</output>
@@ -0,0 +1,255 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 08
type: execute
wave: 3
depends_on: [08-04, 08-05, 08-06, 08-07]
files_modified:
- frontend/package.json
- frontend/package-lock.json
- frontend/tailwind.config.js
- backend/requirements.txt
autonomous: false
requirements: [PERF-01]
tags: [dependencies, perf, requirements-pinning, vite-6, tailwind-forms]
user_setup:
- service: npm-registry-verification
why: "Two PERF-01 packages are install-only verified; the rest must be manually verified on npmjs.com because slopcheck was unavailable at research time"
dashboard_config:
- task: "Verify @vueuse/core, @vueuse/integrations, sortablejs, @tailwindcss/forms, rollup-plugin-visualizer, @types/sortablejs, vite, @vitejs/plugin-vue on npmjs.com — each must show legitimate maintainer, weekly downloads > 10k, and no recent malware advisories"
location: "npmjs.com/package/{name}"
must_haves:
truths:
- "frontend/package.json declares all PERF-01 packages at the locked version constraints"
- "frontend/tailwind.config.js wires the @tailwindcss/forms plugin so VISUAL-02 in Phase 11 can rely on its base styles"
- "frontend/vite.config.js works with vite@^6.4.3 (no breaking changes apply per RESEARCH.md analysis)"
- "backend/requirements.txt uses exact == pins for every package (no floating >= ranges)"
- "cd backend && pytest -v exits 0 (no regression from pinning)"
- "cd frontend && npm install completes cleanly with no peer-dep warnings on vite ↔ @vitejs/plugin-vue"
- "cd frontend && npm run build succeeds with the new Vite 6"
artifacts:
- path: "frontend/package.json"
provides: "Updated dependency manifest with PERF-01 packages"
contains: "@vueuse/core"
- path: "frontend/tailwind.config.js"
provides: "Tailwind config with @tailwindcss/forms plugin wired"
contains: "@tailwindcss/forms"
- path: "backend/requirements.txt"
provides: "Exact-pinned backend dependency list per D-17"
contains: "fastapi=="
key_links:
- from: "frontend/tailwind.config.js"
to: "@tailwindcss/forms"
via: "plugins array"
pattern: "plugins:.*forms"
- from: "frontend/package.json"
to: "vite, @vitejs/plugin-vue, @vueuse/core, sortablejs, @tailwindcss/forms, rollup-plugin-visualizer"
via: "dependencies / devDependencies"
pattern: "(vite|@vueuse|sortablejs|@tailwindcss/forms|rollup-plugin-visualizer)"
---
<objective>
Land PERF-01: bump Vite 5 → 6 (and its plugin), install the new frontend packages (`@vueuse/core`, `@vueuse/integrations`, `sortablejs`, `@tailwindcss/forms`, `rollup-plugin-visualizer`, `@types/sortablejs`), wire `@tailwindcss/forms` into `tailwind.config.js`, and pin `backend/requirements.txt` from floating `>=` ranges to exact `==` pins per D-17. Includes a blocking human checkpoint to manually verify the 8 npm packages on npmjs.com because slopcheck was unavailable at research time.
Purpose: PERF-01 plus the reproducibility benefit of exact pinning. Tailwind plugin wiring is required NOW so Phase 11's VISUAL-02 can rely on `@tailwindcss/forms` base styles being active.
Output: Updated `package.json`, `package-lock.json`, `tailwind.config.js`, and `requirements.txt`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@frontend/package.json
@frontend/vite.config.js
@frontend/tailwind.config.js
@backend/requirements.txt
<interfaces>
PERF-01 package set (locked):
dependencies (production):
vue ^3.5.0 (currently 3.5.34 — already satisfies; no install needed but bump declared range)
@vueuse/core ^14.3.0 (install)
@vueuse/integrations ^14.3.0 (install)
sortablejs ^1.15.7 (install)
@tailwindcss/forms ^0.5.11 (install + wire in tailwind.config.js)
devDependencies:
vite ^6.4.3 (upgrade from ^5.2.0)
@vitejs/plugin-vue ^6.0.7 (upgrade from ^5.0.0 — required for Vite 6 compatibility)
rollup-plugin-visualizer ^7.0.1 (install — Phase 11 wires it into vite.config.js for PERF-02 bundle analysis)
@types/sortablejs latest (install — TypeScript types for sortablejs)
tailwind.config.js wiring (per @tailwindcss/forms docs):
/** @type {import('tailwindcss').Config} */
import forms from '@tailwindcss/forms'
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: { extend: {} },
plugins: [forms],
}
backend/requirements.txt: convert every `>=` constraint to `==` using the currently installed version per D-17. RESEARCH.md confirmed installed versions for: fastapi==0.128.8, anthropic==0.104.0, sqlalchemy[asyncio]==2.0.49, psycopg[binary]==3.2.13, alembic==1.16.5 (note: lower than the previous floating min of 1.18.4 — pin to INSTALLED per D-17), celery[redis]==5.6.3, PyJWT==2.13.0, cryptography==48.0.0, structlog==25.5.0. All other packages tagged [ASSUMED] in RESEARCH.md — discover their installed version via `pip show <pkg>` during execution.
</interfaces>
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 1: Package Legitimacy Checkpoint — manually verify 8 npm packages on npmjs.com</name>
<what-built>
The researcher tagged all 8 PERF-01 npm packages as [ASSUMED] because slopcheck was unavailable in the research environment. Before any install runs, manually verify on npmjs.com that each package is legitimate.
</what-built>
<how-to-verify>
For each package below, open the link, check (a) maintainer is a known org or individual not a fresh account, (b) weekly downloads &gt; 10,000, (c) no malware advisory banner, (d) most recent publish date is reasonable (not "1 day ago" for an established lib), (e) the GitHub repo linked in "Repository" exists and is active:
1. https://www.npmjs.com/package/vite — expect: vitejs maintainer org; millions of weekly downloads
2. https://www.npmjs.com/package/@vitejs/plugin-vue — expect: vitejs org; millions of weekly downloads
3. https://www.npmjs.com/package/@vueuse/core — expect: antfu/vueuse maintainer; millions of weekly downloads
4. https://www.npmjs.com/package/@vueuse/integrations — expect: antfu/vueuse maintainer
5. https://www.npmjs.com/package/sortablejs — expect: SortableJS org; millions of weekly downloads
6. https://www.npmjs.com/package/@tailwindcss/forms — expect: tailwindlabs org
7. https://www.npmjs.com/package/rollup-plugin-visualizer — expect: btd/btmurrell maintainer; hundreds of thousands weekly
8. https://www.npmjs.com/package/@types/sortablejs — expect: DefinitelyTyped org
If any package fails any check, ABORT and re-evaluate the package choice with the project owner.
</how-to-verify>
<resume-signal>Type "approved" to proceed with installs, or list any rejected package by name.</resume-signal>
</task>
<task type="auto">
<name>Task 2: Install PERF-01 frontend packages and wire @tailwindcss/forms</name>
<files>frontend/package.json, frontend/package-lock.json, frontend/tailwind.config.js</files>
<read_first>
- frontend/package.json (current dependency versions)
- frontend/tailwind.config.js (currently `plugins: []` with no extensions)
- frontend/vite.config.js (confirm it has no custom `resolve.conditions` — RESEARCH.md verified Vite 6 migration is clean for this minimal config)
</read_first>
<action>
Run two npm install commands in `frontend/`:
cd frontend && npm install vue@^3.5.0 @vueuse/core@^14.3.0 @vueuse/integrations@^14.3.0 sortablejs@^1.15.7 @tailwindcss/forms@^0.5.11
cd frontend && npm install -D vite@^6.4.3 @vitejs/plugin-vue@^6.0.7 rollup-plugin-visualizer@^7.0.1 @types/sortablejs
These two commands update both `package.json` (declared ranges) and `package-lock.json` (resolved exact versions). Do NOT install `vue@^3.5.0` separately if npm warns the existing 3.5.34 satisfies the new range — the bump-then-deduplicate behavior is automatic.
After npm install completes, modify `frontend/tailwind.config.js` to wire the forms plugin. The CURRENT file uses ESM with `export default`. Update it to:
/** @type {import('tailwindcss').Config} */
import forms from '@tailwindcss/forms'
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: { extend: {} },
plugins: [forms],
}
Do NOT modify `frontend/vite.config.js` — RESEARCH.md verified the existing config is compatible with Vite 6 (no `resolve.conditions`, no Sass, no library mode). `rollup-plugin-visualizer` is installed but NOT wired into `vite.config.js` yet — Phase 11's PERF-02 will wire it when bundle measurements are taken.
After config wiring: run `cd frontend && npm test` to confirm tests still pass on Vite 6, then `cd frontend && npm run build` to confirm a clean production build. If either fails, diagnose and fix before proceeding.
</action>
<verify>
<automated>cd frontend && grep -c '"vite": "\\^6' package.json && grep -c '"@vitejs/plugin-vue": "\\^6' package.json && grep -c '"@vueuse/core": "\\^14' package.json && grep -c '"sortablejs"' package.json && grep -c '"@tailwindcss/forms"' package.json && grep -c '"rollup-plugin-visualizer"' package.json && grep -c "@tailwindcss/forms" tailwind.config.js && grep -c "plugins: \\[forms\\]" tailwind.config.js && npm test 2>&1 | tail -5 && npm run build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `grep -c '"vite": "\^6' frontend/package.json` returns 1
- `grep -c '"@vitejs/plugin-vue": "\^6' frontend/package.json` returns 1
- `grep -c '"@vueuse/core": "\^14' frontend/package.json` returns 1
- `grep -c '"@vueuse/integrations": "\^14' frontend/package.json` returns 1
- `grep -c '"sortablejs": "\^1.15' frontend/package.json` returns 1
- `grep -c '"@tailwindcss/forms": "\^0.5' frontend/package.json` returns 1
- `grep -c '"rollup-plugin-visualizer": "\^7' frontend/package.json` returns 1
- `grep -c '"@types/sortablejs"' frontend/package.json` returns 1
- `grep -c "import forms from '@tailwindcss/forms'" frontend/tailwind.config.js` returns 1
- `grep -c "plugins: \[forms\]" frontend/tailwind.config.js` returns 1
- `cd frontend && npm test` exits 0
- `cd frontend && npm run build` exits 0
- `cd frontend && npm list vite | grep -c "vite@6"` returns at least 1
</acceptance_criteria>
<done>All PERF-01 packages installed; tailwind forms plugin wired; tests + production build pass on Vite 6.</done>
</task>
<task type="auto">
<name>Task 3: Pin backend/requirements.txt to exact installed versions (D-17)</name>
<files>backend/requirements.txt</files>
<read_first>
- backend/requirements.txt (current floating-range constraints)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md §"requirements.txt: Exact Pinning (D-17)" (already-verified versions: fastapi==0.128.8, anthropic==0.104.0, sqlalchemy[asyncio]==2.0.49, psycopg[binary]==3.2.13, alembic==1.16.5, celery[redis]==5.6.3, PyJWT==2.13.0, cryptography==48.0.0, structlog==25.5.0)
</read_first>
<action>
For each package in `backend/requirements.txt` that is tagged [ASSUMED] in RESEARCH.md (uvicorn, python-multipart, pydantic-settings, pydantic, openai, PyMuPDF, python-docx, pytesseract, Pillow, aiofiles, httpx, pytest, pytest-asyncio, minio, redis, aiosqlite, pwdlib, pyotp, slowapi, google-auth-oauthlib, google-api-python-client, msal, webdavclient3, cachetools), run `cd backend && pip show <pkg> 2>/dev/null | grep '^Version:'` to capture the installed version. If a package returns no output (not installed in the local env), run `cd backend && python -c "import importlib.metadata as m; print(m.version('<pkg>'))"` as a fallback. Record each version.
Then rewrite `backend/requirements.txt` so every line uses an exact `==` pin. Preserve the existing section comments (e.g. "# Cloud Storage Backends (Phase 5)", "# Observability (Phase 6 — D-01)"). For the already-verified packages from RESEARCH.md, use the locked versions: fastapi==0.128.8, anthropic==0.104.0, sqlalchemy[asyncio]==2.0.49, psycopg[binary]==3.2.13, alembic==1.16.5, celery[redis]==5.6.3, PyJWT==2.13.0, cryptography==48.0.0, structlog==25.5.0. For everything else, use the version captured by `pip show`. If `alembic` is shown as 1.16.5 (lower than the previous floating min of 1.18.4), pin to 1.16.5 per D-17 — but also append a single-line comment immediately before the alembic line: `# alembic pinned to currently-installed 1.16.5 (was >=1.18.4); D-17 mandates pinning to installed`.
After rewriting, run `cd backend && pip install -r requirements.txt --dry-run 2>&1 | tail -20` to confirm pip considers the file valid and all pins are satisfiable. Then run `cd backend && pytest -v` to confirm no regression from re-resolving deps.
</action>
<verify>
<automated>cd backend && grep -cE '>=' requirements.txt; cd backend && grep -cE '==' requirements.txt; cd backend && pip install -r requirements.txt --dry-run 2>&1 | tail -5; cd backend && pytest -v --tb=no -q 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `grep -c '^[^#].*>=' backend/requirements.txt` returns 0 (every non-comment line uses == or is bare)
- `grep -cE '^[a-zA-Z][a-zA-Z0-9_.\\[\\]-]*==' backend/requirements.txt` returns at least 30 (count of pinned packages — file has ~32 packages)
- `grep -c "^fastapi==0.128.8" backend/requirements.txt` returns 1
- `grep -c "^alembic==1.16.5" backend/requirements.txt` returns 1
- `grep -c "^sqlalchemy\\[asyncio\\]==2.0.49" backend/requirements.txt` returns 1
- `grep -c "^PyJWT==2.13.0" backend/requirements.txt` returns 1
- `grep -c "^cryptography==48.0.0" backend/requirements.txt` returns 1
- `grep -c "alembic pinned to currently-installed" backend/requirements.txt` returns 1
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline
- `cd backend && pip install -r requirements.txt --dry-run` reports no resolution conflicts (exit 0)
</acceptance_criteria>
<done>requirements.txt fully pinned with == ; alembic discrepancy commented; pytest still green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| npmjs.com → frontend node_modules | Supply chain: any of the 8 packages could be malicious if a name was typosquatted or a maintainer account was compromised |
| pypi.org → backend venv | Same supply-chain consideration for any unpinned package; D-17 pinning narrows the attack window |
| Vite 6 build pipeline → bundled JS | Major version bump may introduce regressions; npm test + production build verify integrity |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-08-01 | Supply Chain (Tampering) | 8 npm package installs | mitigate | Blocking human checkpoint (task 1) requires manual npmjs.com verification because slopcheck was unavailable; gate flagged blocking-human and never auto-approvable. T-{phase}-SC retained per Package Legitimacy Gate protocol. |
| T-08-08-02 | Tampering | Vite 6 migration breaking behavior | mitigate | RESEARCH.md analyzed all Vite 5→6 breaking changes against the project's `vite.config.js` and confirmed none apply (no custom `resolve.conditions`, no Sass, no library mode); npm test + npm run build acceptance criteria verify post-install. |
| T-08-08-03 | Information Disclosure | requirements.txt leaks installed versions | accept | Versions are not secrets; reproducibility benefit outweighs disclosure |
| T-08-08-04 | Denial of Service | alembic 1.16.5 lower than 1.18.4 | mitigate | D-17 mandates pinning to installed; the inline comment documents the discrepancy so a future developer can decide to bump deliberately |
| T-08-08-05 | Tampering | @vitejs/plugin-vue peer-dep mismatch | mitigate | Both vite@^6 and @vitejs/plugin-vue@^6 installed in same `npm install -D` command per RESEARCH.md §Pitfall 6 |
| T-08-08-SC | Supply Chain | npm packages [ASSUMED] | mitigate | Blocking human checkpoint (task 1) is the gate; checkpoint is blocking-human and cannot be auto-approved |
</threat_model>
<verification>
- `cd frontend && npm test` — zero failures
- `cd frontend && npm run build` — succeeds with Vite 6
- `cd frontend && npm list vite @vitejs/plugin-vue @vueuse/core @vueuse/integrations sortablejs @tailwindcss/forms rollup-plugin-visualizer @types/sortablejs` — all listed
- `cd backend && pytest -v` — zero failures
- `cd backend && pip install -r requirements.txt --dry-run` — no resolution errors
- `grep -v '^#' backend/requirements.txt | grep -cE '>='` returns 0 (no remaining floating constraints in production-package lines)
</verification>
<success_criteria>
- 8 PERF-01 packages installed and verified
- `@tailwindcss/forms` wired in tailwind.config.js (ready for Phase 11 VISUAL-02)
- Vite 6 production build succeeds
- backend/requirements.txt fully `==` pinned per D-17
- All tests pass
- Human checkpoint approved on npm package legitimacy
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-08-SUMMARY.md` when done. Include: (a) before/after `package.json` dependency table, (b) full pinned `requirements.txt` content, (c) the human checkpoint approval timestamp and any package that required follow-up.
</output>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
---
phase: 8
slug: stack-upgrade-backend-decomposition
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-06-07
---
# Phase 8 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Backend framework** | pytest with pytest-asyncio (asyncio_mode = auto) |
| **Backend config file** | `backend/pytest.ini` |
| **Backend quick run** | `cd backend && pytest tests/test_auth.py -x -v` |
| **Backend full suite** | `cd backend && pytest -v` |
| **Frontend framework** | Vitest 4.1.7 |
| **Frontend config** | `frontend/vitest.config.js` |
| **Frontend quick run** | `cd frontend && npm test` |
| **Estimated runtime** | ~30 seconds (backend), ~10 seconds (frontend) |
---
## Sampling Rate
- **After every task commit:** Run `cd backend && pytest -x -v` (backend changes) or `cd frontend && npm test` (frontend changes)
- **After Wave 0 (test stubs):** Confirm all new stubs are xfail — `pytest --tb=no -q`
- **After decomposition plans:** Full suite `cd backend && pytest -v` — zero failures before advancing
---
## Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CR-01 | `change_password` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_change_password_revokes_other_sessions -x` | ❌ Wave 0 |
| CR-02 | `enable_totp` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_enable_totp_revokes_other_sessions -x` | ❌ Wave 0 |
| CR-03 | `disable_totp` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_disable_totp_revokes_other_sessions -x` | ❌ Wave 0 |
| CODE-01 | All admin URLs still respond on same paths after split | integration | `pytest tests/test_admin.py -x` | ✅ |
| CODE-02 | All document URLs still respond on same paths after split | integration | `pytest tests/test_documents.py -x` | ✅ |
| CODE-03 | All auth URLs still respond on same paths after split | integration | `pytest tests/test_auth.py -x` | ✅ |
| CODE-04 | All existing consumer imports resolve; no 35+ files changed | smoke | `cd frontend && npm test` | ✅ |
| CODE-08 | No model defined twice across packages | static/grep | `grep -rn "class CloudConnectionOut" backend/` | N/A |
| PERF-01 | npm list confirms all packages present | install verification | `cd frontend && npm list` | N/A |
---
## Wave 0 Gaps
New tests to write before decomposition work begins:
- [ ] `backend/tests/test_auth.py` — add 3 xfail stubs promoted to real tests for CR-01/02/03:
- `test_change_password_revokes_other_sessions`
- `test_enable_totp_revokes_other_sessions`
- `test_disable_totp_revokes_other_sessions`
- [ ] Frontend: verify `useToastStore` mock wiring in `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` after stub creation
---
## Critical Test Gates
| Gate | Command | Must pass before |
|------|---------|-----------------|
| Wave 0 stubs xfail | `pytest --tb=no -q` | Any decomposition work |
| Admin split | `pytest tests/test_admin.py -x -v` | Advancing past CODE-01 |
| Documents split | `pytest tests/test_documents.py -x -v` | Advancing past CODE-02 |
| Auth split | `pytest tests/test_auth.py -x -v` | Advancing past CODE-03 |
| Full backend suite | `pytest -v` | Phase gate |
| Frontend smoke | `npm test` | Phase gate |
| URL regression | `pytest tests/test_admin.py tests/test_documents.py tests/test_auth.py -v` | Phase complete |
---
## Security Validation
| Threat | Test | Evidence |
|--------|------|---------|
| Router prefix doubling | `pytest -v` — all existing route tests pass | URL paths unchanged |
| Admin endpoint auth leakage | `pytest tests/test_admin.py -k "not_admin"` | 403 on all admin routes for non-admin |
| Circular import crash | App starts cleanly: `uvicorn main:app --reload` exits 0 | No ImportError |
| CloudConnectionOut import break | `pytest tests/test_cloud.py -x` | cloud.py imports succeed |