backend — Claude context
FastAPI async gateway, port 8000 (internal). Handles auth, user/group management, settings, and proxies document/category requests to doc-service:8001. See root CLAUDE.md for architecture, Docker, and project-wide workflows.
Commands
All commands run inside Docker — never on the host.
Migrations
Lint
File & Folder Tree
Database Models
users
| Column |
Type |
Constraints |
Notes |
id |
String |
PK, UUID |
auto-generated |
email |
String |
UNIQUE, indexed, NOT NULL |
lowercased before storing |
hashed_password |
String |
NOT NULL |
bcrypt 13 rounds |
full_name |
String |
nullable |
sanitized max 128 chars |
is_active |
Boolean |
default=True |
soft-delete flag |
is_superuser |
Boolean |
default=False |
admin role; never exposed as-is (serialised as is_admin) |
dashboard_app_ids |
JSON |
NOT NULL, default=[] |
list of pinned service IDs |
color_mode |
String |
nullable, default=NULL |
user's preferred mode: "light" / "dark" / "system" / NULL (use admin default) |
Relationship: profile (one-to-one, cascade all+delete-orphan)
profiles
| Column |
Type |
Constraints |
Notes |
id |
String |
PK, UUID |
auto-generated |
user_id |
String |
FK→users.id UNIQUE, cascade delete |
one-to-one |
phone |
String(20) |
nullable |
validated format |
date_of_birth |
Date |
nullable |
1900+ and not future |
position |
String(128) |
nullable |
job title |
address |
String(255) |
nullable |
|
updated_at |
DateTime(tz) |
server_default=now(), onupdate=now() |
|
groups
| Column |
Type |
Constraints |
id |
String |
PK, UUID |
name |
String(128) |
UNIQUE indexed, NOT NULL |
description |
String(512) |
nullable |
created_at |
DateTime(tz) |
server_default=now() |
group_memberships
| Column |
Type |
Constraints |
id |
String |
PK, UUID |
group_id |
String |
FK→groups.id, indexed, CASCADE |
user_id |
String |
FK→users.id, indexed, CASCADE |
is_group_admin |
Boolean |
NOT NULL, default=false |
joined_at |
DateTime(tz) |
server_default=now() |
Unique constraint: (group_id, user_id)
Migration chain (must be applied in order)
| Rev ID |
Slug |
38efeff7c45a |
create_users_table |
676084df61d1 |
add_profiles_table |
a3f9c2d14e87 |
add_groups_and_group_memberships |
c7e8f9a0b1d2 |
add_dashboard_app_ids_to_users |
dd6ad2f2c211 |
add_color_mode_to_users |
e1f2a3b4c5d6 |
add_group_member_is_admin |
API Endpoints
Auth (/api/auth) — public
| Method |
Path |
Auth |
Description |
| POST |
/api/auth/register |
— |
Create account; returns UserOut; enforces password policy |
| POST |
/api/auth/login |
— |
OAuth2 password flow; returns {access_token, token_type} |
Users (/api/users) — authenticated
| Method |
Path |
Auth |
Description |
| GET |
/api/users/me |
user |
Current user info → UserOut |
| GET |
/api/users/me/preferences |
user |
Dashboard pinned app IDs → {app_ids} |
| PATCH |
/api/users/me/preferences |
user |
Save pinned app IDs (max 50, slug-safe) |
| PATCH |
/api/users/me/color-mode |
user |
Save colour mode preference ("light"/"dark"/"system") |
| GET |
/api/users/me/groups |
user |
Groups current user belongs to → list[UserGroupOut] |
Profile (/api/profile) — authenticated
| Method |
Path |
Auth |
Description |
| GET |
/api/profile/me |
user |
Fetch profile; auto-creates if missing |
| PUT |
/api/profile/me |
user |
Update profile fields |
Admin — Users (/api/admin) — admin-only
| Method |
Path |
Description |
| GET |
/api/admin/users |
List all users → list[UserAdminOut] |
| POST |
/api/admin/users |
Create user (with optional is_admin) |
| DELETE |
/api/admin/users/{user_id} |
Delete user (204) |
| PATCH |
/api/admin/users/{user_id}/active |
Toggle active status |
Admin — Groups (/api/admin/groups) — admin-only
| Method |
Path |
Description |
| GET |
/api/admin/groups |
List groups with member count |
| POST |
/api/admin/groups |
Create group |
| GET |
/api/admin/groups/{id} |
Group detail + members |
| PATCH |
/api/admin/groups/{id} |
Update name / description |
| DELETE |
/api/admin/groups/{id} |
Delete (cascades memberships) |
| POST |
/api/admin/groups/{id}/members/{user_id} |
Add member |
| DELETE |
/api/admin/groups/{id}/members/{user_id} |
Remove member |
| PATCH |
/api/admin/groups/{id}/members/{user_id}/admin |
Set/unset group admin role (body: { is_group_admin: bool }) |
Settings (/api/settings) — admin-only
| Method |
Path |
Description |
| GET |
/api/settings/ai |
AI config (keys masked) |
| PATCH |
/api/settings/ai |
Update AI provider / credentials |
| POST |
/api/settings/ai/test |
Test AI connection |
| GET |
/api/settings/documents/limits |
PDF upload limits |
| PATCH |
/api/settings/documents/limits |
Update max PDF size |
| GET |
/api/settings/system-prompts |
All editable system prompts |
| PATCH |
/api/settings/system-prompts/{service_id} |
Update system prompt |
| GET |
/api/settings/appearance |
Active theme + default mode (auth) |
| PATCH |
/api/settings/appearance |
Update active theme + default mode (admin) |
| GET |
/api/settings/themes |
List all themes — built-in + custom (auth) |
| POST |
/api/settings/themes |
Create custom theme (admin) |
| PATCH |
/api/settings/themes/{id} |
Update custom theme label/colours (admin) |
| DELETE |
/api/settings/themes/{id} |
Delete custom theme (admin, 204) |
Services (/api/services) — authenticated
| Method |
Path |
Description |
| GET |
/api/services |
Health status of all registered services → list[ServiceStatus] |
Plugins (/api/plugins) — authenticated, auth-per-plugin
| Method |
Path |
Description |
| GET |
/api/plugins |
List plugins accessible to current user |
| GET |
/api/plugins/{id}/manifest |
Plugin manifest with settings JSON Schema (auth-gated) |
| GET |
/api/plugins/{id}/settings |
Proxy to feature /plugin/settings (auth-gated) |
| PATCH |
/api/plugins/{id}/settings |
Proxy to feature /plugin/settings (auth-gated) |
Auth: is_superuser OR member of group listed in manifest required_groups. Returns 404 (not 403) to hide existence.
Admin — Storage (/api/admin) — admin-only
| Method |
Path |
Description |
| GET |
/api/admin/storage-config |
Current backend driver + health → proxied from storage-service /health |
| PATCH |
/api/admin/storage-config |
Reconfigure backend without data migration (same-backend credential update) |
| POST |
/api/admin/storage-config/migrate |
Start async migration to a new backend (copy → verify → switch → cleanup) |
| GET |
/api/admin/storage-config/migrate/status |
Poll migration progress: {state, total, done, failed, errors[]} |
| DELETE |
/api/admin/storage-config/migrate |
Cancel a running migration; old backend remains active |
Documents and Categories — proxied
/api/documents/* and /api/documents/categories/* are transparently proxied to doc-service:8001. The backend injects x-user-id, x-user-groups, and x-user-is-admin headers. See features/doc-service/CLAUDE.md for the internal endpoint list.
Security Standards
These standards are non-negotiable. Every change must comply.
JWT
- Algorithm: RS256 (4096-bit RSA key pair, generated by
scripts/generate_jwt_keys.py)
- Keys: PEM-encoded in
backend/.env as JWT_PRIVATE_KEY / JWT_PUBLIC_KEY (gitignored)
- Expiry: 8 hours (
EXPIRE_MINUTES=480) — never set longer; no refresh tokens
- Claims:
{sub: user_id, exp, iat} — user_id is a UUID string
- Validation:
decode_access_token() in core/security.py; called by get_current_user
- Never: set algorithm to
"none", disable verify_exp, or hardcode secrets in code
Password hashing
- Algorithm: bcrypt, 13 rounds (
bcrypt.gensalt(rounds=13))
- Timing: ~300 ms per hash (intentional brute-force resistance)
- Never use MD5, SHA1, or plain SHA256 for password storage
Password policy (enforced in UserCreate schema)
All of the following must pass:
- ≥ 8 characters
- ≥ 1 uppercase (A–Z)
- ≥ 1 lowercase (a–z)
- ≥ 1 digit (0–9)
- ≥ 1 special character:
!@#$%^&*()\-_=+[]{}|;:'"<>?/\~`
- No common words (password, secret, login, admin, test, qwerty, welcome, …)
Input sanitization
Every user-supplied string stored in the database must pass through core/sanitize.py:
Apply via Pydantic @field_validator on all request schemas.
SQL injection prevention
- Use SQLAlchemy ORM (bound parameters) — never raw SQL strings.
- If
text() is needed, use bindparam() for all user-supplied values.
- Never use f-strings,
.format(), or %-formatting for SQL.
Admin route security
- Use
get_current_admin dependency (checks is_superuser).
- Return 404 (not 403) for unauthorized access — hides both endpoint existence and permission model.
Naming & Code Conventions
Database
- Tables: lowercase, plural, snake_case (
users, group_memberships, document_category_assignments)
- Columns: lowercase, snake_case
- ORM models: PascalCase, singular (
User, Group, GroupMembership, Document)
- Primary keys:
id (String UUID, auto-generated)
- Timestamps:
created_at / updated_at / joined_at / processed_at — always timezone-aware
Pydantic schemas
| Suffix |
Purpose |
Create |
POST request body (user-supplied input) |
Update |
PATCH request body (partial update) |
Out |
API response (safe subset of model) |
AdminOut |
Extended response for admin endpoints |
Read |
GET response (same as Out, used for profiles) |
Always set model_config = {"from_attributes": True} on response schemas.
Use validation_alias when the ORM field name differs from the JSON key (e.g., is_superuser → is_admin).
HTTP status codes
| Code |
Use |
| 200 |
Successful GET / PATCH / PUT |
| 201 |
Successful POST that creates a resource |
| 202 |
Accepted (async processing started, e.g., document upload) |
| 204 |
Successful DELETE or action with no response body |
| 400 |
Bad request (duplicates, invalid data beyond Pydantic) |
| 401 |
Missing / invalid JWT |
| 404 |
Not found and admin routes when not admin |
| 413 |
Payload too large (file exceeds limit) |
| 415 |
Unsupported media type (not a PDF) |
| 422 |
Pydantic validation failure (FastAPI default) |
| 502 |
Downstream service unreachable |
| 503 |
Service unavailable (queue stopped, AI error) |
| 504 |
Gateway timeout |
Backend code style
- Async/await for all I/O (DB, HTTP, file).
raise HTTPException(status_code=..., detail="...") for all errors.
- Response models always declared in route decorator:
@router.get("/path", response_model=XOut).
- Background tasks via
BackgroundTasks param; tasks open their own AsyncSessionLocal session.
- Commit + refresh pattern after mutations:
Default Values & Limits
| Parameter |
Value |
Location |
| JWT expiry |
480 min (8 h) |
core/security.py |
| Bcrypt rounds |
13 |
core/security.py |
User color_mode default |
NULL (falls back to admin default_mode, then system) |
models/user.py |
| Max dashboard pinned apps |
50 |
schemas/user.py |
| App ID max length |
64 chars |
schemas/user.py |
| App ID allowed chars |
[a-zA-Z0-9_\-] |
schemas/user.py |
| full_name max length |
128 chars |
schemas/user.py |
| Group name max length |
128 chars |
schemas/group.py |
| Group description max |
512 chars |
schemas/group.py |
| Phone max length |
20 chars |
sanitize.py |
| Position max length |
128 chars |
schemas/profile.py |
| Address max length |
255 chars |
schemas/profile.py |