# 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<Response>} — 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
