Moves phases 08–11 execution artifacts from .planning/phases/ to .planning/milestones/v0.2-phases/ to keep .planning/phases/ clean for the next milestone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
50 KiB
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 askip_token_hashparam; wired intochange_password,enable_totp, anddisable_totpwithsessions_revokedfield in each response shape; audit log entries written for each revocation. - D-03: Frontend toast implementation: create an empty
useToastStorePinia store stub during Phase 7.1. Phase 7.1 components (SettingsAccountTab.vue,TotpEnrollment.vue) calltoastStore.show(...)whensessions_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 mirrorservices/auth.pylogical 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.pyinside 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.jsbecomes HTTP transport layer + re-export barrel.request()andnoRefreshPathsstay inclient.js. Zero changes to any of the 35+ consumer files. - D-13:
fetchWithRetry()(consolidating 3 blob-download 401-retry duplicates) lives infrontend/src/api/utils.js.client.jsre-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.txtmust 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.jsfunction - 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_hashcomputed fromrequest.cookies.get("refresh_token")at L516-517revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)at L518sessions_revokedin 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_hashcomputed at L613-614revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)at L615sessions_revokedin 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_hashcomputed at L660-661revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)at L662sessions_revokedin 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):
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
-
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 preservedtest_enable_totp_revokes_other_sessionstest_disable_totp_revokes_other_sessions
-
Frontend
useToastStorestub: Createfrontend/src/stores/toast.jswith a stubshow()method (Phase 10 fills in the implementation). The stub must exportuseToastStoreand accept the sameshow(message, type)call shape that Phase 10 will implement. -
Frontend call-site rewiring: In
SettingsAccountTab.vueandTotpEnrollment.vue, replace the existingsessionRevokedToastref +setTimeoutpattern withtoastStore.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:
changePasswordsets toast, 5s timeout - Lines 261-264:
disableTotpsets toast, 5s timeout
TotpEnrollment.vue:
- Lines 1-23: inline toast HTML (inline, not fixed-position)
sessionRevokedToastref andsetTimeoutinconfirmEnrollmentat 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 bothapi/admin/andapi/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 bystream_document_content - Constant:
_CLOUD_PROVIDERSfrozenset (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:
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__.pyno longer directly defines it - Fix: Move
CloudConnectionOuttoapi/schemas.pyBEFORE splitting admin.py. Update cloud.py tofrom 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:
limitermoves toapi/auth/shared.py - Fix:
api/auth/__init__.pyre-exportslimiter.main.pyimport 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/orcelery_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.pyL222:from api.auth import limiter as auth_limiter- After split: update to wherever
limiterlands (viaapi.authre-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-retrynoRefreshPaths(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):
adminExportAuditLogCsv(L428-471) — fetch CSV, 401-retry, Blob downloadadminDownloadDailyExport(L492-529) — fetch CSV, 401-retry, Blob downloadfetchDocumentContent(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,deleteDocumentRemoveOnlyclassifyDocument,getUploadUrl,confirmUpload,uploadToCloudfetchDocumentContent,getDocumentContentUrl
api/auth.js:
login,register,refreshToken,logout,logoutAll,getMechangePassword,totpSetup,totpEnable,totpDisablepasswordResetRequest,passwordResetConfirmgetMyPreferences,updateMyPreferencesgetMyQuota
api/admin.js:
adminListUsers,adminCreateUser,adminDeactivateUser,adminReactivateUseradminResetUserPassword,adminGetUserQuota,adminUpdateQuota,adminUpdateAiConfig,adminDeleteUsergetAiConfig,saveAiConfig,testAiConnection,getAiModelsadminListAuditLog,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,updateDefaultStoragegetCloudFolders,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.jsSettingsAccountTab.vue,SettingsPreferencesTab.vue,TotpEnrollment.vueAppSidebar.vue,AuditLogTab.vue,AdminAiConfigTab.vue,AdminQuotasTab.vue,AdminUsersTab.vueCloudProviderTreeItem.vue,CloudFolderTreeItem.vue,CloudCredentialModal.vueFolderTreeItem.vue,DocumentView.vue,SharedView.vue,CloudFolderView.vue,AccountView.vueNewPasswordView.vue,PasswordResetView.vue
Pattern 2: Named imports import { funcName } from '...api/client.js'
SearchableModelSelect.vue:import { getAiModels }SettingsCloudTab.vue:import { initiateOAuth }AdminAiConfigTab.vue: alsoimport { getAiConfig, saveAiConfig, testAiConnection }DocumentCard.vue:import { moveDocument, classifyDocument }DocumentPreviewModal.vue:import { fetchDocumentContent }DocumentView.vue: alsoimport { 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:
// 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:
export default defineConfig({
plugins: [vue()],
build: { target: 'esnext' },
server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://backend:8000', changeOrigin: true } } },
})
-
resolve.conditionsdefault values changed: Defaults are no longer added internally for custom condition configs. Since this project has NO customresolve.conditions, this change does NOT affect the project. No config change needed. -
json.stringifydefaults to'auto': This project does not configurejson.stringify. The'auto'default (stringifies only large JSON files) is safe — no change needed. -
CSS output filenames (library mode): This is not a library mode build. No impact.
-
Sass modern API default: This project uses Tailwind (PostCSS), not Sass. No impact.
-
@vitejs/plugin-vuemust bump to 6.x: Vite 6 requires@vitejs/plugin-vue@^6.0.0. The current^5.0.0is incompatible. [VERIFIED: npm registry]@vitejs/plugin-vue@6.0.7is the latest 6.x release.
Config changes required:
- Bump
viteto^6.4.3 - Bump
@vitejs/plugin-vueto^6.0.7 - No
vite.config.jscontent changes required for this minimal config
Config changes NOT required:
build.target: 'esnext'— still valid in Vite 6server.proxy— unchanged- No
resolve.conditionscustomization needed
@tailwindcss/forms: config wiring required
Adding @tailwindcss/forms requires a one-line change to tailwind.config.js:
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:
# 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:
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
Sub-routers have NO prefix — they just define routes without path prefix:
# 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:
# 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
// 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:
- Move
requesttoutils.jsand import from there in bothclient.jsand domain files - Each domain file imports
requestfromclient.js— but this creates a circular dependency ifclient.jsre-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'.
// src/api/utils.js
export async function request(path, options = {}) { /* ... */ }
export async function fetchWithRetry(url, onResponse, _retry = false) { /* consolidates 3 blob patterns */ }
// src/api/documents.js
import { request } from './utils.js'
export function listDocuments(...) { return request('/api/documents?...') }
// 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
# 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
# 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
// 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
// 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 revokedtest_enable_totp_revokes_other_sessionstest_disable_totp_revokes_other_sessions
- Frontend: update
SettingsAccountTab.test.jsandTotpEnrollment.test.jsto mockuseToastStoreafter 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
-
create_system_topicplacement 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) orai.py(topics are AI-adjacent)? - Recommendation: Place in
users.py— topic creation is a content management function, not AI config.
- What we know: It's a one-line admin utility that calls
-
useToastStorestub 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.
- What we know: Phase 10 will implement the full toast system (UX-10). D-03 says Phase 7.1 only creates the stub with
-
alembic version discrepancy
- What we know:
requirements.txtsays>=1.18.4butpip3 index versionsshows installed1.16.5. - What's unclear: Is
1.18.4the required minimum or a typo? - Recommendation: Pin to
==1.16.5(the installed version). If1.18.4was intentional, note the mismatch for the user.
- What we know:
Sources
Primary (HIGH confidence)
backend/api/auth.py— full source read, CR-01/02/03 implementation verifiedbackend/api/admin.py— full source read, sub-module boundaries identifiedbackend/api/documents.py— full source read, sub-module boundaries identifiedbackend/services/auth.py—revoke_all_refresh_tokenssignature verifiedfrontend/src/api/client.js— full source read, all 635 lines, consumer mapping completebackend/main.py— router registration pattern verifiedfrontend/package.json— current dependency versions verifiedfrontend/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 decisionspip3 index versionsoutput — 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 versionetc. — registry versions verifiednpm listin 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, onlypip3 index versionsfor 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)