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>
1076 lines
38 KiB
Markdown
1076 lines
38 KiB
Markdown
# 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
|