Files
kite/.planning/milestones/v0.2-phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
T
curo1305andClaude Sonnet 4.6 123ae5b29b chore: archive v0.2 phase directories to milestones/v0.2-phases/
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>
2026-06-17 14:34:52 +02:00

38 KiB

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:

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:

# 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:

# 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:

# 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):

# 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):

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):

router = APIRouter()

Auth guard pattern (every handler must inject _admin):

@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):

@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):

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):

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):

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:

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:

# 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):

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"]).

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:

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):

if doc is None or doc.user_id != current_user.id:
    raise HTTPException(status_code=404, detail="Document not found")

Imports pattern:

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:

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:

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):

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):

@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:

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):

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:

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):

# 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:

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):

"""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):

# 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):

/**
 * 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):

/**
 * 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):

import { request, fetchWithRetry } from './utils.js'

Core function pattern — all functions follow one of two forms:

// 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:

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:

import { request, fetchWithRetry } from './utils.js'

Blob-download function patternadminExportAuditLogCsv and adminDownloadDailyExport refactored to use fetchWithRetry (from client.js lines 428-529):

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):

import { request } from './utils.js'

cloud.js includes initiateOAuth (named import used by SettingsCloudTab.vue — must be exported):

// 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:

/**
 * 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):

import { defineStore } from 'pinia'

Stub pattern (D-03: stub only, Phase 10 fills in full implementation):

/**
 * 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):

// 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).

_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.

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.

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.

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.

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.

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