docs(08): research phase — decomposition boundaries, CR status, Vite 6 migration
This commit is contained in:
@@ -0,0 +1,950 @@
|
||||
# Phase 8: Stack Upgrade & Backend Decomposition — Research
|
||||
|
||||
**Researched:** 2026-06-07
|
||||
**Domain:** FastAPI router decomposition, Python package decomposition, frontend API client decomposition, Vite 5→6 migration, requirements.txt pinning
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
**Phase 7.1 — Session Revocation (Wave 1)**
|
||||
- D-01: Phase 7.1 is absorbed into Phase 8 as the first wave.
|
||||
- D-02: Phase 7.1 scope: `revoke_all_refresh_tokens()` gets a `skip_token_hash` param; wired into `change_password`, `enable_totp`, and `disable_totp` with `sessions_revoked` field in each response shape; audit log entries written for each revocation.
|
||||
- D-03: Frontend toast implementation: create an empty `useToastStore` Pinia store stub during Phase 7.1. Phase 7.1 components (`SettingsAccountTab.vue`, `TotpEnrollment.vue`) call `toastStore.show(...)` when `sessions_revoked > 0`. Phase 10 fills in the full toast implementation — Phase 7.1 only creates the stub and wires the call sites.
|
||||
|
||||
**Backend Router Decomposition**
|
||||
- D-04: Sub-routers MUST have NO prefix on `APIRouter()`. Parent prefix propagates. Any sub-router prefix causes doubled URL segments.
|
||||
- D-05: `api/admin/` split: `users.py`, `quotas.py`, `ai.py` — exact names locked.
|
||||
- D-06: `api/documents/` split: 4 sub-modules. Exact names are researcher/planner choice.
|
||||
- D-07: `api/auth/` split: 4 sub-modules (login/tokens, TOTP, password management, session management). Module names must mirror `services/auth.py` logical groupings.
|
||||
- D-08: Re-classify endpoint placement within `api/documents/` is researcher/planner choice.
|
||||
|
||||
**Shared Pydantic Schemas (CODE-08)**
|
||||
- D-09: Pydantic models shared within a single package → `shared.py` inside that package.
|
||||
- D-10: Pydantic models referenced by 2+ packages → `backend/api/schemas.py`.
|
||||
- D-11: Validators currently defined inline in router files must migrate to appropriate `services/` module.
|
||||
|
||||
**Frontend API Client Decomposition (CODE-04)**
|
||||
- D-12: `client.js` becomes HTTP transport layer + re-export barrel. `request()` and `noRefreshPaths` stay in `client.js`. Zero changes to any of the 35+ consumer files.
|
||||
- D-13: `fetchWithRetry()` (consolidating 3 blob-download 401-retry duplicates) lives in `frontend/src/api/utils.js`. `client.js` re-exports it.
|
||||
- D-14: Domain sub-modules: `documents.js`, `auth.js`, `admin.js`, `folders.js`, `shares.js`, `cloud.js`, `topics.js`.
|
||||
|
||||
**Frontend Dependencies (PERF-01)**
|
||||
- D-15: Vite 5→6 is a major version bump requiring migration research.
|
||||
- D-16: Researcher determines which PERF-01 packages need config wiring vs install-only.
|
||||
|
||||
**Backend Dependency Pinning**
|
||||
- D-17: `requirements.txt` must be converted from floating `>=` ranges to exact `==` pins using currently-installed versions. No version changes.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
- Exact file names for `api/documents/` sub-modules
|
||||
- Exact file names for `api/auth/` sub-modules
|
||||
- Re-classify endpoint placement within `api/documents/`
|
||||
- Which validators in router files qualify for migration to `services/`
|
||||
- Domain grouping of each `client.js` function
|
||||
- Which PERF-01 packages need config wiring vs install-only
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
|
||||
- Backend package version bumps (FastAPI, SQLAlchemy, etc.) — only exact-pin current versions
|
||||
- Composition API migration (Vue components)
|
||||
- Virtual scrolling, dark mode, folder reordering, multi-select batch ops
|
||||
</user_constraints>
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| CR-01 | `change_password` revokes other sessions, returns `sessions_revoked` | ALREADY IMPLEMENTED in auth.py L518-536 — code exists, tests and toast needed |
|
||||
| CR-02 | `enable_totp` revokes other sessions, returns `sessions_revoked` | ALREADY IMPLEMENTED in auth.py L613-636 — code exists, tests and toast needed |
|
||||
| CR-03 | `disable_totp` revokes other sessions, returns `sessions_revoked` | ALREADY IMPLEMENTED in auth.py L659-682 — code exists, tests and toast needed |
|
||||
| CODE-01 | `api/admin.py` (934L) decomposed into `api/admin/` package | Sub-module boundaries identified below |
|
||||
| CODE-02 | `api/documents.py` (852L) decomposed into `api/documents/` package | Sub-module boundaries identified below |
|
||||
| CODE-03 | `api/auth.py` (825L) decomposed into `api/auth/` package | Sub-module boundaries identified below |
|
||||
| CODE-04 | `frontend/src/api/client.js` (635L) decomposed into domain modules | Function mapping identified below |
|
||||
| CODE-08 | No duplicated Pydantic models across router files; shared schemas extracted | Cross-package models identified below |
|
||||
| PERF-01 | Frontend deps bumped/added: vue@^3.5, vite@^6.4.3, @vueuse/core@^14.3, etc. | Migration analysis complete |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 8 is a structural refactoring phase — zero behavior changes, all URL contracts preserved. The phase absorbs Phase 7.1 (session revocation) as Wave 1 before any decomposition work begins.
|
||||
|
||||
**Critical finding on Wave 1 (CR-01, CR-02, CR-03):** Reading `backend/api/auth.py` confirms that all three session-revocation calls (`revoke_all_refresh_tokens` with `skip_token_hash`) are **already implemented** in the codebase at lines 518, 613, and 663. The `sessions_revoked` field is already in all three response shapes. The Wave 1 backend work is complete. What remains for Wave 1 is: (1) tests verifying the revocation behavior, and (2) the `useToastStore` stub + call-site wiring in `SettingsAccountTab.vue` and `TotpEnrollment.vue`. The frontend components already have inline `sessionRevokedToast` refs with 5s auto-dismiss — D-03 says to replace these with `toastStore.show(...)` calls and a stub store.
|
||||
|
||||
**Critical finding on `CloudConnectionOut`:** This Pydantic model is defined in `api/admin.py` and imported by `api/cloud.py` at line 35. When `api/admin.py` is split into `api/admin/users.py`, `api/admin/quotas.py`, `api/admin/ai.py`, this import in `cloud.py` must update to the new location. Since `CloudConnectionOut` is used by 2+ packages (admin + cloud), it belongs in `api/schemas.py` per D-10.
|
||||
|
||||
**Backend decomposition:** The three monoliths have clear natural boundaries based on reading their full source. Sub-module boundaries are documented below with exact line ranges.
|
||||
|
||||
**Frontend decomposition:** 35+ consumer files import from `client.js` using three patterns: `import * as api`, named imports, and lazy `await import()`. The barrel re-export pattern preserves all patterns without touching consumer files.
|
||||
|
||||
**Vite 5→6 migration:** For this project's `vite.config.js` (only `plugins`, `build.target`, `server.proxy`), the migration is low-risk. One potentially required change: `resolve.conditions` defaults changed. Since there is no custom `resolve.conditions` in this config, the default behavior change may or may not affect the project — the safest approach is to test after bump before committing.
|
||||
|
||||
**Primary recommendation:** Execute in wave order — Wave 1 (CR tests + toast stub), Wave 2 (backend decomposition in parallel, CODE-04 independent), Wave 3 (pinning + PERF-01 deps).
|
||||
|
||||
---
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Router decomposition | API layer (`backend/api/`) | None | Pure structural split; no service layer involvement |
|
||||
| Shared Pydantic schemas | API layer (`api/schemas.py`) | None | Response models live at API boundary |
|
||||
| Validators migration | Service layer (`services/`) | None | CLAUDE.md rule: validators raise ValueError, not HTTPException |
|
||||
| Frontend API decomposition | Frontend API layer (`src/api/`) | Stores (consumers) | Barrel pattern ensures stores see no change |
|
||||
| `useToastStore` stub | Frontend store layer (`src/stores/`) | Components (callers) | Pinia stores own application state |
|
||||
| requirements.txt pinning | Infrastructure | None | Reproducibility, no behavior change |
|
||||
| Vite 6 upgrade | Frontend build | None | Build tooling only; no runtime behavior change |
|
||||
|
||||
---
|
||||
|
||||
## Wave 1: Session Revocation (CR-01, CR-02, CR-03) — BACKEND ALREADY COMPLETE
|
||||
|
||||
### What is already implemented
|
||||
|
||||
Reading `backend/api/auth.py` confirms all three revocation calls exist:
|
||||
|
||||
**`change_password` (L475-537):**
|
||||
- `skip_token_hash` computed from `request.cookies.get("refresh_token")` at L516-517
|
||||
- `revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` at L518
|
||||
- `sessions_revoked` in response at L537: `return {"message": "Password updated", "sessions_revoked": revoked}`
|
||||
- Audit log with `metadata_={"sessions_revoked": revoked}` at L526
|
||||
|
||||
**`enable_totp` (L579-636):**
|
||||
- `skip_token_hash` computed at L613-614
|
||||
- `revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` at L615
|
||||
- `sessions_revoked` in response at L636: `return {"backup_codes": plain_codes, "sessions_revoked": revoked}`
|
||||
- Audit log at L619 with `metadata_={"sessions_revoked": revoked}`
|
||||
|
||||
**`disable_totp` (L641-682):**
|
||||
- `skip_token_hash` computed at L660-661
|
||||
- `revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` at L662
|
||||
- `sessions_revoked` in response at L682: `return {"message": "TOTP disabled", "sessions_revoked": revoked}`
|
||||
- Audit log at L664 with `metadata_={"sessions_revoked": revoked}`
|
||||
|
||||
`revoke_all_refresh_tokens` signature in `services/auth.py` (L250-273):
|
||||
```python
|
||||
async def revoke_all_refresh_tokens(
|
||||
session: AsyncSession, user_id: uuid.UUID, skip_token_hash: Optional[str] = None
|
||||
) -> int:
|
||||
```
|
||||
The `skip_token_hash` parameter already exists and is already implemented correctly.
|
||||
|
||||
### What Wave 1 actually needs to do
|
||||
|
||||
1. **Tests only (backend):** Write 3 new tests confirming the revocation behavior:
|
||||
- `test_change_password_revokes_other_sessions` — change password, verify other refresh tokens revoked, current session preserved
|
||||
- `test_enable_totp_revokes_other_sessions`
|
||||
- `test_disable_totp_revokes_other_sessions`
|
||||
|
||||
2. **Frontend `useToastStore` stub:** Create `frontend/src/stores/toast.js` with a stub `show()` method (Phase 10 fills in the implementation). The stub must export `useToastStore` and accept the same `show(message, type)` call shape that Phase 10 will implement.
|
||||
|
||||
3. **Frontend call-site rewiring:** In `SettingsAccountTab.vue` and `TotpEnrollment.vue`, replace the existing `sessionRevokedToast` ref + `setTimeout` pattern with `toastStore.show("Other sessions have been terminated.", "success")`. The existing inline toast HTML blocks in these two components can be removed since the stub store will handle display in Phase 10.
|
||||
|
||||
### Existing inline toast locations
|
||||
|
||||
`SettingsAccountTab.vue`:
|
||||
- Lines 1-25: inline toast HTML (fixed top-right, `v-if="sessionRevokedToast"`)
|
||||
- Line 211: `const sessionRevokedToast = ref(false)`
|
||||
- Lines 225-228: `changePassword` sets toast, 5s timeout
|
||||
- Lines 261-264: `disableTotp` sets toast, 5s timeout
|
||||
|
||||
`TotpEnrollment.vue`:
|
||||
- Lines 1-23: inline toast HTML (inline, not fixed-position)
|
||||
- `sessionRevokedToast` ref and `setTimeout` in `confirmEnrollment` at line 174-177
|
||||
|
||||
**Important:** The existing tests in `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` already test for `sessions_revoked > 0` behavior and expect the toast text "Other sessions have been terminated." — these tests must still pass after the refactor, just via the store rather than the ref.
|
||||
|
||||
---
|
||||
|
||||
## Backend Router Decomposition
|
||||
|
||||
### api/admin.py → api/admin/ package
|
||||
|
||||
**Current structure (934L):**
|
||||
- Response helpers: `_ai_config_to_dict()`, `_user_to_dict()` (L58-91)
|
||||
- Pydantic models: `UserCreate`, `UserStatusUpdate`, `QuotaUpdate`, `UserAiConfigUpdate`, `SystemAiConfigUpdate`, `TestConnectionRequest`, `SystemTopicCreate`, `UserDeleteConfirm`, `CloudConnectionOut` (L96-223)
|
||||
- Endpoints by functional area:
|
||||
|
||||
| Function | Endpoint | Target sub-module |
|
||||
|---|---|---|
|
||||
| `list_users`, `create_user`, `update_user_status`, `initiate_password_reset`, `delete_user` | `/api/admin/users*` | `users.py` |
|
||||
| `get_user_quota`, `update_user_quota` | `/api/admin/users/{id}/quota` | `quotas.py` |
|
||||
| `update_ai_config` (per-user) | `/api/admin/users/{id}/ai-config` | `users.py` |
|
||||
| `create_system_topic` | `/api/admin/topics` | `users.py` (admin utility) or `ai.py` |
|
||||
| `get_ai_config_models`, `test_ai_connection`, `get_ai_config`, `update_system_ai_config` | `/api/admin/ai-config*` | `ai.py` |
|
||||
|
||||
**Recommended sub-module assignment:**
|
||||
|
||||
`api/admin/users.py`: `list_users`, `create_user`, `update_user_status`, `initiate_password_reset`, `delete_user`, `update_ai_config` (per-user), `create_system_topic`
|
||||
`api/admin/quotas.py`: `get_user_quota`, `update_user_quota`
|
||||
`api/admin/ai.py`: `get_ai_config_models`, `test_ai_connection`, `get_ai_config`, `update_system_ai_config`
|
||||
|
||||
**Pydantic model placement:**
|
||||
- `CloudConnectionOut` → `api/schemas.py` (used by both `api/admin/` and `api/cloud.py`)
|
||||
- `UserCreate`, `UserStatusUpdate`, `UserAiConfigUpdate`, `UserDeleteConfirm` → `api/admin/users.py` (only used in users.py)
|
||||
- `QuotaUpdate` → `api/admin/quotas.py` (only used in quotas.py)
|
||||
- `SystemAiConfigUpdate`, `TestConnectionRequest` → `api/admin/ai.py` (only used in ai.py)
|
||||
- `SystemTopicCreate` → `api/admin/users.py` (only used in create_system_topic)
|
||||
- `_user_to_dict()` helper → `api/admin/shared.py` (used by users.py and quotas.py both need user info)
|
||||
- `_ai_config_to_dict()` helper → `api/admin/ai.py` (only used by ai.py)
|
||||
|
||||
**Validators that must migrate to services/ (D-11):**
|
||||
|
||||
`UserCreate.password_strength` (L102-106): This `@field_validator` calls `validate_password_strength(v)` which already lives in `services/auth.py`. The validator itself is just a thin call-through — it is acceptable to keep it as-is in the Pydantic model since it delegates to the service. No migration needed for this one.
|
||||
|
||||
`QuotaUpdate.must_be_positive` (L116-120): This `@field_validator` validates `limit_bytes > 0`. This is a Pydantic input validation rule (belongs at API boundary), NOT business logic. Keep in model per CLAUDE.md: "Pydantic `@field_validator` used for complex field constraints." No migration needed.
|
||||
|
||||
`SystemAiConfigUpdate.provider_must_be_known` (L147-155) and `TestConnectionRequest.provider_must_be_known` (L172-179): These validate `provider_id` against `PROVIDER_DEFAULTS`. Since `PROVIDER_DEFAULTS` is an `ai/` layer concern, migrating this check to `services/ai_config.py` as `validate_provider_id(v: str) -> str` (raises `ValueError`) would be cleaner. The `@field_validator` in both models would then call `ai_config_service.validate_provider_id(v)`. This is the one migration that qualifies under D-11 since it's duplicated across two models.
|
||||
|
||||
`DocumentPatch.filename_no_path_separators` (documents.py L83-88): Security validator — belongs in the Pydantic model at the API boundary. No migration needed.
|
||||
|
||||
**Summary:** Only `provider_must_be_known` in `SystemAiConfigUpdate` + `TestConnectionRequest` qualifies for migration to `services/ai_config.py`. All other validators are appropriate Pydantic field constraints.
|
||||
|
||||
### api/documents.py → api/documents/ package
|
||||
|
||||
**Current structure (852L):**
|
||||
- Helper: `_parse_range()` (L744-760) — used only by `stream_document_content`
|
||||
- Constant: `_CLOUD_PROVIDERS` frozenset (L59)
|
||||
- Pydantic models: `UploadUrlRequest`, `DocumentPatch` (L65-88)
|
||||
|
||||
| Endpoint | HTTP | Target sub-module |
|
||||
|---|---|---|
|
||||
| `request_upload_url` | POST /upload-url | `upload.py` |
|
||||
| `upload_document` | POST /upload | `upload.py` |
|
||||
| `confirm_upload` | POST /{id}/confirm | `upload.py` |
|
||||
| `list_documents` | GET / | `crud.py` |
|
||||
| `get_document` | GET /{id} | `crud.py` |
|
||||
| `patch_document` | PATCH /{id} | `crud.py` |
|
||||
| `delete_document` | DELETE /{id} | `crud.py` |
|
||||
| `classify_document` | POST /{id}/classify | `crud.py` |
|
||||
| `stream_document_content` | GET /{id}/content | `content.py` |
|
||||
|
||||
**Recommended sub-module assignment:**
|
||||
|
||||
`api/documents/upload.py`: `request_upload_url`, `upload_document`, `confirm_upload` — all deal with the presigned-URL + cloud upload flow
|
||||
`api/documents/crud.py`: `list_documents`, `get_document`, `patch_document`, `delete_document`, `classify_document` — standard document CRUD + re-classify
|
||||
`api/documents/content.py`: `stream_document_content`, `_parse_range` helper — content proxy + range header handling
|
||||
`api/documents/shared.py`: `_CLOUD_PROVIDERS` frozenset, `UploadUrlRequest`, `DocumentPatch` — shared by upload.py and crud.py
|
||||
|
||||
Re-classify endpoint (`POST /api/documents/{id}/classify`) → `crud.py` (it operates on an existing document, same ownership check pattern as get/patch/delete).
|
||||
|
||||
### api/auth.py → api/auth/ package
|
||||
|
||||
**Current structure (825L):**
|
||||
|
||||
| Endpoint | HTTP | Target sub-module |
|
||||
|---|---|---|
|
||||
| `register`, `login`, `refresh_token`, `logout`, `logout_all`, `get_me`, `get_my_quota` | `/api/auth/*` | `tokens.py` |
|
||||
| `totp_setup`, `enable_totp` | `/api/auth/totp/*` | `totp.py` |
|
||||
| `password_reset_request`, `password_reset_confirm` | `/api/auth/password-reset*` | `password.py` |
|
||||
| `change_password` | `/api/auth/change-password` | `password.py` |
|
||||
| `disable_totp` | `/api/auth/totp` (DELETE) | `totp.py` |
|
||||
| `get_my_preferences`, `update_my_preferences` | `/api/auth/me/preferences` | `tokens.py` (profile management) |
|
||||
|
||||
**Recommended sub-module assignment:**
|
||||
|
||||
`api/auth/tokens.py`: `register`, `login`, `refresh_token`, `logout`, `logout_all`, `get_me`, `get_my_quota`, `get_my_preferences`, `update_my_preferences` — token issuance, session management, profile
|
||||
`api/auth/totp.py`: `totp_setup`, `enable_totp`, `disable_totp` — TOTP lifecycle
|
||||
`api/auth/password.py`: `change_password`, `password_reset_request`, `password_reset_confirm` — password management
|
||||
`api/auth/shared.py`: `_set_refresh_cookie`, `_user_dict`, `RegisterRequest`, `LoginRequest`, `ChangePasswordRequest`, `TotpEnableRequest`, `PasswordResetRequest`, `PasswordResetConfirmRequest`, `PreferencesUpdate`, `limiter` (the Limiter instance) — shared across all sub-modules
|
||||
|
||||
**JTI/ES256/fgp placement:** These live entirely in `services/auth.py` and `deps/auth.py` — not in the router files. No placement decision needed for decomposition.
|
||||
|
||||
### main.py updates required
|
||||
|
||||
Current imports from monoliths:
|
||||
```python
|
||||
from api.auth import limiter as auth_limiter # L21 — used for app.state.limiter
|
||||
from api.documents import router as documents_router # L22
|
||||
from api.auth import router as auth_router # L320
|
||||
from api.admin import router as admin_router # L321
|
||||
```
|
||||
|
||||
After decomposition, each package's `__init__.py` aggregates sub-routers and exports `router`. `main.py` only changes to import from `api/auth/__init__.py`, `api/documents/__init__.py`, `api/admin/__init__.py`. The `limiter` from `api.auth` becomes `api.auth.shared.limiter` or re-exported from `api/auth/__init__.py`.
|
||||
|
||||
### Circular import risk analysis
|
||||
|
||||
**Risk 1: `api/cloud.py` imports `CloudConnectionOut` from `api/admin.py`**
|
||||
- Current: `from api.admin import CloudConnectionOut` (L35 of cloud.py)
|
||||
- After split: `api/admin/__init__.py` no longer directly defines it
|
||||
- Fix: Move `CloudConnectionOut` to `api/schemas.py` BEFORE splitting admin.py. Update cloud.py to `from api.schemas import CloudConnectionOut`. Both packages import from schemas — no circular dependency.
|
||||
|
||||
**Risk 2: `backend/main.py` imports `limiter` from `api/auth.py`**
|
||||
- Current: `from api.auth import limiter as auth_limiter` (L21)
|
||||
- After split: `limiter` moves to `api/auth/shared.py`
|
||||
- Fix: `api/auth/__init__.py` re-exports `limiter`. `main.py` import unchanged.
|
||||
|
||||
**Risk 3: `celery_app.py` import constraints**
|
||||
- Celery task modules never import from router modules (ARCHITECTURE.md constraint)
|
||||
- The new sub-packages must not be imported by `tasks/` or `celery_app.py`
|
||||
- The tasks deferred-import pattern (e.g., `from tasks.email_tasks import send_reset_email`) must stay as local imports inside function bodies, not at module top level
|
||||
|
||||
**Risk 4: `tests/conftest.py` imports `auth_limiter`**
|
||||
- `backend/tests/conftest.py` L222: `from api.auth import limiter as auth_limiter`
|
||||
- After split: update to wherever `limiter` lands (via `api.auth` re-export, this stays unchanged)
|
||||
|
||||
---
|
||||
|
||||
## Frontend API Client Decomposition (CODE-04)
|
||||
|
||||
### client.js function inventory and domain mapping
|
||||
|
||||
**Functions that stay in `client.js` (transport layer):**
|
||||
- `request()` (L11-57) — HTTP transport, Bearer injection, 401-refresh-retry
|
||||
- `noRefreshPaths` (L26) — skip-refresh list
|
||||
|
||||
**`frontend/src/api/utils.js` (new, per D-13):**
|
||||
The three blob-download 401-retry functions share identical structure — they bypass `request()` to return a raw `Response` instead of JSON. They should be consolidated into `fetchWithRetry(url, options, downloadHandler)`:
|
||||
|
||||
1. `adminExportAuditLogCsv` (L428-471) — fetch CSV, 401-retry, Blob download
|
||||
2. `adminDownloadDailyExport` (L492-529) — fetch CSV, 401-retry, Blob download
|
||||
3. `fetchDocumentContent` (L552-581) — fetch content, 401-retry, return raw Response
|
||||
|
||||
These three share identical authentication injection + 401-retry logic. The `fetchWithRetry` helper in `utils.js` handles the auth+retry boilerplate; each function provides its own `downloadHandler` or just returns the Response.
|
||||
|
||||
**Domain sub-module assignments:**
|
||||
|
||||
`api/documents.js`:
|
||||
- `listDocuments`, `getDocument`, `deleteDocument`, `deleteDocumentRemoveOnly`
|
||||
- `classifyDocument`, `getUploadUrl`, `confirmUpload`, `uploadToCloud`
|
||||
- `fetchDocumentContent`, `getDocumentContentUrl`
|
||||
|
||||
`api/auth.js`:
|
||||
- `login`, `register`, `refreshToken`, `logout`, `logoutAll`, `getMe`
|
||||
- `changePassword`, `totpSetup`, `totpEnable`, `totpDisable`
|
||||
- `passwordResetRequest`, `passwordResetConfirm`
|
||||
- `getMyPreferences`, `updateMyPreferences`
|
||||
- `getMyQuota`
|
||||
|
||||
`api/admin.js`:
|
||||
- `adminListUsers`, `adminCreateUser`, `adminDeactivateUser`, `adminReactivateUser`
|
||||
- `adminResetUserPassword`, `adminGetUserQuota`, `adminUpdateQuota`, `adminUpdateAiConfig`, `adminDeleteUser`
|
||||
- `getAiConfig`, `saveAiConfig`, `testAiConnection`, `getAiModels`
|
||||
- `adminListAuditLog`, `adminExportAuditLogCsv`, `adminListDailyExports`, `adminDownloadDailyExport`
|
||||
|
||||
`api/folders.js`:
|
||||
- `listFolders`, `createFolder`, `getFolder`, `renameFolder`, `deleteFolder`, `moveDocument`
|
||||
|
||||
`api/shares.js`:
|
||||
- `createShare`, `updateSharePermission`, `listShares`, `deleteShare`, `getSharedWithMe`
|
||||
|
||||
`api/cloud.js`:
|
||||
- `listCloudConnections`, `disconnectCloud`, `connectWebDav`, `updateDefaultStorage`
|
||||
- `getCloudFolders`, `initiateOAuth`, `getConnectionConfig`
|
||||
|
||||
`api/topics.js`:
|
||||
- `listTopics`, `createTopic`, `updateTopic`, `deleteTopic`, `suggestTopics`
|
||||
|
||||
### Consumer import inventory
|
||||
|
||||
35+ consumer files import from `client.js` using three patterns:
|
||||
|
||||
**Pattern 1: `import * as api from '...api/client.js'`** (most common — namespace import)
|
||||
- `stores/auth.js`, `stores/documents.js`, `stores/folders.js`, `stores/topics.js`, `stores/cloudConnections.js`
|
||||
- `SettingsAccountTab.vue`, `SettingsPreferencesTab.vue`, `TotpEnrollment.vue`
|
||||
- `AppSidebar.vue`, `AuditLogTab.vue`, `AdminAiConfigTab.vue`, `AdminQuotasTab.vue`, `AdminUsersTab.vue`
|
||||
- `CloudProviderTreeItem.vue`, `CloudFolderTreeItem.vue`, `CloudCredentialModal.vue`
|
||||
- `FolderTreeItem.vue`, `DocumentView.vue`, `SharedView.vue`, `CloudFolderView.vue`, `AccountView.vue`
|
||||
- `NewPasswordView.vue`, `PasswordResetView.vue`
|
||||
|
||||
**Pattern 2: Named imports `import { funcName } from '...api/client.js'`**
|
||||
- `SearchableModelSelect.vue`: `import { getAiModels }`
|
||||
- `SettingsCloudTab.vue`: `import { initiateOAuth }`
|
||||
- `AdminAiConfigTab.vue`: also `import { getAiConfig, saveAiConfig, testAiConnection }`
|
||||
- `DocumentCard.vue`: `import { moveDocument, classifyDocument }`
|
||||
- `DocumentPreviewModal.vue`: `import { fetchDocumentContent }`
|
||||
- `DocumentView.vue`: also `import { fetchDocumentContent }` (in addition to `* as api`)
|
||||
|
||||
**Pattern 3: Lazy `await import('../stores/auth.js')`** (inside `request()` itself, stays in client.js)
|
||||
|
||||
The barrel re-export in `client.js` must re-export every function from every domain module so that all three patterns continue to work unchanged:
|
||||
|
||||
```javascript
|
||||
// client.js (after decomposition)
|
||||
// ... request() and noRefreshPaths stay here ...
|
||||
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 } from './utils.js'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PERF-01: Frontend Dependencies Analysis
|
||||
|
||||
### Currently installed vs. required
|
||||
|
||||
| Package | Currently installed | PERF-01 requires | Action |
|
||||
|---|---|---|---|
|
||||
| `vue` | 3.5.34 | `^3.5.0` | Already satisfies — no change |
|
||||
| `vite` | 5.4.21 | `^6.4.3` | Upgrade required |
|
||||
| `@vitejs/plugin-vue` | 5.2.4 | Must match Vite 6 | Upgrade to 6.x required |
|
||||
| `@vueuse/core` | not installed | `^14.3.0` | Install required |
|
||||
| `@vueuse/integrations` | not installed | `^14.3.0` | Install required |
|
||||
| `sortablejs` | not installed | `^1.15.7` | Install required |
|
||||
| `@tailwindcss/forms` | not installed | `^0.5.11` | Install required |
|
||||
| `rollup-plugin-visualizer` | not installed (dev) | `^7.0.1` | Install required (devDep) |
|
||||
| `@types/sortablejs` | not installed (dev) | latest | Install required (devDep) |
|
||||
|
||||
[VERIFIED: npm registry] — all package versions confirmed via `npm view <pkg> version`.
|
||||
|
||||
### Vite 5→6 migration for this project
|
||||
|
||||
[CITED: https://v6.vite.dev/guide/migration]
|
||||
|
||||
**Breaking changes that affect this project's `vite.config.js`:**
|
||||
|
||||
The current `vite.config.js` is minimal:
|
||||
```javascript
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: { target: 'esnext' },
|
||||
server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://backend:8000', changeOrigin: true } } },
|
||||
})
|
||||
```
|
||||
|
||||
1. **`resolve.conditions` default values changed:** Defaults are no longer added internally for custom condition configs. Since this project has NO custom `resolve.conditions`, this change does NOT affect the project. No config change needed.
|
||||
|
||||
2. **`json.stringify` defaults to `'auto'`:** This project does not configure `json.stringify`. The `'auto'` default (stringifies only large JSON files) is safe — no change needed.
|
||||
|
||||
3. **CSS output filenames (library mode):** This is not a library mode build. No impact.
|
||||
|
||||
4. **Sass modern API default:** This project uses Tailwind (PostCSS), not Sass. No impact.
|
||||
|
||||
5. **`@vitejs/plugin-vue` must bump to 6.x:** Vite 6 requires `@vitejs/plugin-vue@^6.0.0`. The current `^5.0.0` is incompatible. [VERIFIED: npm registry] `@vitejs/plugin-vue@6.0.7` is the latest 6.x release.
|
||||
|
||||
**Config changes required:**
|
||||
- Bump `vite` to `^6.4.3`
|
||||
- Bump `@vitejs/plugin-vue` to `^6.0.7`
|
||||
- No `vite.config.js` content changes required for this minimal config
|
||||
|
||||
**Config changes NOT required:**
|
||||
- `build.target: 'esnext'` — still valid in Vite 6
|
||||
- `server.proxy` — unchanged
|
||||
- No `resolve.conditions` customization needed
|
||||
|
||||
### @tailwindcss/forms: config wiring required
|
||||
|
||||
Adding `@tailwindcss/forms` requires a one-line change to `tailwind.config.js`:
|
||||
```javascript
|
||||
import forms from '@tailwindcss/forms'
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [forms],
|
||||
}
|
||||
```
|
||||
This is used by VISUAL-02 in Phase 11. Installing in Phase 8 and wiring the plugin is the right time since VISUAL-02 needs it.
|
||||
|
||||
### Package config wiring summary
|
||||
|
||||
| Package | Install-only | Config wiring needed | Config file |
|
||||
|---|---|---|---|
|
||||
| `vue@^3.5.0` | Already installed | None | — |
|
||||
| `vite@^6.4.3` | Yes (with plugin-vue bump) | Minimal: test after install | `package.json` only |
|
||||
| `@vitejs/plugin-vue@^6.0.7` | Yes | None | `package.json` only |
|
||||
| `@vueuse/core@^14.3.0` | Yes | None | — |
|
||||
| `@vueuse/integrations@^14.3.0` | Yes | None | — |
|
||||
| `sortablejs@^1.15.7` | Yes | None | — |
|
||||
| `@tailwindcss/forms@^0.5.11` | No | Yes | `tailwind.config.js` |
|
||||
| `rollup-plugin-visualizer@^7.0.1` | No | Yes | `vite.config.js` (for PERF-02) |
|
||||
| `@types/sortablejs` | Yes (dev) | None | — |
|
||||
|
||||
`rollup-plugin-visualizer` is needed for PERF-02 (bundle analysis) in Phase 11. Install now, wire in Phase 11 when measurements are taken.
|
||||
|
||||
---
|
||||
|
||||
## requirements.txt: Exact Pinning (D-17)
|
||||
|
||||
**Current format:** All floating `>=` ranges.
|
||||
**Required format:** Exact `==` pins at currently-installed versions.
|
||||
|
||||
Installed versions confirmed via `pip3 index versions`:
|
||||
|
||||
| Package | Current constraint | Pin to |
|
||||
|---|---|---|
|
||||
| `fastapi` | `>=0.111` | `==0.128.8` |
|
||||
| `uvicorn[standard]` | `>=0.29` | [ASSUMED] — run `pip show uvicorn` to get exact |
|
||||
| `python-multipart` | `>=0.0.27` | [ASSUMED] — run `pip show python-multipart` |
|
||||
| `pydantic-settings` | `>=2.2` | [ASSUMED] — run `pip show pydantic-settings` |
|
||||
| `pydantic[email]` | `>=2.0` | [ASSUMED] — run `pip show pydantic` |
|
||||
| `anthropic` | `>=0.95.0` | `==0.104.0` (INSTALLED from pip3 output) |
|
||||
| `openai` | `>=1.30` | [ASSUMED] — run `pip show openai` |
|
||||
| `PyMuPDF` | `>=1.26.7` | [ASSUMED] — run `pip show PyMuPDF` |
|
||||
| `python-docx` | `>=1.1` | [ASSUMED] — run `pip show python-docx` |
|
||||
| `pytesseract` | `>=0.3` | [ASSUMED] — run `pip show pytesseract` |
|
||||
| `Pillow` | `>=10.3` | [ASSUMED] — run `pip show Pillow` |
|
||||
| `aiofiles` | `>=23.2` | [ASSUMED] — run `pip show aiofiles` |
|
||||
| `httpx` | `>=0.27` | [ASSUMED] — run `pip show httpx` |
|
||||
| `pytest` | `>=8.2` | [ASSUMED] — run `pip show pytest` |
|
||||
| `pytest-asyncio` | `>=1.3.0` | [ASSUMED] — run `pip show pytest-asyncio` |
|
||||
| `sqlalchemy[asyncio]` | `>=2.0.49` | `==2.0.49` (INSTALLED) |
|
||||
| `psycopg[binary]` | `>=3.3.4` | `==3.2.13` (INSTALLED — note: 3.2.x series) |
|
||||
| `alembic` | `>=1.18.4` | `==1.16.5` (INSTALLED — note: installed version lower than constraint) |
|
||||
| `minio` | `>=7.2.20` | [ASSUMED] — run `pip show minio` |
|
||||
| `celery[redis]` | `>=5.5.0` | `==5.6.3` (INSTALLED) |
|
||||
| `redis` | `>=4.6.0` | [ASSUMED] — run `pip show redis` |
|
||||
| `aiosqlite` | `>=0.20.0` | [ASSUMED] — run `pip show aiosqlite` |
|
||||
| `PyJWT` | `>=2.8.0` | `==2.13.0` (INSTALLED) |
|
||||
| `pwdlib[argon2]` | `>=0.2.1` | [ASSUMED] — run `pip show pwdlib` |
|
||||
| `pyotp` | `>=2.9.0` | [ASSUMED] — run `pip show pyotp` |
|
||||
| `slowapi` | `>=0.1.9` | [ASSUMED] — run `pip show slowapi` |
|
||||
| `cryptography` | `>=41.0.0` | `==48.0.0` (INSTALLED) |
|
||||
| `google-auth-oauthlib` | `>=1.3.1` | [ASSUMED] — run `pip show google-auth-oauthlib` |
|
||||
| `google-api-python-client` | `>=2.196.0` | [ASSUMED] — run `pip show google-api-python-client` |
|
||||
| `msal` | `>=1.36.0` | [ASSUMED] — run `pip show msal` |
|
||||
| `webdavclient3` | `>=3.14.7` | [ASSUMED] — run `pip show webdavclient3` |
|
||||
| `cachetools` | `>=5.3.0` | [ASSUMED] — run `pip show cachetools` |
|
||||
| `structlog` | `>=25.5.0` | `==25.5.0` (INSTALLED) |
|
||||
|
||||
**Implementation:** The planner must include a task that runs `pip show <pkg> | grep Version` for all [ASSUMED] packages to get exact versions, then writes the pinned `requirements.txt`.
|
||||
|
||||
**Alembic discrepancy:** `requirements.txt` specifies `>=1.18.4` but `pip3 index versions` shows `1.16.5` installed. This is the installed version — D-17 says pin to currently installed. Pin to `==1.16.5`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Package __init__.py aggregation pattern
|
||||
|
||||
Every new package must have an `__init__.py` that aggregates sub-routers into a single exported `router` object with NO prefix:
|
||||
|
||||
```python
|
||||
# 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()
|
||||
router.include_router(users_router)
|
||||
router.include_router(quotas_router)
|
||||
router.include_router(ai_router)
|
||||
```
|
||||
|
||||
The parent prefix is set in `main.py`:
|
||||
```python
|
||||
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
||||
```
|
||||
|
||||
Sub-routers have NO prefix — they just define routes without path prefix:
|
||||
```python
|
||||
# api/admin/users.py
|
||||
router = APIRouter() # NO prefix here
|
||||
|
||||
@router.get("/users") # becomes /api/admin/users via parent
|
||||
async def list_users(...):
|
||||
...
|
||||
```
|
||||
|
||||
**PITFALL:** `api/auth.py` currently sets `router = APIRouter(prefix="/api/auth", tags=["auth"])`. When decomposed, the parent `api/auth/__init__.py` must set the prefix, and sub-routers must have NO prefix.
|
||||
|
||||
**PITFALL for api/auth:** Currently `main.py` imports `limiter` from `api.auth` for rate limit state. After decomposition, `api/auth/shared.py` defines the limiter, `api/auth/__init__.py` re-exports it: `from api.auth.shared import limiter`. main.py import `from api.auth import limiter as auth_limiter` continues to work.
|
||||
|
||||
### api/schemas.py pattern
|
||||
|
||||
New file for cross-package Pydantic models:
|
||||
```python
|
||||
# api/schemas.py
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
class CloudConnectionOut(BaseModel):
|
||||
"""Moved from api/admin.py — used by api/cloud.py and api/admin/."""
|
||||
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)
|
||||
```
|
||||
|
||||
**Update required in `api/cloud.py`:** Change `from api.admin import CloudConnectionOut` to `from api.schemas import CloudConnectionOut`.
|
||||
|
||||
### Frontend barrel re-export pattern
|
||||
|
||||
```javascript
|
||||
// src/api/client.js (after decomposition)
|
||||
async function request(path, options = {}) { /* unchanged */ }
|
||||
|
||||
const noRefreshPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh']
|
||||
|
||||
// Re-export all domain modules — preserves all 3 consumer import patterns
|
||||
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 } from './utils.js'
|
||||
```
|
||||
|
||||
Domain modules use `request` from a shared location. Since `request` is not exported from `client.js`, domain modules need access to it. Two options:
|
||||
1. Move `request` to `utils.js` and import from there in both `client.js` and domain files
|
||||
2. Each domain file imports `request` from `client.js` — but this creates a circular dependency if `client.js` re-exports from domain files
|
||||
|
||||
**Correct approach (avoids circular imports):** Move `request` to `utils.js`. Domain files import from `utils.js`. `client.js` imports `request` from `utils.js` for its own internal use, and `export * from './domain.js'`.
|
||||
|
||||
```javascript
|
||||
// src/api/utils.js
|
||||
export async function request(path, options = {}) { /* ... */ }
|
||||
export async function fetchWithRetry(url, onResponse, _retry = false) { /* consolidates 3 blob patterns */ }
|
||||
```
|
||||
|
||||
```javascript
|
||||
// src/api/documents.js
|
||||
import { request } from './utils.js'
|
||||
export function listDocuments(...) { return request('/api/documents?...') }
|
||||
```
|
||||
|
||||
```javascript
|
||||
// src/api/client.js
|
||||
import { request } from './utils.js' // for noRefreshPaths compatibility if needed
|
||||
export * from './documents.js'
|
||||
export * from './auth.js'
|
||||
// etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---|---|---|---|
|
||||
| Python package aggregation | Custom router merger | Standard FastAPI `include_router` | Built-in, prefix-aware, tag-aware |
|
||||
| JS barrel re-export | Manually re-listing exports | `export * from './module.js'` | ES module spec, tree-shakeable |
|
||||
| Blob download with auth | New fetch wrapper | Consolidate into `fetchWithRetry` in `utils.js` | 3 existing implementations share identical auth logic |
|
||||
| requirements.txt pinning | pip freeze (wrong: includes dev deps) | `pip show <pkg>` per package | Avoids polluting with dev/transitive deps |
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Sub-router prefix doubling
|
||||
|
||||
**What goes wrong:** Adding `prefix="/api/admin"` to sub-router AND the parent `include_router` call doubles the URL: `/api/admin/api/admin/users`.
|
||||
**Why it happens:** Developers copy the existing `router = APIRouter(prefix="/api/admin", tags=["admin"])` pattern into sub-routers.
|
||||
**How to avoid:** Sub-routers must have `router = APIRouter()` with NO prefix. Only the `include_router` call in `main.py` carries the prefix.
|
||||
**Warning signs:** Test suite returns 404 on all decomposed endpoints.
|
||||
|
||||
### Pitfall 2: Circular import via __init__.py
|
||||
|
||||
**What goes wrong:** `api/admin/__init__.py` imports from `api/admin/users.py` which imports from `api/admin/__init__.py` (e.g., shared helpers).
|
||||
**Why it happens:** Putting shared helpers in `__init__.py` instead of `shared.py`.
|
||||
**How to avoid:** All shared helpers go in `api/admin/shared.py`. `__init__.py` only does router aggregation.
|
||||
**Warning signs:** `ImportError: cannot import name 'X' from partially initialized module`.
|
||||
|
||||
### Pitfall 3: `api/cloud.py` import breaks after admin split
|
||||
|
||||
**What goes wrong:** `api/cloud.py` imports `from api.admin import CloudConnectionOut`. After splitting, `CloudConnectionOut` is no longer in `api/admin/__init__.py`.
|
||||
**Why it happens:** Not updating `cloud.py` when moving `CloudConnectionOut` to `api/schemas.py`.
|
||||
**How to avoid:** Move `CloudConnectionOut` to `api/schemas.py` FIRST, update `cloud.py` import, verify tests pass, THEN split admin.py.
|
||||
|
||||
### Pitfall 4: JS circular import (request in client.js)
|
||||
|
||||
**What goes wrong:** Domain files import `request` from `client.js`, but `client.js` re-exports from domain files → circular dependency. Vite may silently produce undefined exports.
|
||||
**Why it happens:** Keeping `request` in `client.js` while also re-exporting from domain files that need `request`.
|
||||
**How to avoid:** Move `request` to `utils.js`. Domain files import from `utils.js`. `client.js` re-exports from domain files only.
|
||||
|
||||
### Pitfall 5: ES module `export *` name collision
|
||||
|
||||
**What goes wrong:** Two domain modules export a function with the same name (e.g., both `auth.js` and `admin.js` export `list()`). `export *` in `client.js` silently takes the last one.
|
||||
**Why it happens:** Generic function names in domain modules.
|
||||
**How to avoid:** All client.js functions already have unique, descriptive names (`listDocuments`, `adminListUsers`, etc.). Verify no name collisions before adding `export *`.
|
||||
|
||||
### Pitfall 6: Vite 6 `@vitejs/plugin-vue` version mismatch
|
||||
|
||||
**What goes wrong:** Running Vite 6 with `@vitejs/plugin-vue@^5.x` causes plugin incompatibility errors.
|
||||
**Why it happens:** package.json allows `^5.0.0` which cannot satisfy Vite 6's peer dependency.
|
||||
**How to avoid:** Bump both `vite` and `@vitejs/plugin-vue` in the same `npm install` command.
|
||||
|
||||
### Pitfall 7: Wave 1 toast stub breaks existing tests
|
||||
|
||||
**What goes wrong:** Replacing the `sessionRevokedToast` ref with `toastStore.show(...)` causes existing Vitest tests to fail because `useToastStore` is not mocked.
|
||||
**Why it happens:** Tests that mock `api.changePassword` returning `{sessions_revoked: 2}` now need the store to be available.
|
||||
**How to avoid:** The `useToastStore` stub must be importable and its `show()` must be a no-op by default (returns undefined). Tests that check for the toast text must be updated to check the store was called, or the stub renders nothing (which is fine for Phase 10).
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Package __init__.py with sub-router aggregation
|
||||
|
||||
```python
|
||||
# Source: FastAPI docs — include_router pattern (ASSUMED standard pattern)
|
||||
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()
|
||||
router.include_router(users_router)
|
||||
router.include_router(quotas_router)
|
||||
router.include_router(ai_router)
|
||||
```
|
||||
|
||||
### Sub-router file structure
|
||||
|
||||
```python
|
||||
# Source: pattern from existing api/folders.py, api/shares.py (established in project)
|
||||
from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from deps.auth import get_current_admin
|
||||
from deps.db import get_db
|
||||
|
||||
router = APIRouter() # NO prefix — parent sets it
|
||||
|
||||
@router.get("/users") # Becomes /api/admin/users
|
||||
async def list_users(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
): ...
|
||||
```
|
||||
|
||||
### useToastStore stub
|
||||
|
||||
```javascript
|
||||
// Source: established Pinia pattern from auth.js, documents.js (project convention)
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
function show(message, type = 'info') {
|
||||
// Stub: Phase 10 fills in full implementation
|
||||
// Phase 7.1 call sites use this; behavior visible after Phase 10
|
||||
}
|
||||
return { show }
|
||||
})
|
||||
```
|
||||
|
||||
### fetchWithRetry consolidation
|
||||
|
||||
```javascript
|
||||
// Consolidates adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent
|
||||
export async function fetchWithRetry(url, options = {}, _retry = false) {
|
||||
const { useAuthStore } = await import('../stores/auth.js')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const headers = { ...(options.headers || {}) }
|
||||
if (authStore.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${authStore.accessToken}`
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...options, headers, credentials: 'include' })
|
||||
|
||||
if (res.status === 401 && !_retry) {
|
||||
try {
|
||||
await authStore.refresh()
|
||||
return fetchWithRetry(url, options, true)
|
||||
} catch {
|
||||
authStore.accessToken = null
|
||||
authStore.user = null
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
slopcheck was not available in this environment (installation blocked by auto-mode classifier). All packages below are tagged [ASSUMED] per the graceful-degradation rule. The planner must gate each install behind a `checkpoint:human-verify` task.
|
||||
|
||||
| Package | Registry | Purpose | Disposition |
|
||||
|---|---|---|---|
|
||||
| `@vueuse/core@^14.3.0` | npm | Vue composition utilities | [ASSUMED] — verify before install |
|
||||
| `@vueuse/integrations@^14.3.0` | npm | VueUse integrations | [ASSUMED] — verify before install |
|
||||
| `sortablejs@^1.15.7` | npm | Drag-and-drop sorting | [ASSUMED] — verify before install |
|
||||
| `@tailwindcss/forms@^0.5.11` | npm | Form base styles | [ASSUMED] — verify before install |
|
||||
| `rollup-plugin-visualizer@^7.0.1` | npm | Bundle analysis | [ASSUMED] — verify before install |
|
||||
| `@types/sortablejs` | npm | TypeScript types for sortablejs | [ASSUMED] — verify before install |
|
||||
| `vite@^6.4.3` | npm | Build tool (major upgrade) | [ASSUMED] — verify before install |
|
||||
| `@vitejs/plugin-vue@^6.0.7` | npm | Vue SFC compiler for Vite 6 | [ASSUMED] — verify before install |
|
||||
|
||||
**Packages removed due to slopcheck [SLOP] verdict:** none (slopcheck unavailable)
|
||||
**Packages flagged as suspicious [SUS]:** none (slopcheck unavailable)
|
||||
|
||||
*All packages above are tagged [ASSUMED] because slopcheck was unavailable at research time. The planner must gate each install behind a `checkpoint:human-verify` task.*
|
||||
|
||||
---
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
This is a refactoring phase — no renames, no migrations. This section is NOT applicable.
|
||||
|
||||
---
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Python 3.12 | Backend | ✓ | 3.12 (Docker) | — |
|
||||
| Node.js 20 | Frontend | ✓ | 20 (Docker) | — |
|
||||
| npm | PERF-01 package installs | ✓ | bundled with Node | — |
|
||||
| pip3 | requirements.txt pinning | ✓ | system | — |
|
||||
| pytest | Backend tests | ✓ | installed per requirements.txt | — |
|
||||
| vitest | Frontend tests | ✓ | `^4.1.7` per package.json | — |
|
||||
|
||||
**Missing dependencies with no fallback:** None.
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| 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` |
|
||||
|
||||
### 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 | 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)
|
||||
|
||||
- [ ] `backend/tests/test_auth.py` — add 3 new tests for CR-01/02/03:
|
||||
- `test_change_password_revokes_other_sessions` — create 2 tokens, change password with skip_hash for token 1, verify token 2 revoked
|
||||
- `test_enable_totp_revokes_other_sessions`
|
||||
- `test_disable_totp_revokes_other_sessions`
|
||||
- [ ] Frontend: update `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` to mock `useToastStore` after stub creation
|
||||
|
||||
---
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (CR-01/02/03) | Session revocation on privilege change — already implemented |
|
||||
| V3 Session Management | yes | `revoke_all_refresh_tokens` with `skip_token_hash` — no change |
|
||||
| V4 Access Control | yes | `get_current_admin`, `get_regular_user` deps preserved through decomposition |
|
||||
| V5 Input Validation | yes | Pydantic models moved to `shared.py` — no change in validation logic |
|
||||
| V6 Cryptography | no | No crypto changes in this phase |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| Pattern | Risk | Mitigation |
|
||||
|---------|------|------------|
|
||||
| Router prefix doubling | URL structure breaks, unintended route shadowing | Sub-routers MUST have no prefix (D-04) |
|
||||
| `CloudConnectionOut` import break | `cloud.py` fails to start; 500 on all cloud endpoints | Move to `api/schemas.py` BEFORE splitting admin.py |
|
||||
| Circular import crash | App fails to start entirely | `request()` moved to `utils.js`; shared.py not `__init__.py` |
|
||||
| Admin endpoint leakage | After split, a sub-module accidentally lacks `get_current_admin` dep | Every admin sub-module handler must explicitly inject `_admin: User = Depends(get_current_admin)` |
|
||||
|
||||
---
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | Most [ASSUMED]-tagged pip package versions in pinning table | requirements.txt pinning | Wrong exact version pinned — planner must run `pip show` per package |
|
||||
| A2 | `fetchWithRetry` abstraction is sufficient to consolidate all 3 blob patterns | CODE-04 | Minor: may need separate functions if download triggers differ |
|
||||
| A3 | `export *` from domain modules has no name collisions | CODE-04 pitfalls | Low risk: all current client.js exports have unique names |
|
||||
| A4 | Vite 6 with `esnext` build target works without explicit `resolve.conditions` | PERF-01 | Low risk: project uses no custom conditions; test after upgrade |
|
||||
| A5 | `@vitejs/plugin-vue@^6.0.7` is the correct peer for `vite@^6.4.3` | PERF-01 | Low risk: npm will warn on peer dep mismatch at install time |
|
||||
|
||||
**If this table is empty:** Not empty — A1 is the most significant: the planner must run `pip show` for all [ASSUMED] packages before writing pinned requirements.txt.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **`create_system_topic` placement in admin split**
|
||||
- What we know: It's a one-line admin utility that calls `services.storage.create_topic()`
|
||||
- What's unclear: Does it belong in `users.py` (as an admin utility) or `ai.py` (topics are AI-adjacent)?
|
||||
- Recommendation: Place in `users.py` — topic creation is a content management function, not AI config.
|
||||
|
||||
2. **`useToastStore` stub API shape**
|
||||
- What we know: Phase 10 will implement the full toast system (UX-10). D-03 says Phase 7.1 only creates the stub with `show()`.
|
||||
- What's unclear: Should `show()` accept `(message, type)` or `(message, options)`?
|
||||
- Recommendation: `show(message, type = 'info')` — minimal two-arg API that Phase 10 can extend without breaking call sites.
|
||||
|
||||
3. **alembic version discrepancy**
|
||||
- What we know: `requirements.txt` says `>=1.18.4` but `pip3 index versions` shows installed `1.16.5`.
|
||||
- What's unclear: Is `1.18.4` the required minimum or a typo?
|
||||
- Recommendation: Pin to `==1.16.5` (the installed version). If `1.18.4` was intentional, note the mismatch for the user.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `backend/api/auth.py` — full source read, CR-01/02/03 implementation verified
|
||||
- `backend/api/admin.py` — full source read, sub-module boundaries identified
|
||||
- `backend/api/documents.py` — full source read, sub-module boundaries identified
|
||||
- `backend/services/auth.py` — `revoke_all_refresh_tokens` signature verified
|
||||
- `frontend/src/api/client.js` — full source read, all 635 lines, consumer mapping complete
|
||||
- `backend/main.py` — router registration pattern verified
|
||||
- `frontend/package.json` — current dependency versions verified
|
||||
- `frontend/vite.config.js` — current config verified (minimal)
|
||||
- `backend/requirements.txt` — current floating constraints verified
|
||||
- `.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md` — locked decisions
|
||||
- `pip3 index versions` output — installed package versions for fastapi, sqlalchemy, celery, alembic, pyjwt, anthropic, cryptography, structlog, psycopg
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [CITED: https://v6.vite.dev/guide/migration] — Vite 5→6 breaking changes
|
||||
- `npm view vite version`, `npm view @vitejs/plugin-vue version` etc. — registry versions verified
|
||||
- `npm list` in frontend — installed frontend package versions
|
||||
|
||||
### Tertiary (LOW confidence — marked [ASSUMED])
|
||||
- Remaining pip package installed versions (uvicorn, python-multipart, pydantic-settings, etc.) — not verified via `pip show`, only `pip3 index versions` for latest available
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- CR-01/02/03 backend status: HIGH — source code read directly, implementation confirmed complete
|
||||
- Backend sub-module boundaries: HIGH — full source code read for all 3 monoliths
|
||||
- CloudConnectionOut cross-package risk: HIGH — import chain verified in cloud.py
|
||||
- Frontend function mapping: HIGH — full client.js read + all consumer imports grepped
|
||||
- Vite 5→6 migration impact: MEDIUM — official migration guide consulted, no Sass/library mode used
|
||||
- requirements.txt pinning: MEDIUM — some versions confirmed via pip3, most [ASSUMED]
|
||||
- Package legitimacy: LOW — slopcheck unavailable, all npm packages [ASSUMED]
|
||||
|
||||
**Research date:** 2026-06-07
|
||||
**Valid until:** 2026-07-07 (stable refactoring domain; package versions may drift)
|
||||
Reference in New Issue
Block a user