# Phase 8: Stack Upgrade & Backend Decomposition — Pattern Map **Mapped:** 2026-06-07 **Files analyzed:** 18 new/modified files **Analogs found:** 17 / 18 --- ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |---|---|---|---|---| | `backend/api/admin/__init__.py` | router aggregator | request-response | `backend/main.py` (router registration) | role-match | | `backend/api/admin/users.py` | controller | CRUD | `backend/api/admin.py` L228-360 | exact (source file) | | `backend/api/admin/quotas.py` | controller | CRUD | `backend/api/admin.py` (quota endpoints) | exact (source file) | | `backend/api/admin/ai.py` | controller | request-response | `backend/api/admin.py` (AI config endpoints) | exact (source file) | | `backend/api/admin/shared.py` | utility | transform | `backend/api/auth.py` L96-105 (`_user_dict`) | role-match | | `backend/api/documents/__init__.py` | router aggregator | request-response | `backend/main.py` (router registration) | role-match | | `backend/api/documents/upload.py` | controller | file-I/O | `backend/api/documents.py` (upload endpoints) | exact (source file) | | `backend/api/documents/crud.py` | controller | CRUD | `backend/api/documents.py` (CRUD endpoints) | exact (source file) | | `backend/api/documents/content.py` | controller | streaming | `backend/api/documents.py` (content endpoint) | exact (source file) | | `backend/api/documents/shared.py` | utility | transform | `backend/api/folders.py` L40-55 (Pydantic models) | role-match | | `backend/api/auth/__init__.py` | router aggregator | request-response | `backend/main.py` (router registration) | role-match | | `backend/api/auth/tokens.py` | controller | request-response | `backend/api/auth.py` L108-473 | exact (source file) | | `backend/api/auth/totp.py` | controller | request-response | `backend/api/auth.py` (TOTP endpoints) | exact (source file) | | `backend/api/auth/password.py` | controller | request-response | `backend/api/auth.py` L475-537 | exact (source file) | | `backend/api/auth/shared.py` | utility | transform | `backend/api/auth.py` L70-105 (helpers + models) | exact (source file) | | `backend/api/schemas.py` | model | transform | `backend/api/admin.py` L198-223 (`CloudConnectionOut`) | exact (source model) | | `frontend/src/api/utils.js` | utility | request-response | `frontend/src/api/client.js` L428-580 (blob functions) | exact (source functions) | | `frontend/src/api/documents.js` | service | request-response | `frontend/src/api/client.js` L59-111 | exact (source functions) | | `frontend/src/api/auth.js` | service | request-response | `frontend/src/api/client.js` L152-229 | exact (source functions) | | `frontend/src/api/admin.js` | service | request-response | `frontend/src/api/client.js` L231-530 | exact (source functions) | | `frontend/src/api/folders.js` | service | request-response | `frontend/src/api/client.js` (folder functions) | exact (source functions) | | `frontend/src/api/shares.js` | service | request-response | `frontend/src/api/client.js` (share functions) | exact (source functions) | | `frontend/src/api/cloud.js` | service | request-response | `frontend/src/api/client.js` L583-635 | exact (source functions) | | `frontend/src/api/topics.js` | service | request-response | `frontend/src/api/client.js` L112-144 | exact (source functions) | | `frontend/src/stores/toast.js` | store | event-driven | `frontend/src/stores/topics.js` | role-match | --- ## Pattern Assignments ### `backend/api/admin/__init__.py` (router aggregator, request-response) **Analog:** `backend/main.py` lines 314-343 (router registration) and RESEARCH.md code examples. **Core aggregation pattern** — `__init__.py` does ONLY router aggregation, nothing else: ```python 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) ``` **CRITICAL rule (D-04, Pitfall 1):** Sub-routers have NO prefix. The prefix lives in `main.py`: ```python # main.py — unchanged after decomposition: from api.admin import router as admin_router app.include_router(admin_router) # prefix="/api/admin" already set in old admin.py ``` Wait — reading main.py line 321: the current `admin.py` defines `router = APIRouter(prefix="/api/admin", tags=["admin"])` and `main.py` does `app.include_router(admin_router)` with NO explicit prefix in the `include_router` call. After decomposition, the `__init__.py` router must carry the prefix (or `main.py` must set it). Since sub-routers must have no prefix, `api/admin/__init__.py` should set the 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(prefix="/api/admin", tags=["admin"]) router.include_router(users_router) router.include_router(quotas_router) router.include_router(ai_router) ``` Sub-routers in each file have no prefix: ```python # api/admin/users.py router = APIRouter() # NO prefix @router.get("/users") # becomes /api/admin/users @router.post("/users") # becomes /api/admin/users @router.patch("/users/{id}/status") ``` **Limiter re-export pattern** (for `api/auth/__init__.py`): ```python # api/auth/__init__.py from api.auth.shared import limiter # re-exported so main.py import stays unchanged ``` --- ### `backend/api/admin/users.py` (controller, CRUD) **Analog:** `backend/api/admin.py` lines 225-360+ (user endpoints) and `backend/api/folders.py` for sub-router structure. **Imports pattern** (copy from `backend/api/admin.py` lines 24-47, prune to what users.py needs): ```python from __future__ import annotations import time import uuid from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, EmailStr, Field, field_validator from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from db.models import CloudConnection, Document, Quota, RefreshToken, Topic, User from deps.auth import get_current_admin from deps.db import get_db from deps.utils import get_client_ip from services.audit import write_audit_log from services.auth import hash_password, validate_password_strength from api.admin.shared import _user_to_dict ``` **Sub-router declaration** (no prefix — D-04): ```python router = APIRouter() ``` **Auth guard pattern** (every handler must inject `_admin`): ```python @router.get("/users") async def list_users( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), # NEVER omit this ) -> dict: ``` **CRUD handler + error handling pattern** (from `backend/api/admin.py` lines 245-285): ```python @router.post("/users", status_code=status.HTTP_201_CREATED) async def create_user( request: Request, body: UserCreate, session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: existing_email = await session.execute(select(User).where(User.email == str(body.email))) if existing_email.scalar_one_or_none() is not None: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered") # ... create user ... await write_audit_log(session, event_type="admin.user_create", metadata_={"created_user_id": str(new_user.id)}, ip_address=get_client_ip(request)) await session.commit() return _user_to_dict(new_user) ``` **Pydantic models** (move `UserCreate`, `UserStatusUpdate`, `UserAiConfigUpdate`, `UserDeleteConfirm`, `SystemTopicCreate` from `admin.py` lines 96-195 into `users.py`): ```python class UserCreate(BaseModel): handle: str email: EmailStr password: str role: str = "user" @field_validator("password") @classmethod def password_strength(cls, v: str) -> str: validate_password_strength(v) return v ``` --- ### `backend/api/admin/quotas.py` (controller, CRUD) **Analog:** `backend/api/admin.py` quota endpoints + `backend/api/folders.py` L86-110 (ownership check pattern). **Imports pattern** (minimal — only what quota endpoints need): ```python from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from db.models import Quota, User from deps.auth import get_current_admin from deps.db import get_db from api.admin.shared import _user_to_dict router = APIRouter() ``` **`QuotaUpdate` model** (from `admin.py` lines 113-121): ```python class QuotaUpdate(BaseModel): limit_bytes: int @field_validator("limit_bytes") @classmethod def must_be_positive(cls, v: int) -> int: if v <= 0: raise ValueError("limit_bytes must be greater than 0") return v ``` --- ### `backend/api/admin/ai.py` (controller, request-response) **Analog:** `backend/api/admin.py` AI config endpoints + `backend/api/admin.py` lines 129-179 (models). **Imports pattern**: ```python from __future__ import annotations from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, ConfigDict, field_validator from sqlalchemy.ext.asyncio import AsyncSession from ai import get_provider from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS from db.models import SystemSettings, User from deps.auth import get_current_admin from deps.db import get_db from services.ai_config import encrypt_api_key, load_provider_config_by_id from api.admin.shared import _ai_config_to_dict router = APIRouter() ``` **Pydantic models with `extra="forbid"` and cross-model validator** (from `admin.py` lines 129-179). The `provider_must_be_known` validator appears in both `SystemAiConfigUpdate` and `TestConnectionRequest` — migrate to `services/ai_config.py` as `validate_provider_id(v: str) -> str` (raises `ValueError`), then both models call it: ```python # services/ai_config.py — new function to add: def validate_provider_id(v: str) -> str: if v not in PROVIDER_DEFAULTS: raise ValueError(f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}") return v # api/admin/ai.py — models call the service: from services.ai_config import validate_provider_id class SystemAiConfigUpdate(BaseModel): model_config = ConfigDict(extra="forbid") provider_id: str # ... @field_validator("provider_id") @classmethod def provider_must_be_known(cls, v: str) -> str: return validate_provider_id(v) ``` --- ### `backend/api/admin/shared.py` (utility, transform) **Analog:** `backend/api/admin.py` lines 56-91 (`_ai_config_to_dict`, `_user_to_dict`) and `backend/api/auth.py` lines 96-105 (`_user_dict`). **Pattern:** Pure dict-serialization helpers, no imports from `__init__.py` (avoids circular import — Pitfall 2): ```python from __future__ import annotations from db.models import SystemSettings, User def _user_to_dict(user: User) -> dict: """Return safe subset of User fields — never includes password_hash, credentials_enc, totp_secret, or any document content (T-02-27, SEC-07). """ return { "id": str(user.id), "handle": user.handle, "email": user.email, "role": user.role, "is_active": user.is_active, "totp_enabled": user.totp_enabled, "ai_provider": user.ai_provider, "ai_model": user.ai_model, "password_must_change": user.password_must_change, "created_at": user.created_at.isoformat() if user.created_at else None, } def _ai_config_to_dict(row: SystemSettings) -> dict: """Return safe subset — explicitly excludes api_key_enc (T-07-01).""" return { "provider_id": row.provider_id, "base_url": row.base_url, "model_name": row.model_name, "context_chars": row.context_chars, "is_active": row.is_active, "has_api_key": row.api_key_enc is not None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } ``` --- ### `backend/api/documents/__init__.py` (router aggregator, request-response) **Analog:** Same aggregation pattern as `api/admin/__init__.py`. Current `documents.py` line 1: sets `router = APIRouter(prefix="/api/documents", tags=["documents"])`. ```python from fastapi import APIRouter from api.documents.upload import router as upload_router from api.documents.crud import router as crud_router from api.documents.content import router as content_router router = APIRouter(prefix="/api/documents", tags=["documents"]) router.include_router(upload_router) router.include_router(crud_router) router.include_router(content_router) ``` --- ### `backend/api/documents/upload.py` (controller, file-I/O) **Analog:** `backend/api/documents.py` upload endpoints. Sub-router with no prefix. **Imports pattern**: ```python from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from deps.auth import get_current_user from deps.db import get_db from deps.utils import get_client_ip from services.audit import write_audit_log from api.documents.shared import UploadUrlRequest, _CLOUD_PROVIDERS router = APIRouter() ``` --- ### `backend/api/documents/crud.py` (controller, CRUD) **Analog:** `backend/api/documents.py` CRUD endpoints + `backend/api/folders.py` L86-110 (ownership assertion pattern). **Ownership assertion pattern** (from `backend/api/folders.py` lines 104-106 — EVERY resource endpoint uses this): ```python if doc is None or doc.user_id != current_user.id: raise HTTPException(status_code=404, detail="Document not found") ``` **Imports pattern**: ```python from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from deps.auth import get_current_user from deps.db import get_db from deps.utils import get_client_ip from services.audit import write_audit_log from api.documents.shared import DocumentPatch, _CLOUD_PROVIDERS router = APIRouter() ``` --- ### `backend/api/documents/content.py` (controller, streaming) **Analog:** `backend/api/documents.py` `stream_document_content` and `_parse_range` helper. **Pattern:** Contains ONLY `stream_document_content` and the `_parse_range` private helper — the only endpoint that needs it: ```python from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from deps.auth import get_current_user from deps.db import get_db router = APIRouter() def _parse_range(range_header: str, total_size: int): """Parse Range header for partial content (HTTP 206). Only used by stream_document_content.""" # ... (copy verbatim from documents.py lines 744-760) ``` --- ### `backend/api/documents/shared.py` (utility, transform) **Analog:** `backend/api/folders.py` lines 40-55 (Pydantic models block) and `backend/api/admin.py` lines 96-195 (models block). **Pattern:** ```python from __future__ import annotations from typing import Optional from pydantic import BaseModel, field_validator # Shared constant (used by upload.py and crud.py) _CLOUD_PROVIDERS = frozenset({"google_drive", "onedrive", "nextcloud", "webdav"}) class UploadUrlRequest(BaseModel): filename: str content_type: str class DocumentPatch(BaseModel): filename: Optional[str] = None topic: Optional[str] = None folder_id: Optional[str] = None @field_validator("filename") @classmethod def filename_no_path_separators(cls, v): # Security validator: belongs at API boundary (not migrated — D-11 analysis) if v and ('/' in v or '\\' in v): raise ValueError("Filename must not contain path separators") return v ``` --- ### `backend/api/auth/__init__.py` (router aggregator, request-response) **Analog:** Same aggregation pattern. Current `auth.py` line 43 sets prefix `"/api/auth"`. **Limiter re-export** (critical — `main.py` line 21 and `tests/conftest.py` line 222 import `limiter` from `api.auth`): ```python from fastapi import APIRouter from api.auth.tokens import router as tokens_router from api.auth.totp import router as totp_router from api.auth.password import router as password_router from api.auth.shared import limiter # re-exported: "from api.auth import limiter" still works router = APIRouter(prefix="/api/auth", tags=["auth"]) router.include_router(tokens_router) router.include_router(totp_router) router.include_router(password_router) ``` --- ### `backend/api/auth/tokens.py` (controller, request-response) **Analog:** `backend/api/auth.py` lines 108-473 (register, login, refresh, logout, me, quota, preferences). **Rate-limiter decorator pattern** (from `backend/api/auth.py` lines 110-112): ```python @router.post("/register", status_code=status.HTTP_201_CREATED) @limiter.limit("10/minute") async def register(request: Request, body: RegisterRequest, session: AsyncSession = Depends(get_db)): ``` **Imports pattern**: ```python from __future__ import annotations import hashlib import time import uuid from typing import Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from db.models import BackupCode, Quota, RefreshToken, User from deps.auth import get_current_user from deps.db import get_db from deps.utils import get_client_ip from services import auth as auth_service from services.audit import write_audit_log from api.auth.shared import ( limiter, RegisterRequest, LoginRequest, _set_refresh_cookie, _user_dict ) router = APIRouter() ``` **ValueError-to-HTTPException pattern** (from `backend/api/auth.py` lines 124-128 — service layer raises ValueError, router catches): ```python try: auth_service.validate_password_strength(body.password) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) ``` --- ### `backend/api/auth/totp.py` (controller, request-response) **Analog:** `backend/api/auth.py` TOTP endpoints (`totp_setup`, `enable_totp`, `disable_totp`). **Imports pattern**: ```python from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession from db.models import User from deps.auth import get_current_user from deps.db import get_db from deps.utils import get_client_ip from services import auth as auth_service from services.audit import write_audit_log from api.auth.shared import limiter, TotpEnableRequest, _set_refresh_cookie router = APIRouter() ``` --- ### `backend/api/auth/password.py` (controller, request-response) **Analog:** `backend/api/auth.py` lines 475-537 (`change_password`) and password reset endpoints. **Session revocation pattern** (CR-01/02/03 — already implemented, copy verbatim): ```python # From backend/api/auth.py lines 516-518 (change_password): skip_hash = None raw = request.cookies.get("refresh_token") if raw: skip_hash = hashlib.sha256(raw.encode()).hexdigest() revoked = await auth_service.revoke_all_refresh_tokens( session, current_user.id, skip_token_hash=skip_hash ) await write_audit_log(session, event_type="auth.password_change", user_id=current_user.id, metadata_={"sessions_revoked": revoked}, ip_address=get_client_ip(request)) return {"message": "Password updated", "sessions_revoked": revoked} ``` --- ### `backend/api/auth/shared.py` (utility, transform) **Analog:** `backend/api/auth.py` lines 40-105 (limiter, request models, cookie helper, user dict). **Pattern:** Contains everything imported by 2+ auth sub-modules: ```python from __future__ import annotations from typing import Optional from fastapi import Response from pydantic import BaseModel, EmailStr from slowapi import Limiter from config import settings from deps.utils import get_client_ip # IP-level rate limiter — re-exported from api/auth/__init__.py for main.py limiter = Limiter(key_func=get_client_ip) class RegisterRequest(BaseModel): handle: str email: EmailStr password: str class LoginRequest(BaseModel): email: EmailStr password: str totp_code: Optional[str] = None backup_code: Optional[str] = None remember_me: bool = False class ChangePasswordRequest(BaseModel): current_password: str new_password: str class TotpEnableRequest(BaseModel): code: str class PasswordResetRequest(BaseModel): email: EmailStr class PasswordResetConfirmRequest(BaseModel): token: str new_password: str class PreferencesUpdate(BaseModel): # ... fields from existing auth.py pass def _set_refresh_cookie(response: Response, raw_token: str, remember_me: bool = False) -> None: """Set httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).""" max_age = ( settings.refresh_token_expire_days * 86400 if remember_me else settings.refresh_token_expire_hours * 3600 ) response.set_cookie( key="refresh_token", value=raw_token, httponly=True, secure=True, samesite="strict", path="/api/auth/refresh", max_age=max_age, ) def _user_dict(user) -> dict: """Return serialisable user metadata — no password_hash, no credentials_enc.""" return { "id": str(user.id), "handle": user.handle, "email": user.email, "role": user.role, "totp_enabled": user.totp_enabled, "created_at": user.created_at.isoformat() if user.created_at else None, } ``` --- ### `backend/api/schemas.py` (model, transform) **Analog:** `backend/api/admin.py` lines 198-223 (`CloudConnectionOut`) — move this model verbatim. **Pattern:** New file for cross-package Pydantic models (D-10). Created BEFORE splitting admin.py to fix the circular import (Pitfall 3): ```python """Cross-package Pydantic response models. Models here are used by 2+ API packages and cannot live in a single package without creating circular imports (D-10, RESEARCH.md Pitfall 3). """ from __future__ import annotations from datetime import datetime from typing import Optional from pydantic import BaseModel, field_validator class CloudConnectionOut(BaseModel): """SEC-08: credentials_enc deliberately excluded. 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) ``` **Import update required in `backend/api/cloud.py` line 35** (before splitting admin.py): ```python # Before: from api.admin import CloudConnectionOut # After: from api.schemas import CloudConnectionOut ``` --- ### `frontend/src/api/utils.js` (utility, request-response) **Analog:** `frontend/src/api/client.js` lines 428-580 (the three blob-download functions sharing identical auth+retry logic). **Core pattern — `request` function** (moves here from `client.js` to avoid circular imports — Pitfall 4): ```javascript /** * HTTP transport layer. Moved from client.js to utils.js to break the circular * dependency: domain files need request(), client.js re-exports domain files. * * Security: Bearer token injected from authStore (memory only — CLAUDE.md). * On 401: refresh once via authStore.refresh(), retry with _retry guard. */ export async function request(path, options = {}) { 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(path, { ...options, headers, credentials: 'include' }) const noRefreshPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh'] if (res.status === 401 && !options._retry && !noRefreshPaths.includes(path)) { try { await authStore.refresh() return request(path, { ...options, _retry: true }) } catch { authStore.accessToken = null authStore.user = null throw new Error('Session expired') } } if (!res.ok) { let msg = `HTTP ${res.status}` let payload = null try { const body = await res.json() if (typeof body.detail === 'object' && body.detail !== null) { payload = body.detail msg = body.detail.message || `HTTP ${res.status}` } else { msg = body.detail || msg } } catch {} const err = new Error(msg) err.status = res.status if (payload) err.payload = payload throw err } if (res.status === 204 || res.headers.get('content-length') === '0') return null return res.json() } ``` **`fetchWithRetry` — consolidates 3 blob patterns** (from `client.js` lines 428-580): ```javascript /** * Authenticated fetch with 401-retry for non-JSON responses (blobs, raw Response). * * Consolidates adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent * which share identical auth-injection + 401-retry boilerplate. * * @param {string} url — full URL to fetch * @param {RequestInit} [options] — fetch options (method, headers, etc.) * @param {boolean} [_retry] — internal retry guard (do not pass) * @returns {Promise} — raw Response; caller decides how to consume it */ 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 } ``` --- ### `frontend/src/api/documents.js` (service, request-response) **Analog:** `frontend/src/api/client.js` lines 59-111 (document functions) and lines 552-581 (`fetchDocumentContent`). **Imports pattern** (uses `request` from `utils.js`, NOT from `client.js` — avoids circular): ```javascript import { request, fetchWithRetry } from './utils.js' ``` **Core function pattern** — all functions follow one of two forms: ```javascript // Form 1: request() for JSON-returning endpoints export function listDocuments({ topic, page = 1, perPage = 20, folderId = null, q = null, sort = null, order = null } = {}) { const params = new URLSearchParams({ page, per_page: perPage }) if (topic) params.set('topic', topic) if (folderId != null) params.set('folder_id', folderId) if (q) params.set('q', q) if (sort) params.set('sort', sort) if (order) params.set('order', order) return request(`/api/documents?${params}`) } // Form 2: fetchWithRetry for non-JSON (raw Response) — replaces fetchDocumentContent export async function fetchDocumentContent(docId, options = {}) { const res = await fetchWithRetry(`/api/documents/${docId}/content`, options) if (!res.ok) throw new Error(`Failed to fetch document content: ${res.status}`) return res } ``` **Functions to move here** (from client.js): `listDocuments`, `getDocument`, `deleteDocument`, `deleteDocumentRemoveOnly`, `classifyDocument`, `getUploadUrl`, `confirmUpload`, `uploadToCloud`, `fetchDocumentContent`, `getDocumentContentUrl`. --- ### `frontend/src/api/auth.js` (service, request-response) **Analog:** `frontend/src/api/client.js` lines 152-229. **Imports pattern**: ```javascript import { request } from './utils.js' ``` **Functions to move here** (from client.js): `login`, `register`, `refreshToken`, `logout`, `logoutAll`, `getMe`, `changePassword`, `totpSetup`, `totpEnable`, `totpDisable`, `passwordResetRequest`, `passwordResetConfirm`, `getMyPreferences`, `updateMyPreferences`, `getMyQuota`. --- ### `frontend/src/api/admin.js` (service, request-response) **Analog:** `frontend/src/api/client.js` lines 231-529 (admin functions including blob-download functions). **Imports pattern**: ```javascript import { request, fetchWithRetry } from './utils.js' ``` **Blob-download function pattern** — `adminExportAuditLogCsv` and `adminDownloadDailyExport` refactored to use `fetchWithRetry` (from client.js lines 428-529): ```javascript export async function adminExportAuditLogCsv(params = {}) { const searchParams = new URLSearchParams({ format: 'csv' }) if (params.start) searchParams.set('start', params.start) if (params.end) searchParams.set('end', params.end) if (params.user_handle) searchParams.set('user_handle', params.user_handle) if (params.event_type) searchParams.set('event_type', params.event_type) const res = await fetchWithRetry(`/api/admin/audit-log/export?${searchParams}`) if (!res.ok) throw new Error(`Export failed: ${res.status}`) const text = await res.text() const blob = new Blob([text], { type: 'text/csv' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'audit-export.csv' document.body.appendChild(a) a.click() document.body.removeChild(a) setTimeout(() => URL.revokeObjectURL(url), 1000) } ``` **Functions to move here** (from client.js): `adminListUsers`, `adminCreateUser`, `adminDeactivateUser`, `adminReactivateUser`, `adminResetUserPassword`, `adminGetUserQuota`, `adminUpdateQuota`, `adminUpdateAiConfig`, `adminDeleteUser`, `getAiConfig`, `saveAiConfig`, `testAiConnection`, `getAiModels`, `adminListAuditLog`, `adminExportAuditLogCsv`, `adminListDailyExports`, `adminDownloadDailyExport`. --- ### `frontend/src/api/folders.js`, `shares.js`, `cloud.js`, `topics.js` (service, request-response) **Analog:** `frontend/src/api/client.js` corresponding function blocks. **Shared imports pattern** (same for all four): ```javascript import { request } from './utils.js' ``` **`cloud.js` includes `initiateOAuth`** (named import used by `SettingsCloudTab.vue` — must be exported): ```javascript // cloud.js export function initiateOAuth(provider) { return request(`/api/cloud/oauth/initiate/${provider}`) } ``` **Function assignment** (from client.js): - `folders.js`: `listFolders`, `createFolder`, `getFolder`, `renameFolder`, `deleteFolder`, `moveDocument` - `shares.js`: `createShare`, `updateSharePermission`, `listShares`, `deleteShare`, `getSharedWithMe` - `cloud.js`: `listCloudConnections`, `disconnectCloud`, `connectWebDav`, `updateDefaultStorage`, `getCloudFolders`, `initiateOAuth`, `getConnectionConfig` - `topics.js`: `listTopics`, `createTopic`, `updateTopic`, `deleteTopic`, `suggestTopics` --- ### `frontend/src/api/client.js` (transport + barrel, request-response) — MODIFIED **Analog:** Current `frontend/src/api/client.js` lines 1-57 (the `request` function) and RESEARCH.md barrel pattern. **After decomposition, client.js becomes ONLY:** ```javascript /** * API client — HTTP transport re-export barrel. * * request() and noRefreshPaths have moved to utils.js to avoid circular imports * (domain files import request from utils.js; client.js re-exports domain files). * * All 35+ consumer files continue using: * import * as api from '...api/client.js' — namespace pattern * import { funcName } from '...api/client.js' — named import pattern * without any changes (barrel preserves all exports). */ export * from './documents.js' export * from './auth.js' export * from './admin.js' export * from './folders.js' export * from './shares.js' export * from './cloud.js' export * from './topics.js' export { fetchWithRetry, request } from './utils.js' ``` **No export name collisions** — all existing function names are unique (verified: RESEARCH.md §Pitfall 5). --- ### `frontend/src/stores/toast.js` (store, event-driven) **Analog:** `frontend/src/stores/topics.js` (simplest existing Pinia store — setup store pattern with no data fetching). **Imports and defineStore pattern** (from `topics.js` lines 1-5): ```javascript import { defineStore } from 'pinia' ``` **Stub pattern** (D-03: stub only, Phase 10 fills in full implementation): ```javascript /** * useToastStore — Pinia toast notification store. * * Phase 7.1 STUB: show() is a no-op. Phase 10 (UX-10) fills in the full * implementation (queue, auto-dismiss, animation). * * Call sites: SettingsAccountTab.vue, TotpEnrollment.vue * Contract: show(message: string, type: string = 'info') — Phase 10 must * honor this exact signature. */ import { defineStore } from 'pinia' export const useToastStore = defineStore('toast', () => { function show(message, type = 'info') { // Stub: Phase 10 fills in full implementation (UX-10) // Phase 7.1 call sites wire to this; visible behavior ships in Phase 10 } return { show } }) ``` **Call-site wiring pattern** (replace existing `sessionRevokedToast` ref in `SettingsAccountTab.vue` and `TotpEnrollment.vue`): ```javascript // Before (in SettingsAccountTab.vue lines 225-228): sessionRevokedToast.value = true setTimeout(() => { sessionRevokedToast.value = false }, 5000) // After: import { useToastStore } from '../stores/toast.js' const toastStore = useToastStore() // In the handler body: if (data.sessions_revoked > 0) { toastStore.show('Other sessions have been terminated.', 'success') } ``` --- ## Shared Patterns ### Auth guard (backend) **Source:** `backend/api/admin.py` lines 228-232 and `backend/api/folders.py` lines 87-92. **Apply to:** ALL admin sub-module handlers (`users.py`, `quotas.py`, `ai.py`). ```python _admin: User = Depends(get_current_admin) # Admin endpoints — NEVER omit current_user: User = Depends(get_regular_user) # Regular user endpoints current_user: User = Depends(get_current_user) # Auth endpoints (admin+user allowed) ``` ### ValueError-to-HTTPException bridge **Source:** `backend/api/auth.py` lines 124-128. **Apply to:** All auth sub-modules and any router that calls a service function. ```python try: service_function(value) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) ``` ### Ownership assertion (IDOR prevention) **Source:** `backend/api/folders.py` lines 104-106. **Apply to:** All document, folder, share, and cloud sub-module handlers. ```python if resource is None or resource.user_id != current_user.id: raise HTTPException(status_code=404, detail="Resource not found") ``` ### Audit log write **Source:** `backend/api/admin.py` lines 248 and `backend/api/auth.py` lines 526. **Apply to:** All sub-module handlers that mutate state. ```python await write_audit_log( session, event_type="admin.user_create", user_id=current_user.id, # omit for admin endpoints where subject differs metadata_={"key": "value"}, # no document content, no credentials ip_address=get_client_ip(request), ) ``` ### No-prefix sub-router declaration (D-04) **Source:** RESEARCH.md §Pitfall 1, confirmed by reading existing `folders.py` line 37 (which uses full prefix) — the NEW pattern for sub-modules: **Apply to:** Every `router = APIRouter()` in every sub-module file. ```python router = APIRouter() # NO prefix — parent __init__.py or main.py sets it ``` ### `from __future__ import annotations` **Source:** `backend/api/admin.py` line 24, `backend/api/auth.py` line 20, `backend/api/folders.py` line 19. **Apply to:** All new Python files. ### Frontend domain module import line **Source:** Derived from circular-import analysis (RESEARCH.md §Pitfall 4). **Apply to:** All new frontend API domain modules. ```javascript import { request } from './utils.js' // NOT from './client.js' import { fetchWithRetry } from './utils.js' // only in documents.js and admin.js ``` --- ## No Analog Found | File | Role | Data Flow | Reason | |---|---|---|---| | `frontend/tailwind.config.js` (modified) | config | N/A | Config-file plugin wiring — pattern from `@tailwindcss/forms` docs | All other files have clear analogs in the existing codebase. --- ## Execution Order Constraints (for planner) The following ordering is mandatory to avoid broken intermediate states: 1. **Create `backend/api/schemas.py`** and update `backend/api/cloud.py` import — BEFORE splitting `admin.py`. 2. **Split `backend/api/admin.py`** into `api/admin/` package — after schemas.py exists. 3. **Split `backend/api/documents.py`** and `backend/api/auth.py`** — can run in parallel with admin split. 4. **Move `request()` to `frontend/src/api/utils.js`** — BEFORE creating domain modules (domain modules import from utils.js). 5. **Create domain modules** — after utils.js exists. 6. **Update `client.js` to barrel re-export** — AFTER all domain modules exist. 7. **Create `frontend/src/stores/toast.js` stub** — independent, can be done in Wave 1. 8. **Wire call sites** (`SettingsAccountTab.vue`, `TotpEnrollment.vue`) — after toast.js stub exists. --- ## Metadata **Analog search scope:** `backend/api/`, `frontend/src/api/`, `frontend/src/stores/` **Files scanned:** 12 source files (admin.py, auth.py, documents.py, folders.py, cloud.py, topics.py, main.py, client.js, auth.js store, documents.js store, topics.js store, shares.py) **Pattern extraction date:** 2026-06-07