Compare commits

...
8 Commits
Author SHA1 Message Date
curo1305andClaude Sonnet 4.6 25e568973f docs(08): create phase 8 plan — 8 plans, 3 waves (stack upgrade + backend decomposition)
Wave 0: CR-01/02/03 test stubs + api/schemas.py (CloudConnectionOut migration)
Wave 1: useToastStore stub + Phase 7.1 frontend completion
Wave 2 (parallel): api/admin/, api/documents/, api/auth/ package splits + client.js decomposition + PERF-01 deps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:59:27 +02:00
curo1305 c20a5d8913 docs(08): research phase — decomposition boundaries, CR status, Vite 6 migration 2026-06-07 11:01:54 +02:00
curo1305andClaude Sonnet 4.6 118f4ee850 docs(08): UI design contract — toast API contract + sessions-revoked interaction spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:50:39 +02:00
curo1305andClaude Sonnet 4.6 845b681b36 docs(08): UI design contract — useToastStore API and sessions-revoked spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:49:01 +02:00
curo1305 6f2dba8478 docs(state): record phase 8 context session 2026-06-07 10:39:50 +02:00
curo1305 28e1e4aaf4 docs(08): capture phase context 2026-06-07 10:39:32 +02:00
curo1305andClaude Sonnet 4.6 258b0006d6 docs: create milestone v0.2 roadmap (4 phases, 40 requirements)
Phases 8–11: stack upgrade + backend decomposition, admin panel
rearchitecture, UX & interaction, visual design + responsive layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:50:52 +02:00
curo1305andClaude Sonnet 4.6 f7758776ec docs: define milestone v0.2 requirements (36 reqs, 6 categories)
UI overhaul, codebase quality, admin panel rearchitecture, UX
improvements, responsive layout, and performance/stack updates.
Research: STACK.md, FEATURES.md, ARCHITECTURE.md, PITFALLS.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:22:40 +02:00
22 changed files with 6029 additions and 2359 deletions
+66 -76
View File
@@ -8,103 +8,93 @@ DocuVault is a self-hosted, multi-user SaaS document management platform. Users
Every user's documents — and the credentials they use to store them — are inaccessible to everyone except that user, while the platform scales horizontally and supports pluggable storage backends.
## Current Milestone: v0.2 — UI Overhaul and Optimization
**Goal:** Redesign the frontend into a polished, performant, and responsive interface, and overhaul both the backend and frontend codebase to senior-dev quality — minimal, DRY, readable code with no unnecessary comments.
**Target features:**
- Visual redesign — refined Tailwind component system, consistent palette, typography, spacing
- UX & interaction improvements — drag-and-drop upload, keyboard shortcuts, empty states, loading skeletons
- Frontend performance — lazy loading, virtual scrolling, bundle size reduction, API response caching
- Responsive / mobile layout — adaptive layouts for phones/tablets, touch-friendly controls
- Proper admin panel — standalone admin interface with its own routes, layout, and nav (not tabs bolted onto the user app)
- Codebase quality overhaul — eliminate duplication, extract shared utilities, delete dead code, flatten unnecessary abstractions; comments only where code alone is insufficient
## Requirements
### Validated
### Validated (v0.1 — shipped 2026-06-06)
*Capabilities already shipping in the codebase:*
- ✓ Document upload and text extraction (PDF, DOCX, image, plain text)
- ✓ AI-based topic classification via configurable provider
- ✓ Multiple AI provider support (Anthropic, OpenAI, GenericOpenAI-compat, Ollama, LMStudio)
- ✓ Topic CRUD management (per-user namespace)
- ✓ Docker containerization (Compose) with PostgreSQL + MinIO
- ✓ User registration with email/password (Argon2id, strength enforcement, HaveIBeenPwned check)
- ✓ JWT session management (ES256 asymmetric, 15 min access token, 16h/30d refresh, token fingerprinting, JTI revocation)
- ✓ TOTP 2FA with backup codes; session revocation on privilege change
- ✓ Admin: create, deactivate, reset password, assign AI provider per user
- ✓ Admin cannot access user documents or cloud credentials
- ✓ Per-user isolated storage with 100 MB free-tier quota (atomic enforcement)
- ✓ Folder creation, rename, delete, move documents between folders
- ✓ Document sharing by handle (view/edit permission, revocable, no recipient quota charge)
- ✓ "Shared with me" virtual folder
- ✓ Cloud storage backends: OneDrive, Google Drive, Nextcloud, WebDAV (HKDF-encrypted credentials)
- ✓ PDF in-browser preview (proxied, no presigned URL exposure)
- ✓ Full-text search and sorting on document list
- ✓ Audit log (metadata only): logins, uploads, deletes, shares, quota changes
- ✓ Admin audit log viewer with date/user/action filters and CSV export
- ✓ Structured JSON logging (structlog + correlation IDs)
- ✓ Container hardening (non-root user, read-only filesystem, dropped capabilities)
- ✓ AI provider settings in `system_settings` DB table (HKDF-encrypted API keys)
- ✓ Celery retry backoff (30 s / 90 s / 270 s) on classification failure
- ✓ Backend stateless — all state in PostgreSQL and MinIO
- ✓ Document upload and text extraction (PDF, DOCX, image, plain text) — existing
- ✓ AI-based topic classification via configurable provider — existing
- ✓ Multiple AI provider support (Anthropic, OpenAI, Ollama, LMStudio) — existing
- ✓ Topic CRUD management — existing
- ✓ System prompt configuration — existing
- ✓ Docker containerization (Compose) — existing
### Active (v0.2 — this milestone)
### Active
**Users & Auth**
- [ ] User can register with email and password (enforced strength: length, complexity, breach check)
- [ ] User can log in and maintain session via JWT
- [ ] User can enable TOTP authenticator app for 2FA
- [ ] Admin can create, deactivate, and reset passwords for user accounts
- [ ] Admin cannot access any user's documents or cloud storage credentials
**Storage & Quotas**
- [ ] Each user has an isolated storage area with a 100 MB free-tier quota
- [ ] Quota usage is tracked and enforced; uploads exceeding quota are rejected with a clear error
- [ ] Admin can adjust individual user storage quotas
- [ ] Platform migrates from flat-file JSON + filesystem to PostgreSQL + MinIO (S3-compatible)
**Folder Structure**
- [ ] User can create, rename, and delete folders to organize documents
- [ ] Document organization is preserved on move/rename (no auto-rearrangement by AI)
- [ ] A "Shared with me" folder appears automatically when another user shares a document
**Document Sharing**
- [ ] User can share a document (or folder) with another user by their unique handle
- [ ] Shared access is view-only by default; owner controls permission level
- [ ] Revoking share removes access immediately; shared copy is not duplicated in recipient's quota
**Cloud Storage Integration**
- [ ] User can connect an external cloud storage backend (OneDrive, Google Drive, Nextcloud; extensible)
- [ ] Local storage and cloud storage coexist; user selects their default storage destination
- [ ] Cloud storage credentials are encrypted at rest and never readable by admins
- [ ] Documents stored in cloud backend are accessed via the app without being re-copied to local storage
**AI Configuration (Admin-controlled)**
- [ ] Admin can assign an AI provider and model per user or per group
- [ ] System-wide default AI provider and model set by admin
- [ ] Users cannot change their own AI provider or model
- [ ] Per-user topic overrides on top of system default topics
**Audit Logging**
- [ ] Audit log captures: logins, failed logins, uploads, deletes, sharing events, quota changes
- [ ] Audit log records metadata only — no document content
- [ ] Admin can view and filter audit logs
**Scalability**
- [ ] Backend stateless — multiple instances can run behind a load balancer
- [ ] All state in PostgreSQL and MinIO (no local file locks, no per-instance JSON)
*Defined in REQUIREMENTS.md*
### Out of Scope
- Subscription billing / payment processing — future milestone (quotas designed to plug in)
- Subscription billing / payment processing — future milestone (quota table designed for it)
- SSO (Microsoft, Google, Apple) — future; auth layer designed for extension
- Keycloak / SAML / OAuth enterprise federation — future
- Group admin roles — future; groups table will be seeded in schema
- Group admin roles — future; groups table seeded in schema
- Document annotation or in-app editing — not planned
- Mobile app — not planned
- Public document sharing (unauthenticated link) — not planned for v1
- Mobile native app — not planned (responsive web only in v0.2)
- Public document sharing (unauthenticated link) — not planned for v0.x
## Context
- **Existing codebase**: Functional single-user document scanner (FastAPI + Vue 3, Docker Compose). AI provider abstraction already in place — cloud storage will follow the same adapter pattern.
- **Brownfield migration**: Flat-file JSON persistence and per-process file locks must be replaced with PostgreSQL + MinIO before multi-user isolation is safe.
- **Privacy constraint**: SaaS model with strict admin/user data separation. Admin role is a platform operator, not a content viewer. Cloud credentials must be encrypted server-side; the encryption key must not be readable by admin queries.
- **Free tier baseline**: 100 MB per user. Quota model should be designed so future subscription tiers can expand it without schema changes.
- **Cloud storage**: Follows same provider/adapter pattern as existing AI providers. Each cloud integration is an adapter implementing a common StorageBackend interface.
- **Current state**: v0.1 complete (2026-06-06). All foundation features shipped and security-hardened. App is functional for local/self-hosted use but not hardened for public internet deployment.
- **Tech stack**: FastAPI 0.136+ (Python 3.12), SQLAlchemy 2.0 async, Alembic, MinIO SDK; Vue 3 (Options API), Pinia, Vue Router 4, Vite, Tailwind CSS.
- **Code quality**: v0.1 was built feature-first under time pressure. Both backend and frontend contain duplication, inconsistent patterns, and components that grew beyond their original scope. v0.2 addresses this systematically.
- **Admin panel**: AdminView.vue currently renders admin functionality as tabs appended to the main user layout. This is architecturally wrong — the admin interface needs its own route subtree, layout component, and nav.
- **Privacy constraint**: Admin role is a platform operator, not a content viewer. Cloud credentials encrypted with per-user HKDF key; API keys encrypted with separate HKDF domain. Neither is ever in an API response.
## Constraints
- **Tech stack**: FastAPI (Python) + Vue 3 — keep existing stack, extend it
- **Database**: PostgreSQL (replaces flat-file JSON)
- **Object storage**: MinIO (S3-compatible, Docker-native) — replaces local filesystem for documents
- **Auth**: bcrypt passwords, JWT sessions, TOTP 2FA (PyOTP / similar)
- **Cloud credentials**: Encrypted at rest (Fernet symmetric encryption or PostgreSQL pgcrypto) — key in env var, never in DB
- **Scalability target**: Horizontal (multiple backend containers) — no file-system-level coordination
- **Deployment**: Docker Compose (must remain the primary deployment target)
- **Tech stack**: FastAPI (Python) + Vue 3 — keep existing stack, no framework switch
- **Database**: PostgreSQL (Alembic-managed schema, 5 migrations completed)
- **Object storage**: MinIO (S3-compatible, Docker-native)
- **Auth**: Argon2id passwords, ES256 JWT, TOTP 2FA (pyotp), HKDF key derivation
- **Deployment**: Docker Compose (primary deployment target, must remain so)
- **No rewrites**: Refactor, don't rewrite. Preserve all existing functionality and test coverage.
## Key Decisions
| Decision | Rationale | Outcome |
|---|---|---|
| PostgreSQL + MinIO over flat files | Multi-user quotas + horizontal scaling require shared, consistent state | Replacing JSON + filesystem |
| Cloud storage adapter pattern | Mirrors existing AI provider pattern — consistent, extensible | New `storage/` module analogous to `ai/` |
| Privacy-first admin model | SaaS legal/trust requirement — admins must not be able to access user data | Admin queries exclude document content; cloud creds encrypted with user-scoped key |
| Admin controls AI config, not users | Prevents cost overruns and model misuse; future group-admin delegation designed in | AI provider assignment stored per-user in DB, configurable by admin |
| 100 MB free tier | Baseline for subscription model; quota table has a `limit_bytes` column admin can override | Quota enforced at upload time |
| TOTP 2FA before SSO | State-of-the-art security without third-party dependency; SSO added when subscription model lands | TOTP via authenticator app (RFC 6238) |
| PostgreSQL + MinIO over flat files | Multi-user quotas + horizontal scaling require shared, consistent state | Shipped in v0.1 Phase 1 |
| Cloud storage adapter pattern | Mirrors AI provider pattern — consistent, extensible | Shipped in v0.1 Phase 5 |
| Privacy-first admin model | SaaS legal/trust requirement | Admin queries exclude document content; cloud creds encrypted with user-scoped key |
| Admin controls AI config, not users | Prevents cost overruns and model misuse | AI provider assignment stored per-user in DB, configurable by admin only |
| 100 MB free tier | Baseline for future subscription tiers | Quota table has `limit_bytes` column admin can override |
| TOTP 2FA before SSO | State-of-the-art security without third-party dependency | Via pyotp (RFC 6238), shipped v0.1 Phase 2 |
| ES256 over HS256 for JWT | Leaked public key cannot forge tokens; asymmetric signing | Shipped v0.1 Phase 7.3 |
| Token fingerprinting (fgp claim) | Limits stolen access token replay to original device context | HMAC of User-Agent + Accept-Language, shipped v0.1 Phase 7.4 |
| JTI claim + Redis revocation | Closes 15-min window where revoked session's access token stays valid | Shipped v0.1 Phase 7.2 |
| Options API preserved in v0.2 refactor | Composition API migration is scope-creep for a UX milestone; refactor within Options API | Decision logged to prevent scope drift |
| Admin panel as standalone route subtree | AdminView.vue as tabs-on-user-layout is an architectural mistake | v0.2 introduces /admin/* routes with own layout |
## Evolution
@@ -124,4 +114,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state
---
*Last updated: 2026-05-21 after initialization*
*Last updated: 2026-06-07 — milestone v0.2 started*
+135 -158
View File
@@ -1,173 +1,150 @@
# DocuVault — v1 Requirements
# DocuVault v0.2 — Requirements
_Last updated: 2026-05-21_
## v1 Requirements
### Authentication (AUTH)
- [x] **AUTH-01**: User can register with email and password (Argon2 hashing; strength enforced: ≥12 chars, uppercase, lowercase, number, special char; HaveIBeenPwned breach check)
- [x] **AUTH-02**: User can log in and maintain a session (JWT access token in Pinia memory only — never localStorage; refresh token in `httpOnly; Secure; SameSite=Strict` cookie; 15-min access / 30-day refresh)
- [ ] **AUTH-03**: User can enroll a TOTP authenticator app (RFC 6238; 810 single-use backup codes issued and explicitly acknowledged before TOTP is marked active)
- [x] **AUTH-04**: User can complete login using TOTP code or a one-time backup code (backup code invalidated on use)
- [ ] **AUTH-05**: User can reset password via email (signed token, 1-hour expiry; reset does not auto-login — user must pass TOTP gate on next login)
- [ ] **AUTH-06**: User can sign out all active sessions (revokes all refresh tokens in DB; "sign out all devices" control in account settings)
- [ ] **AUTH-07**: Refresh token rotation with family revocation — reuse of a rotated token revokes the entire family and emits a security alert to the user
- [ ] **AUTH-08**: TOTP codes are single-use (mark used in DB within the validity window; prevent replay attacks)
### Security (SEC) — Cross-Cutting
- [x] **SEC-01**: All state-changing endpoints are protected against CSRF (SameSite=Strict cookie + origin validation)
- [x] **SEC-02**: Auth endpoints (login, register, password reset, TOTP verify) are rate-limited (per-IP and per-account)
- [x] **SEC-03**: All DB queries use parameterized statements / ORM (zero raw string interpolation into queries)
- [x] **SEC-04**: All file/document access resolved through DB lookup — object keys are never reconstructed from request parameters (prevents path traversal and cross-user access)
- [x] **SEC-05**: Content-Security-Policy, X-Frame-Options, and X-Content-Type-Options headers set on all responses
- [ ] **SEC-06**: Constant-time comparison used for all token and code verification (prevents timing attacks)
- [ ] **SEC-07**: Admin role verified on every admin endpoint request; admin cannot access document content, extracted text, or cloud credentials in any response
- [ ] **SEC-08**: Cloud credential ciphertext (`credentials_enc`) excluded from all API serializers by default — admin and user responses return only `provider, display_name, connected_at, status`
- [x] **SEC-09**: Account deletion triggers `delete_user_files()` on every active cloud connection before removing DB records (prevents orphaned cloud data and satisfies GDPR Article 17)
### Users & Admin (ADMIN)
- [x] **ADMIN-01**: Admin can create user accounts (email, temporary password that must be changed on first login)
- [x] **ADMIN-02**: Admin can deactivate a user account (blocks all logins and API access; data preserved)
- [x] **ADMIN-03**: Admin can initiate password reset for a user (sends reset email; does not grant admin access to the account)
- [x] **ADMIN-04**: Admin can view and adjust individual user storage quotas (warns if new limit is below current usage)
- [x] **ADMIN-05**: Admin can assign AI provider and model per user (users cannot modify their own AI configuration)
- [ ] **ADMIN-06**: Admin can view audit log filtered by date range, user, and action type (metadata only — no document content, filenames, or extracted text)
- [x] **ADMIN-07**: Admin impersonation ("log in as user") is explicitly excluded by architecture — no endpoint or UI pathway exists
### Storage & Infrastructure (STORE)
- [ ] **STORE-01**: Platform storage layer migrated from flat-file JSON + local filesystem to PostgreSQL (metadata) + MinIO (objects); existing documents preserved via dual-write migration script
- [ ] **STORE-02**: Each user's MinIO objects use `{user_id}/{document_id}/{uuid4()}{ext}` keys — human-readable filenames stored in DB only
- [ ] **STORE-03**: Each user has a 100 MB storage quota enforced atomically at upload using `UPDATE quotas SET used_bytes = used_bytes + $delta WHERE (used_bytes + $delta) <= limit_bytes RETURNING used_bytes`
- [ ] **STORE-04**: User sees quota usage bar in sidebar (X MB of Y MB) with amber warning at 80% and red warning at 95%
- [ ] **STORE-05**: Upload rejected at quota limit with a specific error showing current usage, rejected file size, and a link to storage settings
- [ ] **STORE-06**: Document delete atomically decrements quota usage
- [ ] **STORE-07**: Backend is stateless — no per-instance file locks; multiple instances can run behind a load balancer
- [ ] **STORE-08**: FastAPI `BackgroundTasks` replaced with Celery + Redis or pgqueuer before horizontal scaling is enabled
### Folders & Organization (FOLD)
- [x] **FOLD-01**: User can create, rename, and delete folders (delete confirms content count before proceeding)
- [x] **FOLD-02**: User can move documents between folders
- [x] **FOLD-03**: Breadcrumb navigation renders current folder path; each segment is clickable to navigate up
- [x] **FOLD-04**: Document list supports sort by name, date uploaded, and file size
- [x] **FOLD-05**: Full-text search across user's documents (PostgreSQL `tsvector` index on extracted text)
### Document Sharing (SHARE)
- [ ] **SHARE-01**: User can share a document with another user by their unique handle (at-handle or user ID)
- [ ] **SHARE-02**: Shared documents appear in a "Shared with me" virtual folder for the recipient (no storage quota counted against recipient)
- [ ] **SHARE-03**: Shared access is view-only by default; owner controls permission level
- [ ] **SHARE-04**: Owner can revoke share access; revocation is immediate
- [ ] **SHARE-05**: Documents shared with others display a "shared" indicator in the owner's list view
### Cloud Storage (CLOUD)
- [x] **CLOUD-01**: User can connect OneDrive (Microsoft Graph), Google Drive (v3 API), Nextcloud, or generic WebDAV as a personal storage backend
- [x] **CLOUD-02**: Cloud OAuth credentials encrypted using HKDF per-user key derivation (`HKDF(master_key, salt=user_id_bytes, info=b"cloud-credentials")`); master key in `CLOUD_CREDS_KEY` env var; never stored in DB
- [x] **CLOUD-03**: Local MinIO storage and connected cloud backends coexist; user can select their default storage destination
- [x] **CLOUD-04**: Each cloud connection displays status: `ACTIVE | REQUIRES_REAUTH | ERROR`
- [x] **CLOUD-05**: On OAuth revocation (`invalid_grant`), connection status transitions to `REQUIRES_REAUTH` — the error is surfaced to the user, not retried silently
- [x] **CLOUD-06**: User can disconnect a cloud backend; credentials are permanently deleted from the DB
- [x] **CLOUD-07**: Storage backend abstracted via `StorageBackend` ABC + factory in `storage/` module (mirrors existing `ai/` provider pattern)
### Documents & AI (DOC)
- [ ] **DOC-01**: User can view document metadata and extracted text for any document in their library
- [ ] **DOC-02**: In-browser PDF preview (PDF.js); document bytes proxied through the app — no presigned URLs exposed to the browser (privacy model)
- [x] **DOC-03**: AI provider and model assigned by admin per user; user cannot change AI configuration
- [x] **DOC-04**: System default topics + per-user topic overrides preserved from existing implementation
- [x] **DOC-05**: AI classification uses the user's assigned provider and model (from DB, not from user-supplied settings)
**Milestone:** v0.2 — UI Overhaul and Optimization
**Created:** 2026-06-07
**Total requirements:** 40
---
## v2 Requirements (Deferred)
## Quality Mandate (applies to all phases)
- Subscription billing and payment processing (quota model designed to plug in)
- SSO: Microsoft, Google, Apple (auth layer designed for extension)
- Keycloak / SAML / OAuth2 enterprise federation
- Group admin roles (groups table seeded in schema, unpopulated)
- Share permission levels beyond view-only (edit, comment)
- Document version history
- Share expiry dates
- Real-time collaboration or comments
- Mobile app
- GDPR data export (Article 20) — async background job, deferred to v2
- Email notifications for sharing events
- Public link sharing (unauthenticated)
Every line of code written or modified in v0.2 must be:
- **Minimal:** smallest amount of code that correctly solves the problem. No speculative abstractions.
- **DRY:** if the same logic exists twice, extract it. Duplication is a defect.
- **Readable:** names things accurately. A reader should understand intent without comments.
- **No redundant comments:** comments exist only where the *why* is non-obvious. Never explain what the code does.
- **No dead code:** unreferenced files, components, or functions are deleted — not commented out.
---
## CODE — Codebase Quality
- [ ] **CODE-01**: Backend `api/admin.py` (934L) decomposed into `api/admin/` package with `users.py`, `quotas.py`, `ai.py`; `__init__.py` aggregates sub-routers under the existing `/api/admin` prefix. No URL changes, no behavior changes, all tests stay green.
- [ ] **CODE-02**: `api/documents.py` (852L) decomposed into focused sub-modules (upload flow, content proxy, document CRUD, search/listing) within `api/documents/` package. Prefix and behavior unchanged.
- [ ] **CODE-03**: `api/auth.py` (825L) decomposed into focused sub-modules (login/tokens, TOTP, password management, session management) within `api/auth/` package. Prefix and behavior unchanged.
- [ ] **CODE-04**: Frontend `api/client.js` (635L) decomposed into domain modules (`documents.js`, `auth.js`, `admin.js`, `folders.js`, `shares.js`, `cloud.js`, `topics.js`); `client.js` becomes the HTTP transport layer and re-export barrel. Zero changes to any of the 35+ consumer files.
- [ ] **CODE-05**: All inline SVG blocks (~66 instances) replaced with `<AppIcon name="..." class="..." />`; all icon path data centralized in `components/ui/AppIcon.vue`. No duplicated path strings.
- [ ] **CODE-06**: Tailwind `safelist` configured for all dynamic class name patterns in `formatters.js` (provider colors, backgrounds, badge text). Production builds render topic and provider colors correctly.
- [ ] **CODE-07**: All unreferenced files, components, stores, and unused imports deleted. No dead code retained.
- [ ] **CODE-08**: No duplicated Pydantic model definitions or shared validators across router files. Shared schemas extracted to dedicated modules.
- [ ] **CODE-09**: No comment in any file describes what the code does. Comments exist only where intent or constraint would not be obvious to a competent reader.
---
## ADMIN — Admin Panel
- [ ] **ADMIN-08**: Admin panel moved to `/admin/*` route subtree with `AdminLayout.vue` as the route component. `AdminLayout` has its own sidebar with admin-specific nav only — no user quota bar, no folder tree, no topic list. `AdminView.vue` is deleted.
- [ ] **ADMIN-09**: Admin sidebar nav links (in order): Overview, Users, Quotas, AI Config, Audit Log. "Back to app" link at the bottom returns to `/`.
- [ ] **ADMIN-10**: Each admin section is its own deep-linkable URL (`/admin/users`, `/admin/quotas`, `/admin/ai`, `/admin/audit`). Browser back button works within the admin section.
- [ ] **ADMIN-11**: Admin overview page (`/admin`) shows: total registered user count, total platform storage in use, document status breakdown (processing/ready/failed), last 10 audit log entries. Requires new backend aggregate query endpoints.
- [ ] **ADMIN-12**: `requiresAdmin` guard enforced for all `/admin/*` child routes via `to.matched.some(r => r.meta.requiresAdmin)`. No admin child route is accessible to non-admin users.
---
## UX — UX and Interaction
- [ ] **UX-01**: Every zero-content context shows a contextually distinct empty state using a shared `EmptyState.vue` component (headline, subtext, optional CTA). Applied to: root file list, individual folder, search with no results, shared-with-me, topics list, audit log, cloud connections. No plain "No items" text remains.
- [ ] **UX-02**: Document list (`StorageBrowser`) displays 5-col skeleton grid rows during loading (matching actual grid layout, `animate-pulse`). "Loading..." text removed.
- [ ] **UX-03**: Sidebar folder tree and topics list display skeleton placeholder items during loading. "Loading..." text removed.
- [ ] **UX-04**: Admin user table and audit log table display skeleton table rows during loading.
- [ ] **UX-05**: Pressing `/` when no input is focused moves keyboard focus to the search bar.
- [ ] **UX-06**: Pressing `Escape` closes any open modal (`ShareModal`, `FolderDeleteModal`, `DocumentPreviewModal`) and clears active search. Handled at the overlay level, not only inside inputs.
- [ ] **UX-07**: Pressing `U` when no input is focused triggers the file upload picker.
- [ ] **UX-08**: Pressing `N` when no input is focused starts the new folder inline input in the file manager.
- [ ] **UX-09**: Dragging files from the OS anywhere onto the browser window shows a full-screen drop overlay. Releasing uploads the files via the existing upload flow. Only activates when `dataTransfer.types` includes `'Files'`.
- [ ] **UX-10**: Toast notification system shows transient messages (auto-dismiss 4 seconds, manual dismiss on click) for: upload complete, upload error, document deleted, share revoked, rename successful. Toasts stack vertically and do not block interaction.
- [ ] **UX-11**: Drag-to-move a document onto a folder row in `StorageBrowser` works correctly end-to-end. Drop highlight (`ring-2 ring-inset ring-amber-300`) renders on valid folder targets. "Move to folder" button remains as non-drag alternative.
- [ ] **UX-12**: All views display a breadcrumb reflecting the full current navigation path. Examples: `Folders Reports Q1`, `Cloud Google Drive Projects`, `Admin Users`, `Settings Account`, `Folders Reports document.pdf`. Breadcrumb updates on navigation. A single shared breadcrumb component is used across all view types — no per-view implementations.
- [ ] **UX-13**: All dropdown menus and modal dialogs render their full content within the viewport. No text or interactive element is clipped by a viewport edge or sibling element. Dropdowns that risk edge-clipping use fixed positioning computed from `getBoundingClientRect()` or `<Teleport to="body">`.
- [ ] **UX-14**: The inline "New" button next to the Folders section header in `AppSidebar.vue` is removed. Folder creation accessible only from within the file manager view.
---
## VISUAL — Visual Design
- [ ] **VISUAL-01**: Consistent spacing scale across all components using Tailwind utilities only. No arbitrary `px-[13px]` values or inline `style` margins.
- [ ] **VISUAL-02**: All form elements (inputs, selects, textareas, checkboxes, radio buttons) have consistent cross-browser baseline styling via `@tailwindcss/forms` plugin.
- [ ] **VISUAL-03**: All interactive elements (buttons, links, card rows) have consistent hover states, `focus-visible:` rings, and active states.
- [ ] **VISUAL-04**: Typography scale is consistent: one heading size per level, one body size, one label/caption size. No per-component font-size overrides.
---
## RESP — Responsive Layout
- [ ] **RESP-01**: Sidebar hidden below `lg` (1024px). Hamburger button in mobile-only header toggles a slide-in overlay drawer. Overlay closes on nav tap or backdrop tap.
- [ ] **RESP-02**: Document list hides Size column below `md` (768px) and Modified column below `sm` (640px). Icon, name, and actions always visible.
- [ ] **RESP-03**: Inline icon action buttons have minimum 36×36px touch target on viewports below `md`.
- [ ] **RESP-04**: All modal dialogs are scrollable on viewports below 640px. No modal content overflows the screen.
- [ ] **RESP-05**: Admin layout has the same responsive behavior as the user layout: admin sidebar hidden on mobile, hamburger shows admin nav drawer.
---
## PERF — Performance and Stack
- [ ] **PERF-01**: Frontend dependencies bumped/added: `vue@^3.5.0`, `vite@^6.4.3`, `@vueuse/core@^14.3.0`, `@vueuse/integrations@^14.3.0`, `sortablejs@^1.15.7`, `@tailwindcss/forms@^0.5.11`. Dev: `rollup-plugin-visualizer@^7.0.1`, `@types/sortablejs`.
- [ ] **PERF-02**: Bundle baseline measured with `rollup-plugin-visualizer` before optimizations begin. Second measurement taken after all refactoring is complete. Both reports committed to `.planning/`.
- [ ] **PERF-03**: All non-initial-render route components are lazy-loaded. Admin views explicitly lazy-loaded. No synchronous import for routes not on the critical render path.
---
## Future (deferred)
- Virtual scrolling — quota cap (100 MB/user) limits lists to hundreds of items; `v-for` is sufficient. Revisit with server-side pagination.
- Dark mode — coherent color token system must exist first.
- Folder reordering by drag — requires persistent `position` column in DB.
- Multi-select with batch operations — low frequency in a personal vault.
## Out of Scope
- Admin impersonation / "log in as user" — violates privacy-first core value; explicit architectural exclusion
- Document editing or annotation — not planned
- Document viewer for non-PDF types beyond metadata (DOCX, image renders) — v2
- AI-generated document summaries beyond topic classification — v2
- Webhooks or API access for third parties — not planned for v1
- Composition API migration — refactor within Options API; Composition API is a separate milestone.
- Component library switch (shadcn-vue, PrimeVue) — stay with Tailwind.
- SSO, billing, group roles — v0.x exclusions unchanged from v0.1.
- Document annotation or in-app editing — not planned.
---
## Traceability
_Filled by roadmapper — 2026-05-21._
| REQ-ID | Phase | Notes |
|---|---|---|
| STORE-01 | 1 | Dual-write migration script; schema and Alembic wiring |
| STORE-02 | 1 | Object key schema enforced in model layer |
| STORE-07 | 1 | Stateless backend; no per-instance file locks |
| AUTH-01 | 2 | Registration with Argon2 + HaveIBeenPwned check |
| AUTH-02 | 2 | JWT session; httpOnly refresh cookie; Pinia memory access token |
| AUTH-03 | 2 | TOTP enrollment with backup code acknowledgement flow |
| AUTH-04 | 2 | Login via TOTP code or single-use backup code |
| AUTH-05 | 2 | Password reset email; routes back to TOTP gate |
| AUTH-06 | 2 | Sign out all devices; revokes all refresh tokens |
| AUTH-07 | 2 | Refresh token family revocation on reuse; security alert |
| AUTH-08 | 2 | TOTP single-use enforcement within validity window |
| SEC-01 | 2 | CSRF protection on all state-changing endpoints |
| SEC-02 | 2 | Rate limiting on auth endpoints (per-IP and per-account) |
| SEC-03 | 2 | Parameterized queries / ORM enforced from first migration |
| SEC-05 | 2 | Security response headers on all responses |
| SEC-06 | 2 | Constant-time comparison for token/code verification |
| SEC-07 | 2 | Admin role dependency; admin blocked from document content |
| ADMIN-01 | 2 | Admin creates user with temporary password |
| ADMIN-02 | 2 | Admin deactivates user account |
| ADMIN-03 | 2 | Admin initiates password reset for user |
| ADMIN-04 | 2 | Admin views and adjusts user storage quotas |
| ADMIN-05 | 2 | Admin assigns AI provider and model per user |
| ADMIN-07 | 2 | Explicit architectural exclusion of admin impersonation |
| STORE-03 | 3 | Atomic quota enforcement at upload |
| STORE-04 | 3 | Quota usage bar with 80%/95% warnings |
| STORE-05 | 3 | Upload rejection at quota limit with detailed error |
| STORE-06 | 3 | Atomic quota decrement on document delete |
| STORE-08 | 3 | BackgroundTasks replaced with Celery+Redis or pgqueuer |
| SEC-04 | 3 | DB-lookup-only file access; no key reconstruction from params |
| DOC-03 | 3 | AI provider/model from DB per user; not user-supplied |
| DOC-04 | 3 | System default topics + per-user topic overrides preserved |
| DOC-05 | 3 | Classification uses user's assigned provider and model |
| FOLD-01 | 4 | Folder CRUD with content-count confirmation on delete |
| FOLD-02 | 4 | Document move between folders |
| FOLD-03 | 4 | Breadcrumb navigation with clickable path segments |
| FOLD-04 | 4 | Document list sort by name, date, and file size |
| FOLD-05 | 4 | Full-text search via PostgreSQL tsvector index |
| SHARE-01 | 4 | Share document by user handle |
| SHARE-02 | 4 | "Shared with me" virtual folder; no quota charged to recipient |
| SHARE-03 | 4 | View-only default sharing; owner controls permission level |
| SHARE-04 | 4 | Immediate share revocation |
| SHARE-05 | 4 | Shared indicator on documents in owner's list view |
| SEC-08 | 4 | credentials_enc excluded from all serializers |
| SEC-09 | 4 | Account deletion triggers delete_user_files() per cloud connection |
| ADMIN-06 | 4 | Admin audit log viewer filtered by date, user, action |
| DOC-01 | 4 | View document metadata and extracted text |
| DOC-02 | 4 | In-browser PDF preview via PDF.js; bytes proxied through app |
| CLOUD-01 | 5 | Connect OneDrive, Google Drive, Nextcloud, WebDAV |
| CLOUD-02 | 5 | HKDF per-user key derivation for credential encryption |
| CLOUD-03 | 5 | Local and cloud storage coexist; user selects default |
| CLOUD-04 | 5 | Connection status display: ACTIVE / REQUIRES_REAUTH / ERROR |
| CLOUD-05 | 5 | invalid_grant transitions to REQUIRES_REAUTH; surfaced to user |
| CLOUD-06 | 5 | Disconnect cloud backend; credentials permanently deleted |
| CLOUD-07 | 5 | StorageBackend ABC + factory in storage/ module |
| REQ-ID | Phase | Plan |
|--------|-------|------|
| PERF-01 | Phase 8 | Pending |
| CODE-01 | Phase 8 | Pending |
| CODE-02 | Phase 8 | Pending |
| CODE-03 | Phase 8 | Pending |
| CODE-04 | Phase 8 | Pending |
| CODE-08 | Phase 8 | Pending |
| ADMIN-08 | Phase 9 | Pending |
| ADMIN-09 | Phase 9 | Pending |
| ADMIN-10 | Phase 9 | Pending |
| ADMIN-11 | Phase 9 | Pending |
| ADMIN-12 | Phase 9 | Pending |
| CODE-06 | Phase 9 | Pending |
| CODE-09 | Phase 9 | Pending |
| UX-01 | Phase 10 | Pending |
| UX-02 | Phase 10 | Pending |
| UX-03 | Phase 10 | Pending |
| UX-04 | Phase 10 | Pending |
| UX-05 | Phase 10 | Pending |
| UX-06 | Phase 10 | Pending |
| UX-07 | Phase 10 | Pending |
| UX-08 | Phase 10 | Pending |
| UX-09 | Phase 10 | Pending |
| UX-10 | Phase 10 | Pending |
| UX-11 | Phase 10 | Pending |
| UX-12 | Phase 10 | Pending |
| UX-13 | Phase 10 | Pending |
| UX-14 | Phase 10 | Pending |
| CODE-05 | Phase 10 | Pending |
| VISUAL-01 | Phase 11 | Pending |
| VISUAL-02 | Phase 11 | Pending |
| VISUAL-03 | Phase 11 | Pending |
| VISUAL-04 | Phase 11 | Pending |
| RESP-01 | Phase 11 | Pending |
| RESP-02 | Phase 11 | Pending |
| RESP-03 | Phase 11 | Pending |
| RESP-04 | Phase 11 | Pending |
| RESP-05 | Phase 11 | Pending |
| CODE-07 | Phase 11 | Pending |
| PERF-02 | Phase 11 | Pending |
| PERF-03 | Phase 11 | Pending |
+152
View File
@@ -520,6 +520,158 @@ Before any phase is marked complete, all three gates must pass:
---
---
# DocuVault — v0.2 Roadmap
_Milestone: v0.2 — UI Overhaul and Optimization_
_Started: 2026-06-07_
## v0.2 Phases
- [ ] **Phase 8: Stack Upgrade & Backend Decomposition** — Dependency bumps land; all three backend router monoliths split into focused sub-packages; frontend API client decomposed into domain modules; shared Pydantic schemas extracted
- [ ] **Phase 9: Admin Panel Rearchitecture** — Admin panel moves to `/admin/*` route subtree with its own layout, sidebar, and deep-linkable child routes; Tailwind safelist configured; redundant comments purged
- [ ] **Phase 10: UX & Interaction** — Empty states, loading skeletons, keyboard shortcuts, drag-and-drop upload, toast notifications, breadcrumbs, and icon centralization land across the full UI
- [ ] **Phase 11: Visual Design, Responsive Layout & Cleanup** — Consistent spacing, form styling, hover/focus states, and typography applied; mobile-responsive sidebar and layouts ship; dead code deleted; bundle measured
## Phase Details
### Phase 8: Stack Upgrade & Backend Decomposition
**Goal**: The dependency stack is current, all three backend router monoliths are split into focused sub-packages with zero URL or behavior changes, and the frontend API client is decomposed into domain modules behind a re-export barrel — the entire change is invisible to consumers and tests.
**Depends on**: Phase 7.4 (last v0.1 phase)
**Requirements**: PERF-01, CODE-01, CODE-02, CODE-03, CODE-04, CODE-08
**Implementation notes:**
- Sub-routers in `api/admin/`, `api/documents/`, `api/auth/` must have NO prefix on their `APIRouter()` — the parent prefix propagates. Adding a prefix doubles the URL path. (PITFALLS.md §Pitfall 1)
- Shared Pydantic models, constants, and helpers that span sub-modules go in a `shared.py` within each package — never duplicated across sub-routers. (PITFALLS.md §Pitfall 2 — prevents circular imports)
- `client.js` decomposition uses the re-export barrel pattern: `client.js` re-exports everything from domain sub-files; zero changes to the 35+ consumer files. `request()` and `noRefreshPaths` stay in `client.js`; the three blob-download 401-retry duplicates are consolidated into a single `fetchWithRetry()` helper. (PITFALLS.md §Pitfall 5)
- CODE-01, CODE-02, CODE-03 are independent (touch different files) and can be executed in parallel. CODE-04 is also independent of the backend work.
**Success Criteria** (what must be TRUE):
1. `pytest -v` passes with zero failures immediately after the backend split — every existing API endpoint continues to respond on the same URL path
2. `import * as api from '../api/client.js'` in any existing consumer file resolves all previously-exported names without modification to that consumer file
3. No Pydantic model definition appears more than once across `api/admin/`, `api/documents/`, and `api/auth/` sub-packages — shared types live in a dedicated module
4. `PERF-01` packages are installed and `npm list` confirms `vue@^3.5.x`, `vite@^6.x`, `@vueuse/core@^14.x`, `sortablejs`, `@tailwindcss/forms`, `rollup-plugin-visualizer` are present
**Plans**: 8 plans (3 waves)
**Wave 0** — Foundation (parallel)
- [ ] 08-01-PLAN.md — CR-01/02/03 test stubs (3 xfail stubs in test_auth.py) + Wave 0 scaffolds for regression detection
- [ ] 08-02-PLAN.md — `api/schemas.py` creation + `CloudConnectionOut` migration from admin.py (MUST precede admin split)
**Wave 1** *(blocked on Wave 0)* — Phase 7.1 completion (frontend only — backend already implemented)
- [ ] 08-03-PLAN.md — `useToastStore` stub (`frontend/src/stores/toast.js`) + CR test promotion + SettingsAccountTab.vue + TotpEnrollment.vue inline toast replacement
**Wave 2** *(blocked on Wave 1)* — Backend decomposition + frontend (parallel)
- [ ] 08-04-PLAN.md — Split `api/admin.py``api/admin/` package: users.py, quotas.py, ai.py, shared.py, __init__.py (CODE-01)
- [ ] 08-05-PLAN.md — Split `api/documents.py``api/documents/` package: upload.py, crud.py, content.py, shared.py, __init__.py (CODE-02)
- [ ] 08-06-PLAN.md — Split `api/auth.py``api/auth/` package: tokens.py, totp.py, password.py, sessions.py, shared.py, __init__.py (CODE-03)
- [ ] 08-07-PLAN.md — Frontend `client.js` decomposition: utils.js + 7 domain modules + barrel rewrite (CODE-04)
- [ ] 08-08-PLAN.md — PERF-01 dependency bump + tailwind/vite config wiring + requirements.txt exact pinning (PERF-01, D-17)
**Cross-cutting constraints:**
- Sub-routers MUST have NO prefix on `APIRouter()` — only package `__init__.py` carries the prefix (Plans 04, 05, 06)
- `api/schemas.py` must exist before admin split — `api/cloud.py` imports `CloudConnectionOut` from admin (Plan 02 before Plan 04)
- `request()` moves to `utils.js` for circular-import avoidance; `client.js` re-exports it — zero consumer changes (Plan 07)
- Every admin sub-module handler retains `Depends(get_current_admin)` explicitly — never omit (Plans 04)
---
### Phase 9: Admin Panel Rearchitecture
**Goal**: The admin interface is a standalone route subtree (`/admin/*`) with its own layout component and sidebar; each admin section is deep-linkable and browser-back-button works; the `requiresAdmin` navigation guard correctly protects all child routes; and the Tailwind safelist is configured so dynamic color classes render correctly in production builds.
**Depends on**: Phase 8
**Requirements**: ADMIN-08, ADMIN-09, ADMIN-10, ADMIN-11, ADMIN-12, CODE-06, CODE-09
**Implementation notes:**
- `AdminLayout.vue` is registered as the `/admin` route's component; its `<router-view />` renders child views. `App.vue` does NOT need a third `v-else-if` branch — it renders whatever component the router resolves to. (PITFALLS.md §Pitfall 4)
- The `beforeEach` guard must change from `to.meta.requiresAdmin` to `to.matched.some(r => r.meta.requiresAdmin)`. Vue Router 4 does not inherit `meta` from parent to children automatically. (PITFALLS.md §Pitfall 3)
- `App.vue` layout resolution reads `route.matched.find(r => r.meta.layout)?.meta.layout` — same `matched` walk as the guard. (ARCHITECTURE.md §Integration Point 2)
- `AdminLayout.vue` owns the `p-8 max-w-5xl mx-auto` content wrapper. Extracted tab-component-turned-views (`AdminUsersView.vue` etc.) must strip any top-level padding they inherited from `AdminView.vue` — double padding is the risk. (PITFALLS.md §Pitfall 9)
- Tailwind safelist covers `providerColor`, `providerBg`, `providerLabel` dynamic pattern families from `formatters.js`. (PITFALLS.md §Pitfall 14)
- CODE-09 (comment purge) applies to every file touched in this phase and retroactively to the backend sub-packages created in Phase 8.
**Success Criteria** (what must be TRUE):
1. Navigating directly to `/admin/users`, `/admin/quotas`, `/admin/ai`, and `/admin/audit` in a fresh browser tab loads the correct admin view with the admin sidebar — not the user app sidebar
2. A logged-in non-admin user who navigates to `/admin/users` is redirected to `/` by the `beforeEach` guard; the redirect happens even when the URL is entered directly
3. The browser back button moves from `/admin/audit` to `/admin/users` (or whichever path was previous) without a full page reload
4. `AdminView.vue` is deleted from the repository; no file imports it
5. A production build (`npm run build`) renders topic badge and provider chip colors correctly — no gray/invisible badges caused by purged dynamic Tailwind classes
**Plans**: TBD
**UI hint**: yes
---
### Phase 10: UX & Interaction
**Goal**: The application communicates state clearly at every moment — empty contexts have purposeful empty states, loading transitions show structured skeletons, power users can operate keyboard-first, files can be dragged from the OS directly onto the browser, and every action produces an immediate toast confirmation.
**Depends on**: Phase 9
**Requirements**: UX-01, UX-02, UX-03, UX-04, UX-05, UX-06, UX-07, UX-08, UX-09, UX-10, UX-11, UX-12, UX-13, UX-14, CODE-05
**Implementation notes:**
- Global `keydown` listener must guard against active input elements before firing any shortcut. The check pattern is: `['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable` → early return. Register in `mounted()`, remove in `beforeUnmount()`. (PITFALLS.md §Pitfall 12)
- Drag-and-drop upload (`UX-09`) detects `dataTransfer.types.includes('Files')` to distinguish file drags from element drags. The full-screen overlay must be attached at the `window`/`document` level, not inside `StorageBrowser`. (UX-09 requirement)
- Drag-to-move (`UX-11`) is already partially implemented in `StorageBrowser.vue`. This phase wires it end-to-end and adds the `ring-2 ring-inset ring-amber-300` drop highlight on valid folder targets.
- `DocumentCard.vue` drag must track `dragging` state to prevent the browser's `dragend`-then-`click` fire from navigating after a drag. Use a dedicated drag handle element where possible. (PITFALLS.md §Pitfall 6)
- All dropdown menus that risk viewport-edge clipping must use fixed positioning from `getBoundingClientRect()` or `<Teleport to="body">`. This is a prerequisite for virtual scrolling adoption if pursued later. (UX-13, PITFALLS.md §Pitfall 7)
- `AppIcon.vue` centralizes all SVG path data. Inline `<svg>` blocks are replaced with `<AppIcon name="..." class="..." />`. No duplicated path strings remain. (CODE-05)
- `UX-14`: the inline "New" folder button in `AppSidebar.vue` is removed; folder creation is accessible only from the file manager.
- A single shared `BreadcrumbBar.vue` component serves all view types — no per-view breadcrumb implementations. (UX-12)
**Success Criteria** (what must be TRUE):
1. Every zero-content context (root file list, folder, search with no results, shared-with-me, topics, audit log, cloud connections) shows a distinct `EmptyState.vue` — no plain "No items" text remains anywhere in the app
2. Pressing `/` focuses the search bar, `Escape` closes any open modal and clears active search, `U` triggers the file upload picker, and `N` starts the new-folder input — none of these fire when a text input has focus
3. Dragging files from the OS onto any part of the browser window (not just a drop zone) shows a full-screen overlay and uploads them on drop
4. Every upload, delete, share, revoke, and rename action produces a toast notification that auto-dismisses after 4 seconds and does not block interaction with the page
5. All views display a breadcrumb rendered by a single shared component; the breadcrumb reflects the full navigation path and updates on every route change
**Plans**: TBD
**UI hint**: yes
---
### Phase 11: Visual Design, Responsive Layout & Cleanup
**Goal**: Every component uses the Tailwind spacing scale and typography system consistently, form elements have cross-browser baseline styling, interactive elements have consistent hover/focus states, the layout adapts cleanly to mobile viewports with a hamburger-toggled sidebar drawer, dead code is deleted, and bundle size is measured before and after.
**Depends on**: Phase 10
**Requirements**: VISUAL-01, VISUAL-02, VISUAL-03, VISUAL-04, RESP-01, RESP-02, RESP-03, RESP-04, RESP-05, CODE-07, PERF-02, PERF-03
**Implementation notes:**
- Sidebar responsive behavior: below `lg` (1024px) the sidebar is hidden; a hamburger button in a mobile-only header toggles a slide-in overlay drawer with `translate-x-0 / -translate-x-full` transition. Sidebar open/closed state lives in a Pinia store or `App.vue` ref — not in `AppSidebar`'s own `data()`. (PITFALLS.md §Pitfall 8)
- The admin layout needs the same responsive treatment as the user layout — admin sidebar hidden on mobile, hamburger shows the admin nav drawer. (RESP-05)
- All route components not on the critical render path must be lazy-loaded with `() => import(...)`. Admin views are explicitly lazy-loaded. (PERF-03)
- `PERF-02` requires two Vite bundle reports committed to `.planning/`: one measured before optimizations (taken at start of this phase) and one after all work is complete (taken at end).
- `CODE-07` dead-code deletion sweeps all unreferenced files, components, stores, and unused imports. Deletion happens after all other changes in this phase are complete — delete only what is confirmed unreferenced after all additions.
- `VISUAL-02` requires the `@tailwindcss/forms` plugin (already listed in PERF-01) to be configured in `tailwind.config.js`.
- `RESP-02`: `StorageBrowser`'s grid hides the Size column below `md` and Modified below `sm`. Icon, name, and actions are always visible.
- `RESP-03`: inline icon action buttons have minimum 36×36px touch target below `md`.
- `RESP-04`: all modal dialogs are scrollable on viewports below 640px.
**Success Criteria** (what must be TRUE):
1. At 375px viewport width, the app is fully usable: the sidebar is hidden, the hamburger opens an overlay drawer, the document list shows only icon/name/actions columns, and all touch targets are at least 36×36px
2. All form inputs, selects, textareas, checkboxes, and radio buttons have consistent cross-browser styling with no browser-default rendering differences visible between Chrome, Firefox, and Safari
3. Every button, card row, and interactive link has a visible `focus-visible:` ring and a distinct hover state; no interactive element is style-identical in focused and unfocused states
4. No arbitrary pixel value (`px-[13px]`, `style="margin: 5px"`, etc.) appears in any template; all spacing is from the Tailwind scale
5. Two bundle analysis reports are committed to `.planning/` — one baseline and one post-optimization; all non-initial-render routes are lazy-loaded and appear as separate chunks in the report
**Plans**: TBD
**UI hint**: yes
---
## v0.2 Progress Table
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 8. Stack Upgrade & Backend Decomposition | 0/TBD | Not started | — |
| 9. Admin Panel Rearchitecture | 0/TBD | Not started | — |
| 10. UX & Interaction | 0/TBD | Not started | — |
| 11. Visual Design, Responsive Layout & Cleanup | 0/TBD | Not started | — |
---
## Progress Table
| Phase | Plans Complete | Status | Completed |
+47 -174
View File
@@ -1,176 +1,76 @@
---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: "audit gaps: SHARE-02/STORE-06/ADMIN-06"
current_phase: 07.4
status: completed
last_updated: "2026-06-06T17:42:13.466Z"
milestone: v0.2
milestone_name: Phases
current_phase: "Phase 8: Stack Upgrade & Backend Decomposition"
status: planned
last_updated: "2026-06-08T00:00:00.000Z"
last_activity: 2026-06-08 — Phase 8 planned (8 plans, 3 waves)
progress:
total_phases: 7
completed_phases: 6
total_plans: 20
completed_plans: 20
percent: 86
total_phases: 17
completed_phases: 13
total_plans: 73
completed_plans: 65
percent: 76
---
# Project State
**Project:** DocuVault
**Status:** Phase 07.3 CompleteReady for Phase 07.4
**Current Phase:** 07.4
**Last Updated:** 2026-06-06
## Phase Status
| Phase | Name | Status |
|---|---|---|
| 1 | Infrastructure Foundation | ✓ Complete |
| 2 | Users & Authentication | ✓ Complete (5/5 plans) |
| 3 | Document Migration & Multi-User Isolation | ✓ Complete (5/5 plans, UAT passed, security gate passed) |
| 4 | Folders, Sharing, Quotas & Document UX | ✓ Complete (9/9 plans, UAT 14/15 passed, 1 bug fixed) |
| 5 | Cloud Storage Backends | ✓ Complete (12/12 plans, UAT 5/6 passed, 3 gaps closed by 05-12) |
| 6 | Performance & Production Hardening | ✓ Complete (6/6 plans, UAT passed, CVE gate passed) |
| 6.1 | Close v1.0 audit gaps: SHARE-02/STORE-06/ADMIN-06 | ✓ Complete (2/2 plans) |
| 6.2 | Close v1 sharing + cloud-delete + CSV export gaps | ✓ Complete (5/5 plans, UAT passed, security gate passed) |
| 7 | Redo and Optimize LLM Integration | ✓ Complete (5/5 plans, UAT 11/11 passed, security gate passed) |
| 7.1 | Security: session revocation on privilege change (CR-01..03) | ✓ Complete (2/2 plans, 3 new tests, frontend toasts) |
| 7.2 | Security: JTI claim + Redis access-token revocation | ✓ Complete (3/3 plans, 9/9 verified, 84/84 tests) |
| 7.3 | Security: ES256 algorithm upgrade | ✓ Complete (3/3 plans, all 9 tests passing, remember_me shipped) |
| 7.4 | Security: Token fingerprinting / token binding | ✓ Complete (2/2 plans, 4 FGP tests passing, v0.1.3) |
**Status:** Phase 8 plannedready to execute
**Current Phase:** Phase 8: Stack Upgrade & Backend Decomposition
**Last Updated:** 2026-06-08
## Current Position
Phase: 07.3 (security-es256-algorithm-upgrade-inserted) — COMPLETE (3/3 plans, 9/9 tests, 2026-06-06)
Phase: 07.2 (security-jti-claim-redis-access-token-revocation-inserted) — COMPLETE (3/3 plans, 9/9 verified)
**Progress:** [██████████] 100% (v1.0 base complete; Phase 7.1 inserted as urgent follow-up)
Phase: Phase 8 (planned, 8 plans ready)
Plan: —
Status: Ready to execute — 8 plans across 3 waves
Last activity: 2026-06-08 — Phase 8 planned
## Phase Status
| Phase | Requirements | Status |
|-------|-------------|--------|
| 8. Stack Upgrade & Backend Decomposition | PERF-01, CODE-01, CODE-02, CODE-03, CODE-04, CODE-08 | Planned (8 plans) |
| 9. Admin Panel Rearchitecture | ADMIN-08..12, CODE-06, CODE-09 | Not started |
| 10. UX & Interaction | UX-01..14, CODE-05 | Not started |
| 11. Visual Design, Responsive Layout & Cleanup | VISUAL-01..04, RESP-01..05, CODE-07, PERF-02, PERF-03 | Not started |
## Performance Metrics
| Metric | Value |
|---|---|
| Phases complete | 7 / 7 |
| Requirements mapped | 54 / 54 |
| Plans written | 5 (Phase 7) |
| Plans complete | 5 (Phase 7, all phases done) |
| Phases complete | 0 / 4 |
| Requirements mapped | 40 / 40 |
| Plans written | 8 |
| Plans complete | 0 |
## Accumulated Context
### Key Decisions
*(Carries forward from v0.1 — see PROJECT.md for full decision log.)*
| Decision | Rationale |
|---|---|
| PostgreSQL + MinIO | Multi-user quotas and horizontal scaling require shared, consistent state |
| HKDF per-user key derivation | Single Fernet key would be catastrophic on leak — must be derived before first credential is stored |
| Presigned MinIO URL flow | FastAPI handles metadata only; bytes never pass through the API layer |
| Atomic PostgreSQL quota UPDATE | Never perform quota arithmetic in Python between two DB statements |
| JWT in httpOnly cookie | Refresh token in httpOnly cookie; access token in Pinia memory only — never localStorage |
| Refresh token family revocation | RFC 9700 — reuse of a rotated token revokes entire family and alerts user |
| BackgroundTasks replacement | FastAPI BackgroundTasks is per-instance; replace with Celery+Redis or pgqueuer before horizontal scale |
| AuditLog metadata_ ORM attribute | `metadata` is reserved on DeclarativeBase; ORM attribute is `metadata_` with `name="metadata"` kwarg to avoid silent collision |
| documents.user_id nullable Phase 1 | D-03 — no auth in Phase 1; Phase 2 migration adds NOT NULL after auth lands |
| groups stub table Phase 1 | D-02 — groups is a v2 feature; table created now for schema completeness, no rows until Phase 2+ |
| SEQUENCES grants in migration | GRANT USAGE/SELECT on sequences required for audit_log.id autoincrement nextval() by docuvault_app |
| Admin impersonation excluded | Explicit architectural exclusion — no endpoint or UI pathway; violates privacy-first core value |
| user_id as refresh token family proxy | No separate family_id column; user_id serves as family per RFC 9700 — simpler schema |
| pwdlib over passlib | pwdlib actively maintained with clean Argon2Hasher API; passlib unmaintained |
| TOTP replay TTL=90s | valid_window=1 covers ±30s (90s total) — TTL matches window |
| HIBP fail-open | Network errors return False + log warning; auth never blocked by external service |
| Two-DSN PostgreSQL strategy | DATABASE_URL (docuvault_app, DML only) + DATABASE_MIGRATE_URL (docuvault_migrate, DDL only); celery-worker gets only DATABASE_URL |
| MinIO healthcheck via mc ready local | curl removed from MinIO Docker image since Oct 2023; mc is the correct in-container healthcheck tool |
| pydantic-settings v2 SettingsConfigDict | SettingsConfigDict API used (not deprecated class Config form) for env var config |
| async_client fixture name | Distinct from legacy sync `client` fixture to avoid collision; both coexist until Plan 05 |
| xfail(strict=False) for Wave 0 | All pre-implementation scaffolds use strict=False so unexpected passes don't break CI |
| StorageBackend ABC + factory mirrors ai/ pattern | 5 abstract methods; get_storage_backend() factory; MinIOBackend wraps all sync Minio SDK calls in asyncio.to_thread() |
| Explicit localhost string block in validate_cloud_url | hostname == "localhost" blocked before DNS resolution — OS-agnostic (getaddrinfo("localhost") behaviour varies by OS) |
| Fresh HKDF instance per _derive_fernet_key call | cryptography library raises AlreadyFinalized on 2nd .derive() call; always create new HKDF(...) instance — never cache |
| Lazy import of cloud backends in get_storage_backend_for_document | Avoids circular imports at module load time; backends imported inside function body with type: ignore[import] until Plans 05-03..05-05 create them |
| Fetch-outside-lock async cache pattern | get_cloud_folders_cached acquires lock to check cache, releases lock, awaits fetch_fn, re-acquires lock to write — prevents event loop blocking on cache miss |
| STORE-02 key enforced in code | MinIOBackend.put_object constructs {user_id}/{document_id}/{uuid4()}{ext}; no filename parameter — only extension passes through |
| null-user D-03 sentinel | services/storage.save_upload uses user_id="null-user" in Phase 1 (no auth); Phase 2 replaces with str(current_user.id) |
| load_settings flat-file Phase 1 | users.ai_provider/ai_model columns cannot be populated until Phase 2; settings remain flat-file JSON for Phase 1 |
| Deferred Celery import in /password-reset | send_reset_email.delay called via from tasks.email_tasks import send_reset_email inside handler body — same circular-import fix as document_tasks |
| TOTP QR code as otpauth:// link | No QR library installed; plan permits manual secret display for MVP; functional flow complete without rendered QR image |
| ConfirmBlock no acknowledgment checkbox | ConfirmBlock handles message + button pair; BackupCodesDisplay owns its separate acknowledgment checkbox — no overlap |
| ADMIN-07 enforced by omission | No impersonation endpoint exists; AST check + test_admin_impersonation_not_found verify absence; violates privacy-first core value |
| _user_to_dict() whitelist for admin responses | Explicit field whitelist prevents accidental password_hash/credentials_enc leakage from admin endpoints |
| Quota warning is 200 not 4xx | Below-usage limit change is applied; warning=True advisory field returned — not a rejection |
| AdminQuotasTab fetches quotas per-user via Promise.allSettled | adminListUsers() does not include quota fields; per-user endpoint parallelized; failed quotas filtered silently |
| Temp password via crypto.getRandomValues | Browser-native CSPRNG; no external library; always satisfies AUTH-01 strength rules |
| batch_alter_table for NOT NULL in migration 0003 | SQLite requires batch_alter_table for ALTER COLUMN; transparent passthrough on PostgreSQL — enables SQLite CI test runs |
| MinIO step in migration 0003 gated on MINIO_ENDPOINT | Migration skips MinIO deletions when env var absent; enables safe SQLite test runs per T-03-02 |
| raising=False for Phase 3 MinIO mock fixtures | mock_minio_presigned + mock_minio_stat patch methods that don't exist until Plan 03-02; raising=False pre-installs them |
| Dual MinIO client (internal + public) | Presigned URL HMAC signature must be computed with browser-visible hostname (localhost:9000); using internal Docker client (minio:9000) causes browser signature mismatch |
| Wave 2 user_id=None guard | upload-url sets user_id=None + object_key "null-user/" prefix; confirm skips quota when user_id is None; Plan 03-03 removes both guards |
| SQLite quota xfail(strict=False) | SQLite stores UUID as CHAR(32) without dashes; raw SQL WHERE user_id = :uid never matches str(uuid) dashed format — test-env limitation, not code defect |
| Celery mock required in /confirm tests | extract_and_classify.delay() connects to Redis; monkeypatch blocks it in unit tests; MagicMock pattern established for all confirm endpoint tests |
| get_regular_user raises 403 for admin | Admin is authenticated but must not access document content; 401 would falsely imply unauthenticated — 403 is correct for role rejection |
| Cross-user doc access returns 404 not 403 | Combining "not found" and "wrong owner" into 404 prevents attacker from learning which doc IDs exist for other users (D-16, T-03-11) |
| CASE WHEN replaces GREATEST in quota decrement | SQLite lacks GREATEST scalar function; CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END is semantically equivalent and SQLite-compatible |
| load_topics_for_user uses or_(user_id == x, user_id.is_(None)) | SQLAlchemy is_(None) not == None; or_() combines system topics and user's own topics for namespace-scoped query (D-17, DOC-04) |
| AI-suggested topics go in user namespace | classifier passes user_id=doc.user_id to create_topic; AI-suggested topics are per-user not system-wide (D-11) |
| Celery task signature unchanged for ai_provider | Task receives only document_id; ai_provider/ai_model resolved inside _run via session.get(User, doc.user_id) — prevents broker injection (T-03-19) |
| _DEFAULT_SYSTEM_PROMPT in classifier.py | System prompt env var is optional; hardcoded fallback kept in classifier module not config.py (D-13) |
| Default AI provider is ollama/llama3.2 | Code defaults; overridable via DEFAULT_AI_PROVIDER / DEFAULT_AI_MODEL env vars (D-15) |
| /settings route kept as static placeholder | SettingsView shows admin-managed card; route not removed to avoid UX regression (Risk 6) |
| Plain anchor in quota rejection block | <a href="/settings"> used instead of <router-link> to avoid import dependency in upload component |
| uploadProgress entries owned by parent | Store does not clear uploadProgress map entries after upload; DropZone/parent clears on row dismiss |
| fetchQuota silent catch in auth store | Silent catch keeps last-known values; QuotaBar owns loadFailed state and hides on error (UI-SPEC) |
| XHR PUT progress range 590 | 5 + Math.round(pct * 0.85) maps XHR 0-100 → visual 5-90; remaining 10% covers confirm + enqueue |
| FTS stubs carry both xfail and skipif(INTEGRATION) | skipif fires first in non-INTEGRATION runs (tests appear SKIPPED); xfail catches failures when INTEGRATION=1 — both decorators required |
| Wave 0 stubs: single-line body only | All Phase 4 stubs: body is only pytest.xfail("not implemented yet") — no assertion code; strict=False so xpass never breaks CI |
| GIN index via op.execute() raw SQL | Alembic autogenerate cannot round-trip expression indexes; raw SQL with comment prevents re-creation on every --autogenerate run (issue #1390) |
| put_object_raw not in StorageBackend ABC | audit-logs bucket is MinIO-only; local/WebDAV backends have no audit concept; MinIOBackend-only method |
| write_audit_log uses session.flush() | D-14: caller owns the transaction; flush queues the audit entry without committing — commit remains caller's responsibility |
| Breadcrumb uses iterative Python parent-walk | Not WITH RECURSIVE — ensures SQLite unit tests pass; cycle guard (visited set) prevents infinite loop on malformed data |
| document_move_router is a separate APIRouter | PATCH /api/documents/{id}/folder placed in folders.py not documents.py; separate router with /api/documents prefix avoids circular import |
| FTS plainto_tsquery wrapped in try/except | SQLite silently degrades to unfiltered results when plainto_tsquery unavailable; PostgreSQL works fully — no unit test breakage |
| Share IDOR: DELETE returns 404 not 403 | Prevents share ID enumeration; attacker cannot learn which share IDs exist for other users (T-04-04-02) |
| /received before /{share_id} in router | Path parameter conflict: FastAPI routes /received as /{share_id}="received" if DELETE is defined first — ordering enforced by comment |
| No quota touch in shares.py | Recipient's quota is never modified by share operations (T-04-04-04); sharing is metadata-only from quota's perspective |
| login_failed audit metadata_=None | No email, no hash, no PII in login failure audit events — T-04-07-01 threat mitigation |
| document audit metadata whitelist | document.uploaded contains only size_bytes and storage_backend; document.deleted contains only size_bytes — no filename, no extracted_text |
| CloudConnectionOut whitelist pattern | Pydantic model with exactly the safe fields; credentials_enc absent by omission — SEC-08 safe-by-default |
| admin.user_deleted flush before delete | audit write flushed (session.flush()) while user FK still valid; session.delete(user) follows — preserves audit FK integrity |
| test_admin_impersonation 405 acceptable | DELETE /users/{id} causes GET to return 405 not 422; both mean no GET impersonation endpoint; test updated to accept {404, 405, 422} |
| CloudConnectionError shared exception type | Defined once in google_drive_backend.py; imported by onedrive_backend.py — single exception type across all cloud backends |
| cache_discovery=False on Drive build() | Prevents /tmp discovery cache writes — directory traversal vector (T-05-03-05) |
| createUploadSession for all OneDrive uploads | No 4 MB size gate; resumable sessions handle small and large files through same code path (Pitfall 6) |
| MSAL invalid_grant via result.get('error') | MSAL returns dict (never raises); field-level check is correct — Assumption A3 confirmed |
| WebDAVBackend SSRF double guard pattern | validate_cloud_url in __init__ (construct-time) AND before every asyncio.to_thread() call — mirrors D-17 requirement for DNS-rebinding mitigation |
| nextcloud/webdav dispatch to distinct classes | NextcloudBackend for 'nextcloud' provider (has list_folder); WebDAVBackend for 'webdav' — identical constructor signatures |
| webdavclient3 upload_to/download_from confirmed | A1 assumption in RESEARCH.md was correct; verified via runtime dir(Client) inspection before use |
| OAuth callback not authenticated via JWT | OAuth redirect flow cannot carry Bearer header; state token (256 bits, TTL 1800s, single-use) provides equivalent security |
| Cloud cleanup added to admin delete_user only | auth.py has no DELETE /api/users/me; admin-initiated deletion is the only account deletion code path |
| Cloud cleanup runs before MinIO cleanup | credentials still in DB when get_storage_backend_for_document is called; sessions.flush() after conn deletes |
| Options API preserved in v0.2 refactor | Composition API migration is scope-creep; refactor within Options API |
| Admin panel as standalone route subtree | AdminView as tabs-on-user-layout is architecturally wrong; v0.2 fixes this |
| No rewrites — refactor only | Preserve existing tests and behavior; change internal structure, not contracts |
| client.js barrel re-export pattern | Zero consumer churn; all 35+ import sites stay unchanged; mocks still work |
| Sub-routers carry NO prefix | Parent APIRouter prefix propagates — sub-routers with prefix cause double-segment URLs |
| AdminLayout as route component, not App.vue branch | Router resolves AdminLayout as the /admin component; its router-view renders children |
| to.matched.some() for requiresAdmin guard | Vue Router 4 does not inherit meta to children; direct to.meta check is a security regression |
### Roadmap Evolution
- Phase 6 added: Performance & Production Hardening (2026-05-30)
- Phase 6.1 inserted: Close v1.0 audit gaps — SHARE-02/STORE-06/ADMIN-06 (2026-05-30)
- Phase 6.2 inserted: Close v1 sharing + cloud-delete + CSV export gaps (2026-05-31)
- Phase 7 added: Redo and optimize LLM integration
- Phase 7.1 inserted (URGENT): Security: session revocation on privilege change (CR-01..03) — inserted after Phase 7 (2026-06-05)
- v0.1 completed: all 7 foundation phases + security hardening (2026-06-06)
- v0.2 started: UI overhaul, code quality, admin panel rearchitecture (2026-06-07)
- v0.2 roadmap defined: 4 phases, 40 requirements, phases 811 (2026-06-07)
### Open Questions
- Verify cloud SDK minor versions on PyPI before Phase 5 pinning
### Workflow Changes (2026-05-25)
Two mandatory cross-cutting gates added to all phases going forward:
**1. Test gate** — every plan must leave `pytest -v` passing with zero failures. Every new function/endpoint/component requires at least one test. All security-invariant negative tests (wrong owner, admin block, token replay) must exist and pass.
**2. Security gate** — a security agent runs after every plan execution and is a blocking requirement before phase advancement. It:
- Runs `bandit -r backend/`, `pip audit`, `npm audit --audit-level=high`
- Checks for path traversal, IDOR, SSRF, timing attacks, mass assignment, token replay
- Verifies admin endpoints never return `password_hash`, `credentials_enc`, or document content
- Fixes issues directly (full edit access) rather than deferring
**3. Bug fix rule** — all fixes: root cause only, ≤50 lines, regression test required, no workarounds.
See CLAUDE.md "Testing Protocol" and "Security Protocol" sections for full detail.
None.
### Blockers
@@ -182,34 +82,7 @@ _Updated at each phase transition._
| Field | Value |
|---|---|
| Last session | 2026-05-25 — Phase 3 UAT complete (10/10); security gate passed (3 fixes: bandit B324, Referrer-Policy, IDOR on /topics/suggest); test fix for test_lmstudio.py import |
| Last session | 2026-05-25 — Phase 4 context gathered (4 areas: folder nav, sharing, PDF proxy, audit log) |
| Last session | 2026-05-25 — Phase 4 UI-SPEC approved (6 dimensions: 2 PASS clean, 3 FLAG non-blocking, 0 BLOCK) |
| Last session | 2026-05-25 — Phase 4 plans created (9 plans, 7 waves) + verification passed (0 blockers, 2 warnings) |
| Last session | 2026-05-25 — Plan 04-01 executed: 30 Wave 0 xfail stubs across 5 test files; 39 xfailed total, zero new failures |
| Last session | 2026-05-25 — Plan 04-02 executed: migration 0004 (pdf_open_mode, GIN FTS index, audit-logs bucket) + MinIOBackend.put_object_raw(); 122 tests pass |
| Last session | 2026-05-25 — Plan 04-03 executed: write_audit_log() helper (flush-not-commit, never-raises) + FOLD-01..05 folder API + document sort/FTS/move; 122 pass, 0 new failures |
| Last session | 2026-05-25 — Plan 04-04 executed: Sharing API (SHARE-01..05) — grant/list/received/revoke with IDOR protection; 7 xfailed, zero new failures |
| Last session | 2026-05-28 — Phase 4 UAT complete (14/15 passed, 1 bug found + fixed: duplicate folder on creation); sidebar collapsible folder tree added; Phase 4 marked complete |
| Last session | 2026-05-28 — Phase 5 UI-SPEC approved (6/6 dimensions passed; 2 revision rounds: Cancel label → context-specific, text-lg → text-xl) |
| Last session | 2026-05-28 — Phase 5 planned (8 plans, 7 waves); verification passed (4 blockers → resolved: D-05 API-layer refresh path, SEC-09 cloud cleanup, frontend_url config, RESEARCH resolved markers) |
| Last session | 2026-05-28 — Plan 05-01 executed: Wave 0 Nyquist scaffold — 19 xfail stubs in test_cloud.py, 4 cloud fixtures in conftest.py, 6 package pins, 8 config settings; 172 passed / 43 xfailed |
| Last session | 2026-05-28 — Plan 05-02 executed: cloud_utils.py (SSRF+HKDF), cloud_cache.py (TTLCache), storage factory extended; 199 passed / 43 xfailed / 1 pre-existing failure |
| Last session | 2026-05-28 — Plan 05-03 executed: GoogleDriveBackend (Drive v3, cache_discovery=False, asyncio.to_thread) + OneDriveBackend (MSAL, resumable upload, CHUNK_SIZE=10MB); 262 passed / 43 xfailed / 1 pre-existing failure |
| Last session | 2026-05-28 — Plan 05-04 executed: WebDAVBackend + NextcloudBackend (SSRF double-guard, asyncio.to_thread, list_folder); 262 passed / 43 xfailed / 1 pre-existing failure |
| Last session | 2026-05-29 — Plan 05-05 executed: cloud.py (7 endpoints), main.py (routers registered), admin.py (SEC-09 cloud cleanup); 262 passed / 43 xfailed / 1 pre-existing failure |
| Last session | 2026-05-29 — Plan 05-06 executed: documents.py cloud upload+content-proxy extension; all 15 xfail stubs promoted to 20 passing tests (CLOUD-03, CLOUD-05, CLOUD-07); 282 passed / 24 xfailed / 1 pre-existing failure |
| Last session | 2026-05-29 — Plan 05-07 executed: useCloudConnectionsStore, 3-tab SettingsView, SettingsCloudTab (4 providers, status badges, OAuth callback), CloudCredentialModal; 61 tests passing, build exits 0 |
| Last session | 2026-05-29 — Phase 5 complete: 4 cloud backends (Google Drive, OneDrive, Nextcloud, WebDAV), HKDF credential encryption, SSRF prevention, OAuth flows, cloud API (7 endpoints), frontend Settings 3-tab + CloudCredentialModal, AppSidebar cloud section, all 20 Phase 5 tests passing, security gates passed |
| Last session | 2026-05-30 — Phase 5 UAT: 5/6 tests passed; 3 gaps diagnosed (OneDrive unconfigured 500, cloud doc stream opaque 500, DropZone disappeared); gap-closure plan 05-12 created (3 tasks, wave 1) |
| Last session | 2026-05-30 — Plan 05-12 executed: OAuth 400 preflight (unconfigured creds), 502 cloud fallback, celery-worker volume mount, upload hint in CloudStorageView; 293 passed / 24 xfailed / 1 pre-existing failure |
| Last session | 2026-05-30 — Phase 6.1 executed: 7 share tests + 4 audit tests promoted from xfail stubs; second_auth_user fixture added; 309 passed / 0 failed |
| Last session | 2026-05-31 — Phase 6.2 planned: 4 plans (3 waves); SHARE-03/SHARE-05 (Plan 02), cloud-delete (Plan 03), ADMIN-06 audit enrichment + CSV + daily exports (Plan 04); verification passed (0 blockers, 2 cosmetic warnings fixed) |
| Last session | 2026-06-03 — Phase 7 planned: 5 plans (5 waves); system_settings DB + HKDF (Plan 01), ProviderConfig + GenericOpenAIProvider + singleton fix (Plan 02), Anthropic output_config + classifier wiring (Plan 03), Celery retry harness + re-queue endpoint (Plan 04), admin AI panel + DocumentCard badge (Plan 05); verification passed (4 blockers fixed in revision round, 0 remaining) |
| Last session | 2026-06-05 — Phase 7 complete: all 5 plans executed; UAT 11/11 passed; security gate passed (bandit zero HIGH, npm audit zero high/critical); _doc_to_dict status field fix + regression test committed; v1.0 milestone DONE |
| Last session | 2026-06-05 — Phase 7.1 complete: CR-01/CR-02/CR-03 implemented; skip_token_hash added to revoke_all_refresh_tokens; change_password, enable_totp, disable_totp now revoke other sessions and return sessions_revoked; 3 new tests passing; frontend toasts in SettingsAccountTab + TotpEnrollment; 373 passed 0 failed; v0.1.1 |
| Last session | 2026-06-05 — Phase 7.2 planned: 3 plans (Wave 0 test scaffolding, Wave 1 jti+NBF check, Wave 2 user_nbf writes in 4 handlers); verification passed (0 blockers, 1 warning fixed); ready to execute |
| Last session | 2026-06-06 — Phase 7.3 complete: ES256 asymmetric JWT signing live; default session 16h; remember_me opt-in 30d; startup bulk-revocation hook; all 9 ES256/remember-me tests green; v0.1.2 |
| Next action | Phase 7.4 complete — security review then ship |
| Last session | 2026-06-08 — Phase 8 planned; 8 plans created |
| Next action | /gsd:execute-phase 8 |
| Pending decisions | None |
| Resume file | None |
| Resume file | .planning/phases/08-stack-upgrade-backend-decomposition/ |
@@ -0,0 +1,129 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 01
type: execute
wave: 0
depends_on: []
files_modified:
- backend/tests/test_auth.py
autonomous: true
requirements: [CR-01, CR-02, CR-03]
tags: [tests, session-revocation, wave-0]
must_haves:
truths:
- "Three new pytest tests exist for CR-01, CR-02, CR-03 as xfail stubs"
- "Stubs name the exact behavior they cover so executor of plan 08-03 can promote them"
- "Running pytest -v shows three new tests with status xfail (not error, not pass)"
artifacts:
- path: "backend/tests/test_auth.py"
provides: "Three new xfail test stubs for session revocation on privilege change"
contains: "test_change_password_revokes_other_sessions"
key_links:
- from: "backend/tests/test_auth.py"
to: "backend/api/auth.py change_password / enable_totp / disable_totp"
via: "test exercises real handler via httpx.AsyncClient"
pattern: "client.post\\(\"/api/auth/(change-password|totp/enable|totp)\""
---
<objective>
Create Wave 0 test stubs (xfail) for CR-01, CR-02, CR-03. These tests will be promoted to passing in plan 08-03 after the `useToastStore` stub and frontend wiring are complete. The backend implementation for all three is ALREADY in place (verified in RESEARCH.md §"Wave 1: Session Revocation"); these stubs lock the expected behavior contract before any refactoring touches `api/auth.py`.
Purpose: Anti-regression Nyquist scaffold — when plan 08-06 splits `api/auth.py` into `api/auth/` package, these promoted tests guarantee the session revocation logic still works through the new module structure.
Output: Three xfail-marked tests added to `backend/tests/test_auth.py`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-VALIDATION.md
@backend/api/auth.py
@backend/services/auth.py
@backend/tests/test_auth.py
@backend/tests/conftest.py
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add three xfail stubs for CR-01/CR-02/CR-03 to test_auth.py</name>
<files>backend/tests/test_auth.py</files>
<read_first>
- backend/tests/test_auth.py (read the full file to discover existing fixtures, login helper pattern, and how authenticated requests are issued)
- backend/tests/conftest.py (locate `auth_user` fixture, `client` fixture, and `auth_limiter` reset hook)
- backend/api/auth.py lines 478-540 (change_password — confirm response shape includes `sessions_revoked`)
- backend/api/auth.py lines 579-636 (enable_totp — confirm response shape)
- backend/api/auth.py lines 641-682 (disable_totp — confirm response shape)
- backend/services/auth.py lines 250-273 (revoke_all_refresh_tokens signature with skip_token_hash)
</read_first>
<behavior>
- Test `test_change_password_revokes_other_sessions`: register user, log in twice to obtain TWO refresh-token rows in DB (call them session A and session B). Using session A's access token, POST `/api/auth/change-password` with the correct current password and a new valid password. Assert: response 200, body contains `"sessions_revoked": 1` (session B revoked, session A preserved via `skip_token_hash`); session A's refresh token is still usable on POST `/api/auth/refresh`; session B's refresh token fails on POST `/api/auth/refresh` with 401.
- Test `test_enable_totp_revokes_other_sessions`: register user, log in twice (sessions A and B). Using session A, POST `/api/auth/totp/setup` to obtain a `provisioning_uri`, derive a valid TOTP code via `pyotp.TOTP(secret).now()`, POST `/api/auth/totp/enable` with that code. Assert: response 200, body contains `"sessions_revoked": 1`, session B refresh fails 401, session A refresh succeeds.
- Test `test_disable_totp_revokes_other_sessions`: register user, enable TOTP, then log in twice with TOTP (sessions A and B). Using session A, DELETE `/api/auth/totp` with the current TOTP code. Assert: response 200, body contains `"sessions_revoked": 1`, session B refresh fails 401, session A refresh succeeds.
</behavior>
<action>
Append three test functions to `backend/tests/test_auth.py`, each decorated with `@pytest.mark.xfail(reason="Wave 0 stub — promoted to passing in 08-03", strict=False)`. Use the existing test patterns in the file (httpx.AsyncClient async fixtures, `auth_user` fixture from conftest, `await client.post(...)`). The three function names MUST be exactly:
- `async def test_change_password_revokes_other_sessions(client, db_session)`
- `async def test_enable_totp_revokes_other_sessions(client, db_session)`
- `async def test_disable_totp_revokes_other_sessions(client, db_session)`
Inside each test body, write the full assertion logic per the `<behavior>` block — do NOT leave them as bare `pass` stubs. The tests SHOULD pass right now (backend already implemented per RESEARCH.md), but `strict=False` xfail allows either xpassed or xfailed without erroring the suite. Plan 08-03 will remove the `@pytest.mark.xfail` decorator and confirm they pass cleanly. Use `pyotp.TOTP(secret).now()` for TOTP code generation; import pyotp at the top of the test file if not already imported. For the login-twice pattern, use distinct `User-Agent` headers on each login to ensure separate refresh-token rows; capture `response.cookies['refresh_token']` for each session.
</action>
<verify>
<automated>cd backend && pytest tests/test_auth.py::test_change_password_revokes_other_sessions tests/test_auth.py::test_enable_totp_revokes_other_sessions tests/test_auth.py::test_disable_totp_revokes_other_sessions --tb=no -q</automated>
</verify>
<acceptance_criteria>
- `grep -c "def test_change_password_revokes_other_sessions" backend/tests/test_auth.py` returns 1
- `grep -c "def test_enable_totp_revokes_other_sessions" backend/tests/test_auth.py` returns 1
- `grep -c "def test_disable_totp_revokes_other_sessions" backend/tests/test_auth.py` returns 1
- `grep -c "pytest.mark.xfail" backend/tests/test_auth.py` increased by at least 3 vs. pre-change baseline
- Pytest output for these three test IDs shows status `XPASS` or `XFAIL` — never `ERROR` and never `FAILED`
- Body of each test asserts `data["sessions_revoked"] == 1` (not `>= 1`, not `is not None`)
- Body of each test verifies the OTHER session's refresh token returns 401 on `/api/auth/refresh`
- Body of each test verifies the CURRENT session's refresh token returns 200 on `/api/auth/refresh`
</acceptance_criteria>
<done>Three xfail-marked tests appended to test_auth.py with full assertion logic exercising the documented CR-01/02/03 contracts; pytest collects and runs them without error.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → POST /api/auth/change-password | Authenticated user requests password change; backend must revoke all OTHER refresh tokens |
| client → POST /api/auth/totp/enable | Authenticated user enables TOTP; backend must revoke all OTHER refresh tokens |
| client → DELETE /api/auth/totp | Authenticated user disables TOTP; backend must revoke all OTHER refresh tokens |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-01-01 | Tampering | Test fixture isolation | mitigate | Each test creates its own user via `auth_user` fixture; tests do not share session state |
| T-08-01-02 | Repudiation | xfail strict mode | mitigate | `strict=False` permits XPASS without erroring; plan 08-03 removes the marker and runs strict |
| T-08-01-SC | Supply Chain | pytest, pyotp | accept | Already pinned in requirements.txt; this plan adds no new packages |
</threat_model>
<verification>
- `cd backend && pytest tests/test_auth.py --tb=no -q` shows three new tests with xpassed/xfailed status (no errors)
- Full backend suite still green: `cd backend && pytest -v` — zero new failures vs. baseline
- File diff shows only additions to `backend/tests/test_auth.py` (no other files touched)
</verification>
<success_criteria>
- Three new xfail tests exist with the exact names listed
- Each test body contains real assertion logic (not `pass` or `pytest.skip`)
- Pytest collects all three without error
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-01-SUMMARY.md` when done. Include: which fixtures were used, any helper functions added, the exact xfail decorator reason string, and the pre-/post-stub `pytest --co` count.
</output>
@@ -0,0 +1,172 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 02
type: execute
wave: 0
depends_on: []
files_modified:
- backend/api/schemas.py
- backend/api/cloud.py
autonomous: true
requirements: [CODE-08]
tags: [shared-schemas, cross-package, prerequisite, wave-0]
must_haves:
truths:
- "backend/api/schemas.py exists and defines CloudConnectionOut with field_validator coerce_id_to_str"
- "backend/api/cloud.py imports CloudConnectionOut from api.schemas (not api.admin)"
- "All cloud endpoints continue to return the same JSON shape as before"
- "CloudConnectionOut definition appears exactly once across the entire backend tree"
artifacts:
- path: "backend/api/schemas.py"
provides: "Cross-package Pydantic response models (D-10 destination for shared schemas)"
contains: "class CloudConnectionOut"
- path: "backend/api/cloud.py"
provides: "Cloud endpoint router updated to import from api.schemas"
contains: "from api.schemas import CloudConnectionOut"
key_links:
- from: "backend/api/cloud.py"
to: "backend/api/schemas.py"
via: "import statement at module top"
pattern: "from api.schemas import CloudConnectionOut"
- from: "backend/api/admin.py"
to: "backend/api/schemas.py"
via: "import statement to be added in plan 08-04 admin split"
pattern: "from api.schemas import CloudConnectionOut"
---
<objective>
Create the new `backend/api/schemas.py` module and move `CloudConnectionOut` into it. Update `backend/api/cloud.py` to import from the new location. This MUST happen BEFORE plan 08-04 splits `api/admin.py` — otherwise the admin-split plan would break `cloud.py`'s `from api.admin import CloudConnectionOut` import.
Purpose: Eliminate the cross-package coupling between `api/cloud.py` and `api/admin.py` (RESEARCH.md §"Pitfall 3"). Establishes the `api/schemas.py` module that plan 08-04 will continue populating with any models discovered to be shared.
Output: New `backend/api/schemas.py` with `CloudConnectionOut`; one updated import in `backend/api/cloud.py`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@backend/api/admin.py
@backend/api/cloud.py
<interfaces>
<!-- Key definition to move (from backend/api/admin.py lines ~198-223). -->
<!-- After this plan, this class lives ONLY in backend/api/schemas.py. -->
class CloudConnectionOut(BaseModel):
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)
<!-- The import currently in backend/api/cloud.py line 35: -->
from api.admin import CloudConnectionOut
<!-- becomes: -->
from api.schemas import CloudConnectionOut
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create backend/api/schemas.py with CloudConnectionOut</name>
<files>backend/api/schemas.py</files>
<read_first>
- backend/api/admin.py lines 198-223 (current CloudConnectionOut definition — must be copied verbatim including the field_validator)
- backend/api/__init__.py (confirm it exists as a package marker; do not modify)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md section "backend/api/schemas.py" (exact pattern template)
</read_first>
<action>
Create `backend/api/schemas.py` as a new file. Add a `from __future__ import annotations` header. Add a module docstring: `"""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)."""`. Add imports: `from datetime import datetime`, `from typing import Optional`, `from pydantic import BaseModel, field_validator`. Then define `class CloudConnectionOut(BaseModel)` with the exact field set from `backend/api/admin.py` lines ~198-223 (per D-10): `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}`, and the `@field_validator("id", mode="before") coerce_id_to_str` classmethod that returns `str(v)`. Add a class-level docstring noting SEC-08: `credentials_enc` deliberately excluded, moved from `api/admin.py`, used by `api/cloud.py` and `api/admin/`. Do NOT remove the class from `api/admin.py` in this task — task 2 handles the import switch in cloud.py, and plan 08-04 will delete the original definition during the admin split.
</action>
<verify>
<automated>cd backend && python -c "from api.schemas import CloudConnectionOut; obj = CloudConnectionOut.model_validate({'id': 'abc-123', 'provider': 'google_drive', 'display_name': 'Test', 'status': 'ACTIVE', 'connected_at': '2026-06-07T00:00:00'}); assert obj.id == 'abc-123' and obj.provider == 'google_drive'"</automated>
</verify>
<acceptance_criteria>
- File `backend/api/schemas.py` exists
- `grep -c "^class CloudConnectionOut" backend/api/schemas.py` returns 1
- `grep -c "coerce_id_to_str" backend/api/schemas.py` returns 1
- `grep -c "from_attributes" backend/api/schemas.py` returns 1
- `python -c "from api.schemas import CloudConnectionOut"` from inside `backend/` exits 0 with no output
- All seven fields (id, provider, display_name, status, connected_at, server_url, connection_username) are present per `grep -c " [a-z_]*: " backend/api/schemas.py` ≥ 7
</acceptance_criteria>
<done>schemas.py exists, importable, CloudConnectionOut validates a sample dict, original admin.py class is still in place (deletion deferred to plan 08-04).</done>
</task>
<task type="auto">
<name>Task 2: Switch backend/api/cloud.py import to api.schemas</name>
<files>backend/api/cloud.py</files>
<read_first>
- backend/api/cloud.py line 35 (current `from api.admin import CloudConnectionOut`)
- backend/api/cloud.py top imports block (confirm no other re-exports from `api.admin` exist)
</read_first>
<action>
In `backend/api/cloud.py`, replace the single line `from api.admin import CloudConnectionOut` with `from api.schemas import CloudConnectionOut`. Do not change any other imports or any code below. After this edit, run the full test suite to confirm cloud endpoints still respond with the identical JSON shape (existing `tests/test_cloud.py` covers admin response surface; if not, the broader `pytest -v` smoke covers the admin list-cloud-connections endpoint too).
</action>
<verify>
<automated>cd backend && grep -c "from api.admin import CloudConnectionOut" api/cloud.py; cd backend && grep -c "from api.schemas import CloudConnectionOut" api/cloud.py; cd backend && pytest tests/test_cloud.py -x -v 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `grep -c "from api.admin import CloudConnectionOut" backend/api/cloud.py` returns 0
- `grep -c "from api.schemas import CloudConnectionOut" backend/api/cloud.py` returns 1
- `cd backend && pytest tests/test_cloud.py -x` exits 0
- `cd backend && pytest tests/test_admin.py -x -k "cloud"` exits 0 (admin endpoints that serialize CloudConnectionOut continue to work because the original definition in admin.py is still untouched at this point)
</acceptance_criteria>
<done>cloud.py imports CloudConnectionOut from api.schemas; tests for cloud endpoints and any admin endpoints that surface cloud connections continue to pass.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| api/cloud.py → api/schemas.py | Module-load-time import; no runtime data crosses this boundary other than a class reference |
| Admin list-cloud-connections endpoint → CloudConnectionOut serialization | SEC-08 requires `credentials_enc` to be deliberately excluded from this model |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-02-01 | Information Disclosure | CloudConnectionOut schema | mitigate | Field list must NOT include `credentials_enc`; field set is restricted to the 7 fields documented in PATTERNS.md |
| T-08-02-02 | Tampering | id coercion | mitigate | Preserve `@field_validator("id", mode="before") coerce_id_to_str` so UUID→str conversion behavior is byte-identical to the old definition |
| T-08-02-03 | Denial of Service | Duplicate class definitions | mitigate | Plan 08-04 removes the old definition from api/admin.py during the admin split; until then both definitions coexist but only the schemas.py version is imported by cloud.py |
| T-08-02-SC | Supply Chain | No new packages | accept | This plan installs zero new packages |
</threat_model>
<verification>
- `cd backend && pytest tests/test_cloud.py tests/test_admin.py -x` — zero failures
- `grep -rn "from api.admin import CloudConnectionOut" backend/` returns no matches (cloud.py was the only consumer)
- `grep -rn "from api.schemas import CloudConnectionOut" backend/` returns exactly one match (cloud.py)
- `backend/api/admin.py` still contains the original `class CloudConnectionOut` definition (deletion deferred to plan 08-04)
</verification>
<success_criteria>
- `backend/api/schemas.py` exists with `CloudConnectionOut` defined correctly
- `backend/api/cloud.py` imports from `api.schemas`
- Cloud and admin tests continue to pass
- No other file is modified
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-02-SUMMARY.md` when done. Include: confirmation that the original class in admin.py is left untouched, the exact `pytest tests/test_cloud.py tests/test_admin.py -v` summary line, and a note that plan 08-04 owns the deletion of the old admin.py definition.
</output>
@@ -0,0 +1,228 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 03
type: execute
wave: 1
depends_on: [08-01]
files_modified:
- frontend/src/stores/toast.js
- frontend/src/components/settings/SettingsAccountTab.vue
- frontend/src/components/auth/TotpEnrollment.vue
- backend/tests/test_auth.py
autonomous: true
requirements: [CR-01, CR-02, CR-03]
tags: [phase-7.1, toast-stub, session-revocation, frontend]
must_haves:
truths:
- "frontend/src/stores/toast.js exists, exports useToastStore, exposes show(message, type, duration)"
- "show() is a silent no-op in this phase (Phase 10 will render); calling it never throws"
- "SettingsAccountTab.vue calls toastStore.show('Other sessions have been terminated.', 'success') when data.sessions_revoked > 0 in both changePassword and disableTotp handlers"
- "TotpEnrollment.vue calls toastStore.show('Other sessions have been terminated.', 'success') when data.sessions_revoked > 0 in confirmEnrollment handler"
- "Inline sessionRevokedToast ref + setTimeout + inline toast template blocks are removed from both components"
- "CR-01, CR-02, CR-03 tests run as plain passing tests (xfail decorator removed)"
artifacts:
- path: "frontend/src/stores/toast.js"
provides: "Pinia toast store stub with the show() contract Phase 10 must honor"
contains: "export const useToastStore"
- path: "frontend/src/components/settings/SettingsAccountTab.vue"
provides: "Refactored to use toastStore.show() in changePassword + disableTotp handlers"
contains: "toastStore.show"
- path: "frontend/src/components/auth/TotpEnrollment.vue"
provides: "Refactored to use toastStore.show() in confirmEnrollment handler"
contains: "toastStore.show"
- path: "backend/tests/test_auth.py"
provides: "Three session-revocation tests promoted from xfail to passing"
contains: "test_change_password_revokes_other_sessions"
key_links:
- from: "SettingsAccountTab.vue changePassword handler"
to: "useToastStore.show"
via: "function call when data.sessions_revoked > 0"
pattern: "toastStore\\.show\\(['\"]Other sessions have been terminated"
- from: "TotpEnrollment.vue confirmEnrollment handler"
to: "useToastStore.show"
via: "function call when data.sessions_revoked > 0"
pattern: "toastStore\\.show\\(['\"]Other sessions have been terminated"
---
<objective>
Complete the absorbed Phase 7.1 work. The backend code for CR-01/CR-02/CR-03 is already implemented in `backend/api/auth.py` (RESEARCH.md confirmed lines 518, 615, 662). This plan: (1) creates the `useToastStore` Pinia stub per the UI-SPEC.md contract, (2) replaces the inline `sessionRevokedToast` ref + `setTimeout` pattern in `SettingsAccountTab.vue` and `TotpEnrollment.vue` with `toastStore.show(...)` calls, (3) removes the inline toast HTML blocks from both components, and (4) promotes the three CR xfail stubs from plan 08-01 to passing tests.
Purpose: Ship CR-01/CR-02/CR-03 to the user-visible behavior contract that the UI-SPEC.md locks (toast on `sessions_revoked > 0`), with the call signature Phase 10 must honor without modifying any Phase 8 call site.
Output: New toast store, refactored two Vue components, promoted three backend tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-UI-SPEC.md
@frontend/src/stores/topics.js
@frontend/src/components/settings/SettingsAccountTab.vue
@frontend/src/components/auth/TotpEnrollment.vue
@backend/tests/test_auth.py
<interfaces>
<!-- The locked store API contract from UI-SPEC.md §"useToastStore API Contract": -->
toastStore.show(message: string, type: 'success'|'error'|'info' = 'success', duration: number = 4000): void
<!-- Trigger map from UI-SPEC.md §"Sessions-Revoked Notification — Interaction Contract": -->
SettingsAccountTab.vue / changePassword() → on data.sessions_revoked > 0 → toastStore.show('Other sessions have been terminated.', 'success')
SettingsAccountTab.vue / disableTotp() → on data.sessions_revoked > 0 → toastStore.show('Other sessions have been terminated.', 'success')
TotpEnrollment.vue / confirmEnrollment() → on data.sessions_revoked > 0 → toastStore.show('Other sessions have been terminated.', 'success')
<!-- Existing inline state to remove (from grep): -->
SettingsAccountTab.vue: lines 6, 17, 211, 226-227, 263-264 (sessionRevokedToast ref + setTimeout + inline HTML block)
TotpEnrollment.vue: lines 6, 15, 149, 175-176 (sessionRevokedToast ref + setTimeout + inline HTML block)
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create useToastStore Pinia stub at frontend/src/stores/toast.js</name>
<files>frontend/src/stores/toast.js</files>
<read_first>
- frontend/src/stores/topics.js (simplest existing Pinia store — copy the setup-store style with defineStore + named function exports)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-UI-SPEC.md §"useToastStore API Contract" (exact signature, default values, "MUST NOT" rules)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/stores/toast.js" (full stub template)
</read_first>
<behavior>
- Importing `useToastStore` from `'../stores/toast.js'` resolves successfully
- Calling `useToastStore().show('hello')` returns `undefined` and does not throw
- Calling `useToastStore().show('hello', 'error')` returns `undefined` and does not throw
- Calling `useToastStore().show('hello', 'success', 8000)` returns `undefined` and does not throw
- The store does not render any DOM element (verified visually — Phase 10 implements rendering)
</behavior>
<action>
Create `frontend/src/stores/toast.js`. Use the setup-store style (`defineStore('toast', () => { ... })`). Define a single function `show(message, type = 'success', duration = 4000)` with positional parameters only (the UI-SPEC explicitly forbids object-argument shape `show({message, type})`). The function body is empty (no-op stub). Return `{ show }` so the store exposes the method. Add a top-of-file docstring (JS comment block) stating: Phase 7.1 STUB — Phase 10 (UX-10) fills in the full implementation; the signature `show(message, type, duration)` is locked and Phase 10 must honor it without modifying Phase 8 call sites. Default values match UI-SPEC.md: `type = 'success'` (NOT `'info'`), `duration = 4000`. Export as named export: `export const useToastStore = defineStore(...)`. Do NOT import or depend on any component.
</action>
<verify>
<automated>cd frontend && node -e "import('./src/stores/toast.js').then(m => { const s = m.useToastStore; if (typeof s !== 'function') throw new Error('useToastStore not a function'); console.log('ok'); })" 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- File `frontend/src/stores/toast.js` exists
- `grep -c "export const useToastStore" frontend/src/stores/toast.js` returns 1
- `grep -c "defineStore('toast'" frontend/src/stores/toast.js` returns 1
- `grep -c "function show(message, type = 'success', duration = 4000)" frontend/src/stores/toast.js` returns 1
- `cd frontend && npm test -- --run stores/toast 2>/dev/null || true` does not throw an import error
- The file contains NO `import` of any Vue component or DOM API
</acceptance_criteria>
<done>toast.js exists with the exact UI-SPEC signature; the module imports cleanly; show() is a silent no-op.</done>
</task>
<task type="auto">
<name>Task 2: Refactor SettingsAccountTab.vue and TotpEnrollment.vue to use toastStore</name>
<files>frontend/src/components/settings/SettingsAccountTab.vue, frontend/src/components/auth/TotpEnrollment.vue</files>
<read_first>
- frontend/src/components/settings/SettingsAccountTab.vue (full file — current inline toast HTML at lines 5-25, `sessionRevokedToast` ref at line 211, setTimeouts in changePassword at 225-228 and disableTotp at 261-264)
- frontend/src/components/auth/TotpEnrollment.vue (full file — current inline toast HTML at lines 5-23, `sessionRevokedToast` ref at line 149, setTimeout in confirmEnrollment at 174-177)
- frontend/src/stores/toast.js (the stub created in task 1)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-UI-SPEC.md §"Sessions-Revoked Notification — Interaction Contract" (message copy locked exactly: 'Other sessions have been terminated.')
</read_first>
<action>
For `frontend/src/components/settings/SettingsAccountTab.vue`:
1. Delete the inline `<div v-if="sessionRevokedToast" ...>...</div>` template block (lines ~5-25 — the fixed-position toast).
2. Delete the line `const sessionRevokedToast = ref(false)` (~line 211).
3. In the `changePassword` handler (~lines 220-230), replace `sessionRevokedToast.value = true; setTimeout(() => { sessionRevokedToast.value = false }, 5000)` with `toastStore.show('Other sessions have been terminated.', 'success')`. Keep the surrounding `if (data.sessions_revoked > 0) { ... }` guard.
4. In the `disableTotp` handler (~lines 258-267), apply the same replacement.
5. Add `import { useToastStore } from '../../stores/toast.js'` at the top with the other imports.
6. Add `const toastStore = useToastStore()` near the other store/ref declarations in `<script setup>`.
7. If `ref` is no longer used anywhere else in the file, remove it from the `vue` import; otherwise leave the import untouched.
For `frontend/src/components/auth/TotpEnrollment.vue`:
1. Delete the inline `<div v-if="sessionRevokedToast" ...>...</div>` template block (lines ~5-23).
2. Delete the line `const sessionRevokedToast = ref(false)` (~line 149).
3. In the `confirmEnrollment` handler (~lines 170-180), replace the `sessionRevokedToast.value = true; setTimeout(...)` block with `toastStore.show('Other sessions have been terminated.', 'success')`. Keep the surrounding `if (data.sessions_revoked > 0) { ... }` guard.
4. Add `import { useToastStore } from '../../stores/toast.js'` at the top.
5. Add `const toastStore = useToastStore()` near the other store/ref declarations.
6. Same `ref` cleanup rule as above.
The message string MUST be exactly `'Other sessions have been terminated.'` (matches UI-SPEC.md and the previously displayed inline copy). The `type` argument MUST be `'success'` (not `'info'`). Do NOT pass a `duration` argument — let the default 4000ms apply.
Update any existing Vitest tests (`SettingsAccountTab.test.js`, `TotpEnrollment.test.js`) ONLY if they explicitly assert on `sessionRevokedToast` or the inline DOM block. Mock `useToastStore` via `vi.mock('../../stores/toast.js', () => ({ useToastStore: () => ({ show: vi.fn() }) }))` and assert the mock was called with the locked message string. If tests already pass after the refactor without changes, do not touch them.
</action>
<verify>
<automated>cd frontend && npm test 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `grep -c "sessionRevokedToast" frontend/src/components/settings/SettingsAccountTab.vue` returns 0
- `grep -c "sessionRevokedToast" frontend/src/components/auth/TotpEnrollment.vue` returns 0
- `grep -c "toastStore.show('Other sessions have been terminated\\.', 'success')" frontend/src/components/settings/SettingsAccountTab.vue` returns 2 (changePassword + disableTotp)
- `grep -c "toastStore.show('Other sessions have been terminated\\.', 'success')" frontend/src/components/auth/TotpEnrollment.vue` returns 1 (confirmEnrollment)
- `grep -c "from '../../stores/toast.js'" frontend/src/components/settings/SettingsAccountTab.vue` returns 1
- `grep -c "from '../../stores/toast.js'" frontend/src/components/auth/TotpEnrollment.vue` returns 1
- `grep -c "setTimeout" frontend/src/components/settings/SettingsAccountTab.vue` returns 0 (the only setTimeouts in this file were for the toast)
- `cd frontend && npm test` exits 0
</acceptance_criteria>
<done>Both components removed the inline ref/setTimeout/HTML, both wire to toastStore.show with the locked copy, frontend test suite passes.</done>
</task>
<task type="auto">
<name>Task 3: Promote three CR test stubs in test_auth.py from xfail to passing</name>
<files>backend/tests/test_auth.py</files>
<read_first>
- backend/tests/test_auth.py (find the three xfail-decorated tests added in plan 08-01: test_change_password_revokes_other_sessions, test_enable_totp_revokes_other_sessions, test_disable_totp_revokes_other_sessions)
</read_first>
<action>
For each of the three tests added in plan 08-01, remove the `@pytest.mark.xfail(reason="Wave 0 stub — promoted to passing in 08-03", strict=False)` decorator line. Leave the function bodies untouched (the assertion logic was already complete in plan 08-01). Run the three tests in strict mode to confirm they pass against the existing backend implementation. Do not modify any other test or any production code.
</action>
<verify>
<automated>cd backend && pytest tests/test_auth.py::test_change_password_revokes_other_sessions tests/test_auth.py::test_enable_totp_revokes_other_sessions tests/test_auth.py::test_disable_totp_revokes_other_sessions -x -v</automated>
</verify>
<acceptance_criteria>
- `grep -B1 "def test_change_password_revokes_other_sessions" backend/tests/test_auth.py | grep -c "xfail"` returns 0
- `grep -B1 "def test_enable_totp_revokes_other_sessions" backend/tests/test_auth.py | grep -c "xfail"` returns 0
- `grep -B1 "def test_disable_totp_revokes_other_sessions" backend/tests/test_auth.py | grep -c "xfail"` returns 0
- Pytest output shows all three test IDs with status `PASSED` (not XFAIL, not XPASS, not SKIPPED)
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline
</acceptance_criteria>
<done>Three xfail decorators removed; three tests pass strictly; full backend suite green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Vue component → Pinia toast store | In-process function call; no untrusted input crosses |
| Backend response (sessions_revoked) → frontend toast trigger | Backend value is already validated server-side; frontend only uses the boolean test `> 0` |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-03-01 | Spoofing | Toast message injection | mitigate | Message string is a literal in the call sites, not user-controlled; Phase 10 contract specifies plain text only (no HTML) |
| T-08-03-02 | Repudiation | Audit log for revoked sessions | mitigate | Backend (api/auth.py) already writes audit log with `metadata_={"sessions_revoked": revoked}` for all three handlers; this plan does not alter audit behavior |
| T-08-03-03 | Information Disclosure | Toast leaks session count | accept | UI shows generic "Other sessions have been terminated."; the exact count is not revealed to the UI |
| T-08-03-04 | Tampering | xfail strict=False masks regression | mitigate | Decorator removed in task 3; subsequent pytest runs are strict |
| T-08-03-SC | Supply Chain | pinia, vue, pytest unchanged | accept | No new packages added |
</threat_model>
<verification>
- `cd frontend && npm test` — zero failures
- `cd backend && pytest tests/test_auth.py -v` — three new tests show PASSED (not XPASS / XFAIL)
- Visual smoke (recommended for executor, not gating): `cd frontend && npm run dev`, change password while logged in on two browsers; the second browser session should be revoked (this is backend behavior — Phase 10 will make the toast visible)
- `grep -rn "sessionRevokedToast" frontend/src/` returns no matches
</verification>
<success_criteria>
- toast.js stub exists with the UI-SPEC signature
- Both components wire to toastStore.show with the locked message
- All three CR tests pass strictly (no xfail)
- Full backend + frontend test suites pass
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-03-SUMMARY.md` when done. Include: exact final test output for the three CR tests, confirmation that no other components were touched, and a flagged forward-reference to Phase 10 (UX-10) that the toast store contract is locked.
</output>
@@ -0,0 +1,282 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 04
type: execute
wave: 2
depends_on: [08-02, 08-03]
files_modified:
- backend/api/admin/__init__.py
- backend/api/admin/users.py
- backend/api/admin/quotas.py
- backend/api/admin/ai.py
- backend/api/admin/shared.py
- backend/api/admin.py
- backend/services/ai_config.py
autonomous: true
requirements: [CODE-01, CODE-08]
tags: [backend-decomposition, admin, sub-router]
must_haves:
truths:
- "backend/api/admin/ is a Python package (has __init__.py) with sub-modules users.py, quotas.py, ai.py, shared.py"
- "All admin endpoints respond on the same URL paths as before (no doubled segments, no missing routes)"
- "Every admin sub-router handler explicitly injects _admin: User = Depends(get_current_admin)"
- "CloudConnectionOut is defined exactly once in the codebase (in api/schemas.py); the old definition in admin.py is deleted along with admin.py"
- "No sub-router declares a prefix on APIRouter(); only api/admin/__init__.py carries prefix=/api/admin"
- "validate_provider_id helper lives in services/ai_config.py; both SystemAiConfigUpdate and TestConnectionRequest call it"
- "Old backend/api/admin.py monolith file is deleted (replaced by the package)"
- "main.py import `from api.admin import router as admin_router` continues to work because api/admin/__init__.py re-exports router"
artifacts:
- path: "backend/api/admin/__init__.py"
provides: "Router aggregator with prefix=/api/admin; includes users_router, quotas_router, ai_router"
contains: "router = APIRouter(prefix"
- path: "backend/api/admin/users.py"
provides: "User CRUD + AI per-user config + create_system_topic handlers"
contains: "async def list_users"
- path: "backend/api/admin/quotas.py"
provides: "Per-user quota GET + PATCH handlers"
contains: "async def get_user_quota"
- path: "backend/api/admin/ai.py"
provides: "System AI config GET/PUT, test-connection, models endpoints"
contains: "async def get_ai_config"
- path: "backend/api/admin/shared.py"
provides: "_user_to_dict helper shared between users.py and quotas.py"
contains: "def _user_to_dict"
- path: "backend/services/ai_config.py"
provides: "validate_provider_id helper migrated from inline router validators per D-11"
contains: "def validate_provider_id"
key_links:
- from: "backend/main.py"
to: "backend/api/admin/__init__.py"
via: "from api.admin import router as admin_router"
pattern: "from api.admin import router as admin_router"
- from: "backend/api/admin/__init__.py"
to: "backend/api/admin/{users,quotas,ai}.py"
via: "include_router calls"
pattern: "router\\.include_router"
- from: "backend/api/admin/users.py and quotas.py"
to: "backend/api/admin/shared.py"
via: "from api.admin.shared import _user_to_dict"
pattern: "from api.admin.shared import"
---
<objective>
Decompose the 934-line `backend/api/admin.py` monolith into a focused Python package `backend/api/admin/` containing `users.py`, `quotas.py`, `ai.py` per locked decision D-05. Move shared helpers to `shared.py`. Migrate the duplicated `provider_must_be_known` validator to `services/ai_config.py` per D-11. Delete the old monolith. Zero URL changes, zero behavior changes — `pytest tests/test_admin.py -x` must pass identically before and after.
Purpose: CODE-01 (decomposition) + CODE-08 (single definition of the `provider_must_be_known` rule, plus the already-completed `CloudConnectionOut` migration from plan 08-02).
Output: `backend/api/admin/` package; one new helper in `services/ai_config.py`; `backend/api/admin.py` monolith file deleted.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@backend/api/admin.py
@backend/api/folders.py
@backend/api/cloud.py
@backend/api/schemas.py
@backend/services/ai_config.py
@backend/ai/provider_config.py
@backend/main.py
@CLAUDE.md
<interfaces>
Endpoint to sub-module map (locked per RESEARCH.md §"Recommended sub-module assignment"):
users.py: list_users (GET /users), create_user (POST /users), update_user_status (PATCH /users/{id}/status),
initiate_password_reset (POST /users/{id}/password-reset), update_ai_config (PATCH /users/{id}/ai-config),
delete_user (DELETE /users/{id}), create_system_topic (POST /topics)
quotas.py: get_user_quota (GET /users/{id}/quota), update_user_quota (PATCH /users/{id}/quota)
ai.py: get_ai_config_models (GET /ai-config/models), test_ai_connection (POST /ai-config/test-connection),
get_ai_config (GET /ai-config), update_system_ai_config (PUT /ai-config)
Pydantic model to file map:
users.py: UserCreate, UserStatusUpdate, UserAiConfigUpdate, UserDeleteConfirm, SystemTopicCreate
quotas.py: QuotaUpdate
ai.py: SystemAiConfigUpdate, TestConnectionRequest
Helper map:
shared.py: _user_to_dict (used by users.py and quotas.py)
ai.py: _ai_config_to_dict (used only by ai.py — keeps it local)
New helper to add (D-11 migration) in backend/services/ai_config.py:
def validate_provider_id(v: str) -> str:
"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""
if v not in PROVIDER_DEFAULTS:
raise ValueError(f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}")
return v
Package aggregator pattern for backend/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-router pattern in each sub-module (D-04 — NO prefix on the sub-router):
router = APIRouter() # NO prefix — parent __init__.py carries it
@router.get("/users") # becomes /api/admin/users via parent
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add validate_provider_id helper to services/ai_config.py (D-11 migration)</name>
<files>backend/services/ai_config.py</files>
<read_first>
- backend/services/ai_config.py (current contents — confirm PROVIDER_DEFAULTS is already imported at line 34)
- backend/api/admin.py lines 147-179 (current inline validators in SystemAiConfigUpdate and TestConnectionRequest)
- backend/ai/provider_config.py (confirm PROVIDER_DEFAULTS keys)
</read_first>
<action>
Add a new top-level function `validate_provider_id(v: str) -> str` to `backend/services/ai_config.py`. The function MUST: (a) take a single string argument, (b) raise `ValueError(f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}")` if `v not in PROVIDER_DEFAULTS`, (c) otherwise return `v` unchanged. Place the function after the existing `load_provider_config` helpers, before any HKDF helpers. PROVIDER_DEFAULTS is already imported at line 34 of the file. Add a single-line docstring: `"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""`. Do not modify any existing function in this file.
</action>
<verify>
<automated>cd backend && python -c "from services.ai_config import validate_provider_id; assert validate_provider_id('openai') == 'openai'; thrown=False
try:
validate_provider_id('not-a-provider')
except ValueError as e:
assert 'Unknown provider_id' in str(e); thrown=True
assert thrown; print('ok')"</automated>
</verify>
<acceptance_criteria>
- `grep -c "^def validate_provider_id" backend/services/ai_config.py` returns 1
- Running `python -c "from services.ai_config import validate_provider_id; validate_provider_id('openai')"` from `backend/` exits 0
- Running `python -c "from services.ai_config import validate_provider_id; validate_provider_id('xxx')"` from `backend/` exits non-zero with `ValueError`
- No existing function in `backend/services/ai_config.py` was modified (verify by reading the file)
</acceptance_criteria>
<done>validate_provider_id function exists, raises ValueError per CLAUDE.md service-layer rule, importable.</done>
</task>
<task type="auto">
<name>Task 2: Create backend/api/admin/ package — shared.py, users.py, quotas.py, ai.py, __init__.py</name>
<files>backend/api/admin/__init__.py, backend/api/admin/shared.py, backend/api/admin/users.py, backend/api/admin/quotas.py, backend/api/admin/ai.py</files>
<read_first>
- backend/api/admin.py lines 1-934 (full source — every endpoint, every Pydantic model, every helper)
- backend/api/folders.py (analog sub-router structure, ownership check pattern, error handling style)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md sections for `backend/api/admin/*` (exact import lists, sub-router declaration, auth guard, ValueError-to-HTTPException bridge)
- CLAUDE.md §"Backend: shared module map" (must not violate; helpers go in deps/utils.py / services/auth.py / api/admin/shared.py — never duplicated)
</read_first>
<action>
This task creates the package `backend/api/admin/` and ALL five files in one logical unit. Execute in this order so the package is in a valid state on disk after each sub-step:
(A) Create `backend/api/admin/shared.py` first. Add `from __future__ import annotations`. Import `User`, `SystemSettings` from `db.models`. Define `_user_to_dict(user: User) -> dict` exactly per the body at `backend/api/admin.py` lines 75-91 (copy verbatim — same field set, same `created_at.isoformat()` handling, same docstring). The `_ai_config_to_dict` helper does NOT go in shared.py — it goes in ai.py because only ai.py uses it.
(B) Create `backend/api/admin/users.py`. Header: `from __future__ import annotations`. Imports per PATTERNS.md §"backend/api/admin/users.py" plus `from api.admin.shared import _user_to_dict`. Declare `router = APIRouter()` with NO prefix (D-04). Move these Pydantic models verbatim from `backend/api/admin.py`: `UserCreate` (lines 96-107), `UserStatusUpdate` (109-111), `UserAiConfigUpdate` (124-127), `UserDeleteConfirm` (190-195), `SystemTopicCreate` (182-187). Move these handler functions verbatim including decorators from `backend/api/admin.py`: `list_users` (228-243), `create_user` (245-316), `update_user_status` (318-383), `initiate_password_reset` (385-415), `update_ai_config` (494-534 — the per-user one), `delete_user` (536-637 — confirm exact end line by reading source), `create_system_topic` (639-662). Every handler keeps its `_admin: User = Depends(get_current_admin)` injection — never omit.
(C) Create `backend/api/admin/quotas.py`. Header + imports per PATTERNS.md §"backend/api/admin/quotas.py", plus `from api.admin.shared import _user_to_dict`. `router = APIRouter()` no prefix. Move `QuotaUpdate` Pydantic model (lines 113-121). Move handlers `get_user_quota` (417-439) and `update_user_quota` (441-492). Both inject `_admin: User = Depends(get_current_admin)`.
(D) Create `backend/api/admin/ai.py`. Header + imports per PATTERNS.md §"backend/api/admin/ai.py". Add `from services.ai_config import encrypt_api_key, load_provider_config_by_id, validate_provider_id` (note the new `validate_provider_id` from task 1). `router = APIRouter()` no prefix. Move `_ai_config_to_dict` helper (lines 58-73) here as a module-level private helper (not in shared.py — only ai.py uses it). Move Pydantic models `SystemAiConfigUpdate` (129-155) and `TestConnectionRequest` (157-179). In both models, replace the inline `provider_must_be_known` validator body with `return validate_provider_id(v)` — the validator becomes a one-line call-through to the service-layer function (D-11). Move handlers `get_ai_config_models` (664-725), `test_ai_connection` (727-784), `get_ai_config` (786-824), `update_system_ai_config` (826-934). Every handler injects `_admin: User = Depends(get_current_admin)`.
(E) Create `backend/api/admin/__init__.py`. Contents per PATTERNS.md §"backend/api/admin/__init__.py": import APIRouter from fastapi, import `router as users_router`, `router as quotas_router`, `router as ai_router` from the three sub-modules, declare `router = APIRouter(prefix="/api/admin", tags=["admin"])`, call `router.include_router(users_router)`, `router.include_router(quotas_router)`, `router.include_router(ai_router)`. The `__init__.py` does ONLY router aggregation — no helpers, no models, no logic (Pitfall 2 prevention).
Do NOT delete `backend/api/admin.py` yet — that happens in task 3 after URL regression passes. Do NOT modify `backend/main.py` — the existing `from api.admin import router as admin_router` will continue to work after task 3 because the package's `__init__.py` exports `router`.
IMPORTANT URL regression: After creating the package, Python's import resolution prefers the package (directory with `__init__.py`) over the module (admin.py file) ONLY if both exist at the same level — but here they collide. To avoid an import ambiguity error, ensure the package directory is created and immediately rename `backend/api/admin.py` to `backend/api/admin_OLD_REMOVE_IN_TASK_3.py` as part of the same atomic edit. This sidelines the monolith without deleting it so task 3 can confirm tests pass and then delete the renamed file.
</action>
<verify>
<automated>cd backend && python -c "from api.admin import router; print('admin routes:', len(router.routes))" && python -c "from api.admin.users import router as r; print('users routes:', len(r.routes))" && python -c "from api.admin.quotas import router as r; print('quotas routes:', len(r.routes))" && python -c "from api.admin.ai import router as r; print('ai routes:', len(r.routes))"</automated>
</verify>
<acceptance_criteria>
- Files exist: `backend/api/admin/__init__.py`, `backend/api/admin/shared.py`, `backend/api/admin/users.py`, `backend/api/admin/quotas.py`, `backend/api/admin/ai.py`
- `grep -v '^#' backend/api/admin/__init__.py | grep -c 'router = APIRouter(prefix="/api/admin"'` returns 1
- `grep -v '^#' backend/api/admin/users.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/admin/quotas.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/admin/ai.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -c "Depends(get_current_admin)" backend/api/admin/users.py` ≥ 7 (one per handler)
- `grep -c "Depends(get_current_admin)" backend/api/admin/quotas.py` ≥ 2
- `grep -c "Depends(get_current_admin)" backend/api/admin/ai.py` ≥ 4
- `grep -c "return validate_provider_id(v)" backend/api/admin/ai.py` returns 2 (SystemAiConfigUpdate + TestConnectionRequest)
- `cd backend && python -c "from api.admin import router; assert len(router.routes) >= 13"` exits 0 (total: 7 users + 2 quotas + 4 ai = 13)
- `cd backend && python -c "from main import app; routes = [r.path for r in app.routes]; assert '/api/admin/users' in routes" 2>&1 | tail -3` shows no AssertionError
</acceptance_criteria>
<done>Package exists with all five files; sub-router count totals 13; no sub-router carries a prefix; main.py imports still resolve; admin.py monolith is renamed but not yet deleted.</done>
</task>
<task type="auto">
<name>Task 3: Run URL regression suite, then delete the old monolith</name>
<files>backend/api/admin.py</files>
<read_first>
- backend/tests/test_admin.py (full file — confirm test surface)
- backend/api/admin_OLD_REMOVE_IN_TASK_3.py (the renamed monolith from task 2)
</read_first>
<action>
Run `cd backend && pytest tests/test_admin.py tests/test_cloud.py tests/test_admin_ai_config.py -x -v` to confirm every admin URL still responds correctly (URLs unchanged, response shapes unchanged, auth guards still effective). If ANY test fails, do NOT delete the renamed monolith — diagnose, fix, and re-run. Only after all admin + admin_ai_config + cloud tests pass: delete `backend/api/admin_OLD_REMOVE_IN_TASK_3.py` (the renamed monolith from task 2). The old file is now superseded by the package. Re-run the full backend suite `cd backend && pytest -v` to confirm no other tests regressed.
</action>
<verify>
<automated>cd backend && pytest tests/test_admin.py tests/test_cloud.py tests/test_admin_ai_config.py -x -v 2>&1 | tail -15 && test ! -f backend/api/admin.py && test ! -f backend/api/admin_OLD_REMOVE_IN_TASK_3.py && echo "old monolith deleted"</automated>
</verify>
<acceptance_criteria>
- `test ! -f backend/api/admin.py` exits 0 (monolith deleted)
- `test ! -f backend/api/admin_OLD_REMOVE_IN_TASK_3.py` exits 0 (renamed monolith also deleted)
- `test -d backend/api/admin` exits 0 (package directory exists)
- `cd backend && pytest tests/test_admin.py -x` exits 0
- `cd backend && pytest tests/test_cloud.py -x` exits 0 (because plan 08-02 already migrated CloudConnectionOut to api/schemas.py)
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline before this plan
- `grep -rn "class CloudConnectionOut" backend/` returns exactly one match — `backend/api/schemas.py`
- `grep -rn "^class UserCreate" backend/api/admin/` returns exactly one match (in users.py); same single-definition check for `QuotaUpdate`, `SystemAiConfigUpdate`, `TestConnectionRequest`
</acceptance_criteria>
<done>Old admin.py file deleted; admin/cloud/ai-config test suites pass; full backend suite green; each Pydantic model defined exactly once.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Unauthenticated/regular user → admin endpoints | All sub-router handlers must enforce `get_current_admin` to prevent privilege escalation |
| Admin endpoint → CloudConnectionOut serialization | SEC-08: `credentials_enc` must remain absent from the schema after the move |
| Admin endpoint → response body | T-02-27 / SEC-07: `_user_to_dict` must continue to exclude `password_hash`, `credentials_enc`, `totp_secret`, document content |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-04-01 | Spoofing/Elevation of Privilege | Admin sub-router auth gate | mitigate | Every handler in users.py / quotas.py / ai.py declares `_admin: User = Depends(get_current_admin)`. Acceptance criterion counts these injections. |
| T-08-04-02 | Tampering | Sub-router prefix doubling | mitigate | Sub-routers declare `router = APIRouter()` with NO prefix per D-04; only `__init__.py` carries `prefix="/api/admin"`. Acceptance criterion greps for this. |
| T-08-04-03 | Information Disclosure | `_user_to_dict` field set | mitigate | Helper copied verbatim from admin.py lines 75-91; existing tests covering admin user list endpoint guard against accidental field inclusion. |
| T-08-04-04 | Denial of Service | Circular import via `__init__.py` | mitigate | `__init__.py` does ONLY aggregation; `_user_to_dict` lives in `shared.py` not `__init__.py` (Pitfall 2). |
| T-08-04-05 | Tampering | Validator duplication | mitigate | `provider_must_be_known` migrated to `services/ai_config.py` as `validate_provider_id`; both Pydantic models call the same service helper (D-11). |
| T-08-04-06 | Information Disclosure | Old admin.py left in place | mitigate | Task 3 deletes the file only after URL regression tests pass; renamed file as an intermediate state cannot be imported because no code imports from `api.admin_OLD_REMOVE_IN_TASK_3`. |
| T-08-04-SC | Supply Chain | No new packages | accept | This plan installs zero new packages. |
</threat_model>
<verification>
- `cd backend && pytest tests/test_admin.py tests/test_admin_ai_config.py tests/test_cloud.py -x -v` — zero failures
- `cd backend && pytest -v` — full suite zero failures
- `grep -rn "from api.admin import CloudConnectionOut" backend/` returns no matches
- `grep -rn "class CloudConnectionOut" backend/` returns exactly one match (api/schemas.py)
- `cd backend && python -c "from main import app; admin_paths = sorted({r.path for r in app.routes if r.path.startswith('/api/admin')}); print('\\n'.join(admin_paths))"` lists at minimum: `/api/admin/users`, `/api/admin/users/{user_id}/status`, `/api/admin/users/{user_id}/quota`, `/api/admin/users/{user_id}/ai-config`, `/api/admin/users/{user_id}/password-reset`, `/api/admin/users/{user_id}`, `/api/admin/topics`, `/api/admin/ai-config`, `/api/admin/ai-config/models`, `/api/admin/ai-config/test-connection`
</verification>
<success_criteria>
- `backend/api/admin/` package with 5 files
- `backend/api/admin.py` deleted
- All admin URL paths unchanged
- CODE-08: CloudConnectionOut + provider_must_be_known single-defined
- All tests pass
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-04-SUMMARY.md` when done. Include: (a) full list of admin paths emitted by `app.routes` before vs. after (must be identical), (b) exact admin/cloud/ai-config test counts pre vs. post, (c) confirmation that `_admin: User = Depends(get_current_admin)` count matches the number of handlers per module.
</output>
@@ -0,0 +1,219 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 05
type: execute
wave: 2
depends_on: [08-03]
files_modified:
- backend/api/documents/__init__.py
- backend/api/documents/upload.py
- backend/api/documents/crud.py
- backend/api/documents/content.py
- backend/api/documents/shared.py
- backend/api/documents.py
autonomous: true
requirements: [CODE-02, CODE-08]
tags: [backend-decomposition, documents, sub-router]
must_haves:
truths:
- "backend/api/documents/ is a Python package with sub-modules upload.py, crud.py, content.py, shared.py and __init__.py"
- "All document endpoints respond on the same URL paths as before"
- "Every document endpoint enforces get_current_user (regular user) and the ownership assertion `resource.user_id == current_user.id`"
- "_CLOUD_PROVIDERS frozenset and shared Pydantic models live in shared.py, not duplicated across sub-modules"
- "No sub-router declares a prefix; only api/documents/__init__.py carries prefix=/api/documents"
- "Old backend/api/documents.py monolith is deleted"
- "main.py import `from api.documents import router as documents_router` continues to work"
artifacts:
- path: "backend/api/documents/__init__.py"
provides: "Router aggregator with prefix=/api/documents; includes upload_router, crud_router, content_router"
contains: "router = APIRouter(prefix"
- path: "backend/api/documents/upload.py"
provides: "Presigned URL + direct upload + confirm handlers (POST /upload-url, POST /upload, POST /{id}/confirm)"
contains: "async def request_upload_url"
- path: "backend/api/documents/crud.py"
provides: "List/get/patch/delete + re-classify endpoint per D-08"
contains: "async def list_documents"
- path: "backend/api/documents/content.py"
provides: "Range-aware content streaming endpoint + _parse_range helper"
contains: "async def stream_document_content"
- path: "backend/api/documents/shared.py"
provides: "UploadUrlRequest, DocumentPatch Pydantic models, _CLOUD_PROVIDERS constant"
contains: "_CLOUD_PROVIDERS = frozenset"
key_links:
- from: "backend/main.py"
to: "backend/api/documents/__init__.py"
via: "from api.documents import router as documents_router"
pattern: "from api.documents import router as documents_router"
- from: "backend/api/documents/upload.py and crud.py"
to: "backend/api/documents/shared.py"
via: "from api.documents.shared import _CLOUD_PROVIDERS, UploadUrlRequest, DocumentPatch"
pattern: "from api.documents.shared import"
---
<objective>
Decompose the 852-line `backend/api/documents.py` monolith into the focused package `backend/api/documents/` per locked D-06 (4 sub-modules) and D-08 (re-classify endpoint placement is researcher/planner choice — placed in `crud.py` because it operates on an existing document with the same ownership-check pattern as get/patch/delete). Zero URL changes, zero behavior changes.
Purpose: CODE-02 (decomposition) + CODE-08 (single-definition discipline for `UploadUrlRequest`, `DocumentPatch`, `_CLOUD_PROVIDERS`).
Output: `backend/api/documents/` package; `backend/api/documents.py` monolith deleted.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@backend/api/documents.py
@backend/api/folders.py
@backend/main.py
@CLAUDE.md
<interfaces>
Endpoint to sub-module map (locked per RESEARCH.md §"api/documents.py → api/documents/ package"):
upload.py: request_upload_url (POST /upload-url, line 93-135), upload_document (POST /upload, line 137-296),
confirm_upload (POST /{id}/confirm, line 298-404)
crud.py: list_documents (GET "", line 406-529), get_document (GET /{id}, line 531-576),
patch_document (PATCH /{id}, line 578-630), delete_document (DELETE /{id}, line 632-705),
classify_document (POST /{id}/classify, line 707-742) — placed here per D-08
content.py: stream_document_content (GET /{id}/content, line 765 onwards) + _parse_range helper (line 744-760)
Pydantic model + constant to file map:
shared.py: _CLOUD_PROVIDERS frozenset (line 59), UploadUrlRequest (line 66-69), DocumentPatch (line 71-88)
Package aggregator pattern for backend/api/documents/__init__.py:
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)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create backend/api/documents/ package — shared.py, upload.py, crud.py, content.py, __init__.py</name>
<files>backend/api/documents/__init__.py, backend/api/documents/shared.py, backend/api/documents/upload.py, backend/api/documents/crud.py, backend/api/documents/content.py</files>
<read_first>
- backend/api/documents.py lines 1-852 (full source — every endpoint, every Pydantic model, every helper, every import)
- backend/api/folders.py (ownership assertion pattern: `if doc is None or doc.user_id != current_user.id: raise HTTPException(status_code=404, ...)`)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md sections for `backend/api/documents/*` (exact import lists, sub-router declaration, ownership assertion pattern)
- CLAUDE.md §"Key Architectural Rules" (atomic quota UPDATE pattern, ownership checks; preserved verbatim)
</read_first>
<action>
This task creates the package and ALL five files. Execute in this order so the package is in a valid state on disk after each sub-step:
(A) Create `backend/api/documents/shared.py`. Add `from __future__ import annotations`. Import `from typing import Optional` and `from pydantic import BaseModel, field_validator`. Move the constant `_CLOUD_PROVIDERS = frozenset({"google_drive", "onedrive", "nextcloud", "webdav"})` from `documents.py` line 59. Move `UploadUrlRequest` (line 66-69) and `DocumentPatch` (line 71-88) verbatim, including the `filename_no_path_separators` validator which is a security validator at the API boundary (RESEARCH.md confirms it stays in the Pydantic model — D-11 analysis).
(B) Create `backend/api/documents/upload.py`. Header: `from __future__ import annotations`. Imports per PATTERNS.md §"backend/api/documents/upload.py" plus `from api.documents.shared import UploadUrlRequest, _CLOUD_PROVIDERS`. `router = APIRouter()` — NO prefix (D-04). Move handler functions verbatim including decorators: `request_upload_url` (lines 93-135), `upload_document` (lines 137-296), `confirm_upload` (lines 298-404). Every handler injects `current_user: User = Depends(get_current_user)` and asserts ownership on any pre-existing document lookup using the pattern `if doc is None or doc.user_id != current_user.id: raise HTTPException(status_code=404, ...)`.
(C) Create `backend/api/documents/crud.py`. Header + imports per PATTERNS.md §"backend/api/documents/crud.py" plus `from api.documents.shared import DocumentPatch, _CLOUD_PROVIDERS`. `router = APIRouter()` — NO prefix. Move handlers verbatim: `list_documents` (406-529), `get_document` (531-576), `patch_document` (578-630), `delete_document` (632-705), `classify_document` (707-742) — placed here per D-08. Each handler injects `current_user: User = Depends(get_current_user)` and includes the ownership assertion.
(D) Create `backend/api/documents/content.py`. Header: `from __future__ import annotations`. Imports per PATTERNS.md §"backend/api/documents/content.py" (FastAPI APIRouter, Depends, HTTPException, Request; FastAPI responses StreamingResponse; SQLAlchemy AsyncSession; deps.auth.get_current_user; deps.db.get_db). `router = APIRouter()` — NO prefix. Move `_parse_range(range_header: str, file_size: int)` helper verbatim from line 744-760 as a private module-level function. Move `stream_document_content` handler verbatim from line 765 onwards. The handler injects `current_user: User = Depends(get_current_user)` and asserts ownership before serving bytes (DOC-04 / SEC-04).
(E) Create `backend/api/documents/__init__.py`. Contents per PATTERNS.md §"backend/api/documents/__init__.py": import APIRouter from fastapi, import `router as upload_router`, `router as crud_router`, `router as content_router` from the three sub-modules, declare `router = APIRouter(prefix="/api/documents", tags=["documents"])`, call `router.include_router(upload_router)`, `router.include_router(crud_router)`, `router.include_router(content_router)`. ONLY aggregation — no helpers, no models, no logic.
To avoid Python's package-vs-module name collision, immediately after creating the package directory, rename `backend/api/documents.py` to `backend/api/documents_OLD_REMOVE_IN_TASK_2.py`. Task 2 verifies tests pass and then deletes the renamed file.
The route registration order matters: `crud.py` registers `GET ""` (matches `/api/documents`), and `crud.py` registers `GET /{doc_id}` which could shadow `upload.py`'s `POST /upload-url` if route precedence is wrong. FastAPI matches by exact path + method, so HTTP-method differentiation prevents collision; verify by listing routes after include.
</action>
<verify>
<automated>cd backend && python -c "from api.documents import router; paths = sorted({(r.path, list(r.methods)[0] if r.methods else '') for r in router.routes}); print('\n'.join(f'{m} {p}' for p, m in paths))"</automated>
</verify>
<acceptance_criteria>
- Files exist: `backend/api/documents/__init__.py`, `backend/api/documents/shared.py`, `backend/api/documents/upload.py`, `backend/api/documents/crud.py`, `backend/api/documents/content.py`
- `grep -v '^#' backend/api/documents/__init__.py | grep -c 'router = APIRouter(prefix="/api/documents"'` returns 1
- `grep -v '^#' backend/api/documents/upload.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/documents/crud.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/documents/content.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -c "_CLOUD_PROVIDERS = frozenset" backend/api/documents/shared.py` returns 1
- `grep -rn "_CLOUD_PROVIDERS = frozenset" backend/api/documents/` returns exactly one match (in shared.py)
- `grep -c "Depends(get_current_user)" backend/api/documents/upload.py` ≥ 3
- `grep -c "Depends(get_current_user)" backend/api/documents/crud.py` ≥ 5
- `grep -c "Depends(get_current_user)" backend/api/documents/content.py` ≥ 1
- `grep -c "doc.user_id != current_user.id\\|user_id != current_user.id" backend/api/documents/crud.py` ≥ 3 (ownership checks on get/patch/delete and classify)
- `cd backend && python -c "from api.documents import router; assert len(router.routes) >= 9"` exits 0 (3 upload + 5 crud + 1 content = 9)
</acceptance_criteria>
<done>Package exists with all 5 files; sub-router count totals 9; no sub-router carries a prefix; ownership checks preserved; monolith renamed (not yet deleted).</done>
</task>
<task type="auto">
<name>Task 2: Run URL regression suite, then delete the old monolith</name>
<files>backend/api/documents.py</files>
<read_first>
- backend/tests/test_documents.py (full file — confirms which endpoints are exercised)
- backend/api/documents_OLD_REMOVE_IN_TASK_2.py (renamed monolith from task 1)
</read_first>
<action>
Run `cd backend && pytest tests/test_documents.py -x -v` to confirm every document URL still responds correctly (URLs unchanged, response shapes unchanged, ownership 404s preserved, quota behavior preserved). If ANY test fails, do NOT delete the renamed monolith — diagnose, fix, and re-run. Only after `tests/test_documents.py` and `tests/test_shares.py` (because shares.py uses Document lookups) both pass, delete `backend/api/documents_OLD_REMOVE_IN_TASK_2.py`. Re-run the full backend suite `cd backend && pytest -v` to confirm no other tests regressed.
</action>
<verify>
<automated>cd backend && pytest tests/test_documents.py tests/test_shares.py -x -v 2>&1 | tail -15 && test ! -f backend/api/documents.py && test ! -f backend/api/documents_OLD_REMOVE_IN_TASK_2.py && echo "old monolith deleted"</automated>
</verify>
<acceptance_criteria>
- `test ! -f backend/api/documents.py` exits 0
- `test ! -f backend/api/documents_OLD_REMOVE_IN_TASK_2.py` exits 0
- `test -d backend/api/documents` exits 0
- `cd backend && pytest tests/test_documents.py -x` exits 0
- `cd backend && pytest tests/test_shares.py -x` exits 0
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline
- `grep -rn "^class UploadUrlRequest" backend/api/documents/` returns exactly one match (in shared.py)
- `grep -rn "^class DocumentPatch" backend/api/documents/` returns exactly one match (in shared.py)
</acceptance_criteria>
<done>Old documents.py file deleted; full backend suite green; Pydantic models defined exactly once.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Regular user → document endpoints | Every handler enforces `get_current_user` AND `resource.user_id == current_user.id` to prevent IDOR (DOC-04 / SEC-04) |
| Browser → MinIO presigned PUT URL | Generated server-side scoped to `{user_id}/{document_id}/{uuid4}`; never includes admin or other user paths |
| Range-header → byte offset arithmetic | `_parse_range` must reject malformed input; copied verbatim from monolith |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-05-01 | Spoofing/Elevation | IDOR on document endpoints | mitigate | All handlers copied verbatim including `current_user.id` ownership assertion. Acceptance criterion grep counts ≥ 3 ownership checks in crud.py. |
| T-08-05-02 | Tampering | Sub-router prefix doubling | mitigate | All three sub-routers declare `router = APIRouter()` with NO prefix; only `__init__.py` carries `prefix="/api/documents"`. |
| T-08-05-03 | Information Disclosure | DocumentPatch.filename validator | mitigate | `filename_no_path_separators` validator preserved verbatim in shared.py (path traversal defense). |
| T-08-05-04 | Tampering | Atomic quota UPDATE invariant | mitigate | `confirm_upload` and `delete_document` copied verbatim — atomic `UPDATE quotas SET used_bytes = used_bytes + $delta WHERE …` pattern preserved (CLAUDE.md non-negotiable). |
| T-08-05-05 | Denial of Service | Circular import via `__init__.py` | mitigate | `__init__.py` does ONLY aggregation; shared models in `shared.py`. |
| T-08-05-SC | Supply Chain | No new packages | accept | This plan installs zero new packages. |
</threat_model>
<verification>
- `cd backend && pytest tests/test_documents.py tests/test_shares.py -x -v` — zero failures
- `cd backend && pytest -v` — full suite zero failures
- `cd backend && python -c "from main import app; doc_paths = sorted({r.path for r in app.routes if r.path.startswith('/api/documents')}); print('\\n'.join(doc_paths))"` lists at minimum: `/api/documents`, `/api/documents/upload-url`, `/api/documents/upload`, `/api/documents/{doc_id}`, `/api/documents/{doc_id}/confirm`, `/api/documents/{doc_id}/classify`, `/api/documents/{doc_id}/content`
</verification>
<success_criteria>
- `backend/api/documents/` package with 5 files
- `backend/api/documents.py` deleted
- All document URL paths unchanged, response shapes unchanged
- Every handler preserves ownership assertion
- All tests pass
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-05-SUMMARY.md` when done. Include: (a) full list of document paths emitted by `app.routes` before vs. after (must be identical), (b) `tests/test_documents.py` test count, (c) confirmation that the `_CLOUD_PROVIDERS` constant exists exactly once in the package.
</output>
@@ -0,0 +1,273 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 06
type: execute
wave: 2
depends_on: [08-03]
files_modified:
- backend/api/auth/__init__.py
- backend/api/auth/tokens.py
- backend/api/auth/totp.py
- backend/api/auth/password.py
- backend/api/auth/shared.py
- backend/api/auth.py
autonomous: true
requirements: [CODE-03, CODE-08, CR-01, CR-02, CR-03]
tags: [backend-decomposition, auth, sub-router, session-revocation]
must_haves:
truths:
- "backend/api/auth/ is a Python package with sub-modules tokens.py, totp.py, password.py, shared.py and __init__.py"
- "All auth endpoints respond on the same URL paths as before, including rate-limit decorators"
- "limiter is defined in api/auth/shared.py and re-exported from api/auth/__init__.py so `from api.auth import limiter` continues to work in main.py and tests/conftest.py"
- "change_password (in password.py), enable_totp (in totp.py), and disable_totp (in totp.py) preserve the existing revoke_all_refresh_tokens(skip_token_hash=...) call exactly — CR-01/CR-02/CR-03 tests promoted in plan 08-03 must still pass"
- "No sub-router declares a prefix; only api/auth/__init__.py carries prefix=/api/auth"
- "Old backend/api/auth.py monolith is deleted"
- "Test file imports `from api.auth import limiter as auth_limiter` (conftest.py, test_auth_api.py, test_auth_totp.py, test_totp_replay.py, test_security_headers.py) continue to work without modification"
artifacts:
- path: "backend/api/auth/__init__.py"
provides: "Router aggregator with prefix=/api/auth; includes tokens_router, totp_router, password_router; re-exports limiter"
contains: "router = APIRouter(prefix"
- path: "backend/api/auth/tokens.py"
provides: "register, login, refresh, logout, logout-all, me, me/quota, me/preferences handlers"
contains: "async def login"
- path: "backend/api/auth/totp.py"
provides: "totp/setup, totp/enable, totp DELETE (disable) handlers"
contains: "async def enable_totp"
- path: "backend/api/auth/password.py"
provides: "change-password, password-reset, password-reset/confirm handlers"
contains: "async def change_password"
- path: "backend/api/auth/shared.py"
provides: "Limiter instance, request/response Pydantic models, _set_refresh_cookie, _user_dict helpers"
contains: "limiter = Limiter"
key_links:
- from: "backend/main.py"
to: "backend/api/auth/__init__.py"
via: "from api.auth import limiter as auth_limiter AND from api.auth import router as auth_router"
pattern: "from api.auth import (limiter as auth_limiter|router as auth_router)"
- from: "backend/tests/conftest.py and 4 other test files"
to: "backend/api/auth/__init__.py"
via: "from api.auth import limiter as auth_limiter"
pattern: "from api.auth import limiter as auth_limiter"
- from: "password.py change_password handler"
to: "services.auth.revoke_all_refresh_tokens"
via: "skip_token_hash=skip_hash argument"
pattern: "revoke_all_refresh_tokens\\(.*skip_token_hash"
---
<objective>
Decompose the 825-line `backend/api/auth.py` monolith into the focused package `backend/api/auth/` per locked D-07 (4 sub-modules: tokens, totp, password, plus shared.py for cross-module helpers; module names mirror `services/auth.py` logical groupings). The Limiter instance must remain importable as `from api.auth import limiter` to avoid touching 5 test files plus `main.py`. The CR-01/CR-02/CR-03 session-revocation behavior (already implemented and now covered by passing tests from plan 08-03) MUST continue to work unchanged.
Purpose: CODE-03 (decomposition) + CODE-08 (single definition of auth Pydantic request models in `shared.py`) while preserving CR-01/CR-02/CR-03 production behavior and its passing test coverage.
Output: `backend/api/auth/` package; `backend/api/auth.py` monolith deleted; all auth + CR + rate-limit + security-header tests still pass.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@backend/api/auth.py
@backend/services/auth.py
@backend/main.py
@backend/tests/conftest.py
@CLAUDE.md
<interfaces>
Endpoint to sub-module map (locked per RESEARCH.md §"api/auth.py → api/auth/ package"):
tokens.py: register (POST /register, line 110-188), login (POST /login, line 190-323),
refresh_token (POST /refresh, line 325-387), logout (POST /logout, line 389-422),
logout_all (POST /logout-all, line 424-449),
get_me (GET /me, line 451-457), get_my_quota (GET /me/quota, line 459-475),
get_my_preferences (GET /me/preferences, line 790-806),
update_my_preferences (PATCH /me/preferences, line 808 onwards)
totp.py: totp_setup (GET /totp/setup, line 557-577), enable_totp (POST /totp/enable, line 579-639),
disable_totp (DELETE /totp, line 641-685)
password.py: change_password (POST /change-password, line 477-540),
password_reset_request (POST /password-reset, line 687-720),
password_reset_confirm (POST /password-reset/confirm, line 722-778)
Pydantic model + helper map (shared.py):
shared.py: limiter (the slowapi Limiter instance, line 46),
RegisterRequest (line 51-55), LoginRequest (line 57-63), ChangePasswordRequest (line 65-69),
TotpEnableRequest (line 542-544), PasswordResetRequest (line 546-548),
PasswordResetConfirmRequest (line 550-554), PreferencesUpdate (line 780-787),
_set_refresh_cookie (line 72-94), _user_dict (line 96-105)
Package aggregator pattern for backend/api/auth/__init__.py — note the limiter re-export:
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 so `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)
Critical: 5 production/test files import `limiter`:
- backend/main.py line 21
- backend/tests/conftest.py line 222
- backend/tests/test_auth_api.py line 99
- backend/tests/test_auth_totp.py line 98
- backend/tests/test_totp_replay.py line 76
- backend/tests/test_security_headers.py line 72
None of these files may be modified by this plan — the `__init__.py` re-export keeps the import path stable.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create backend/api/auth/ package — shared.py, tokens.py, totp.py, password.py, __init__.py</name>
<files>backend/api/auth/__init__.py, backend/api/auth/shared.py, backend/api/auth/tokens.py, backend/api/auth/totp.py, backend/api/auth/password.py</files>
<read_first>
- backend/api/auth.py lines 1-825 (full source — every endpoint, every Pydantic model, every helper, every import including hashlib for skip_hash computation, time for Redis nbf updates)
- backend/services/auth.py (confirm `revoke_all_refresh_tokens(session, user_id, skip_token_hash=None)` signature; copy the calling pattern verbatim)
- backend/main.py lines 20-30 and 246-260 (confirm `from api.auth import limiter as auth_limiter` and `app.state.limiter = auth_limiter` wiring)
- backend/tests/conftest.py line 220-225 (auth_limiter reset hook)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md sections for `backend/api/auth/*` (exact import lists, rate-limit decorator pattern, ValueError-to-HTTPException bridge, session revocation pattern)
- CLAUDE.md §"Login token hardening" (must preserve: ES256, 15-min access TTL, JTI claim handling, fgp validation, refresh token rotation behavior in tokens.py)
</read_first>
<action>
This task creates the package and ALL five files. Execute in this order so the package is in a valid state on disk after each sub-step:
(A) Create `backend/api/auth/shared.py` first because every other sub-module imports from it. Add `from __future__ import annotations`. Imports: `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`. Then declare `limiter = Limiter(key_func=get_client_ip)` (line 46 of monolith). Move these Pydantic models verbatim from monolith: `RegisterRequest` (51-55), `LoginRequest` (57-63 — keep `remember_me: bool = False` field), `ChangePasswordRequest` (65-69), `TotpEnableRequest` (542-544), `PasswordResetRequest` (546-548), `PasswordResetConfirmRequest` (550-554), `PreferencesUpdate` (780-787). Move helpers verbatim: `_set_refresh_cookie(response, raw_token, remember_me=False)` (72-94 — note the remember_me-aware max_age branching from Phase 7.3) and `_user_dict(user)` (96-105).
(B) Create `backend/api/auth/tokens.py`. Header: `from __future__ import annotations`. Imports per PATTERNS.md §"backend/api/auth/tokens.py" plus `from api.auth.shared import limiter, RegisterRequest, LoginRequest, PreferencesUpdate, _set_refresh_cookie, _user_dict`. `router = APIRouter()` — NO prefix (D-04). Move handlers verbatim INCLUDING the `@limiter.limit("10/minute")` decorators: `register` (110-188), `login` (190-323), `refresh_token` (325-387), `logout` (389-422), `logout_all` (424-449), `get_me` (451-457), `get_my_quota` (459-475), `get_my_preferences` (790-806), `update_my_preferences` (808 onwards). The ES256 + JTI + fgp + Redis user_nbf logic in `login`/`refresh_token` is preserved verbatim (CLAUDE.md non-negotiable).
(C) Create `backend/api/auth/totp.py`. Header + imports per PATTERNS.md §"backend/api/auth/totp.py" plus `from api.auth.shared import limiter, TotpEnableRequest, _set_refresh_cookie`. `router = APIRouter()` — NO prefix. Move handlers verbatim: `totp_setup` (557-577), `enable_totp` (579-639), `disable_totp` (641-685). The CR-02 (enable_totp) and CR-03 (disable_totp) skip-hash + revoke_all_refresh_tokens + sessions_revoked logic is preserved verbatim — these handlers are covered by passing tests from plan 08-03, which this plan MUST not break. Copy `hashlib` and `time` imports as needed at the top.
(D) Create `backend/api/auth/password.py`. Header + imports per PATTERNS.md §"backend/api/auth/password.py" plus `from api.auth.shared import limiter, ChangePasswordRequest, PasswordResetRequest, PasswordResetConfirmRequest`. `router = APIRouter()` — NO prefix. Move handlers verbatim: `change_password` (477-540), `password_reset_request` (687-720), `password_reset_confirm` (722-778). The CR-01 (change_password) skip-hash + revoke_all_refresh_tokens + sessions_revoked + Redis user_nbf logic is preserved verbatim from monolith lines 514-540.
(E) Create `backend/api/auth/__init__.py`. Contents:
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-export
router = APIRouter(prefix="/api/auth", tags=["auth"])
router.include_router(tokens_router)
router.include_router(totp_router)
router.include_router(password_router)
The `from api.auth.shared import limiter` line makes `from api.auth import limiter` work for `main.py` and the 5 test files (because module attribute access resolves through `__init__.py`).
To avoid the package-vs-module name collision, immediately after creating the package directory, rename `backend/api/auth.py` to `backend/api/auth_OLD_REMOVE_IN_TASK_2.py`. Task 2 verifies tests pass and deletes the renamed file.
</action>
<verify>
<automated>cd backend && python -c "from api.auth import router, limiter; print('auth routes:', len(router.routes)); print('limiter type:', type(limiter).__name__)"</automated>
</verify>
<acceptance_criteria>
- Files exist: `backend/api/auth/__init__.py`, `backend/api/auth/shared.py`, `backend/api/auth/tokens.py`, `backend/api/auth/totp.py`, `backend/api/auth/password.py`
- `grep -v '^#' backend/api/auth/__init__.py | grep -c 'router = APIRouter(prefix="/api/auth"'` returns 1
- `grep -v '^#' backend/api/auth/tokens.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/auth/totp.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -v '^#' backend/api/auth/password.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix)
- `grep -c "limiter = Limiter" backend/api/auth/shared.py` returns 1
- `grep -c "from api.auth.shared import limiter" backend/api/auth/__init__.py` returns 1 (re-export)
- `grep -c "skip_token_hash=skip_hash" backend/api/auth/password.py` returns 1 (CR-01 preserved)
- `grep -c "skip_token_hash=skip_hash" backend/api/auth/totp.py` returns 2 (CR-02 enable_totp + CR-03 disable_totp preserved)
- `cd backend && python -c "from api.auth import limiter, router; assert callable(getattr(limiter, 'limit', None)); assert len(router.routes) >= 14"` exits 0 (9 tokens + 3 totp + 3 password = at minimum 14, likely 15)
</acceptance_criteria>
<done>Package exists with all 5 files; limiter re-exported from __init__.py; CR-01/02/03 skip_token_hash calls preserved; monolith renamed (not yet deleted).</done>
</task>
<task type="auto">
<name>Task 2: Run URL + CR regression suite, then delete the old monolith</name>
<files>backend/api/auth.py</files>
<read_first>
- backend/tests/test_auth.py (the three CR tests from plan 08-03)
- backend/tests/test_auth_api.py (rate-limit tests)
- backend/tests/test_auth_totp.py (TOTP integration tests)
- backend/tests/test_totp_replay.py (replay-prevention tests)
- backend/tests/test_security_headers.py (CSP/security headers tests)
- backend/api/auth_OLD_REMOVE_IN_TASK_2.py (renamed monolith)
</read_first>
<action>
Run the affected test files in sequence to confirm decomposition preserves: (1) URL paths, (2) rate-limit behavior (limiter import path), (3) CR-01/CR-02/CR-03 session revocation, (4) TOTP replay prevention, (5) CSP headers.
Command: `cd backend && pytest tests/test_auth.py tests/test_auth_api.py tests/test_auth_totp.py tests/test_totp_replay.py tests/test_security_headers.py -x -v`.
If ANY test fails, diagnose, fix the sub-module, and re-run. Special attention: if a test fails with `ImportError: cannot import name 'limiter' from 'api.auth'`, the `__init__.py` re-export line is missing or misspelled. If a CR test fails, compare the exact `skip_hash` computation block in `password.py` / `totp.py` against the original monolith — every byte must match.
Only after all five test files pass: delete `backend/api/auth_OLD_REMOVE_IN_TASK_2.py`. Re-run `cd backend && pytest -v` to confirm no other tests regressed.
</action>
<verify>
<automated>cd backend && pytest tests/test_auth.py tests/test_auth_api.py tests/test_auth_totp.py tests/test_totp_replay.py tests/test_security_headers.py -x -v 2>&1 | tail -20 && test ! -f backend/api/auth.py && test ! -f backend/api/auth_OLD_REMOVE_IN_TASK_2.py && echo "old monolith deleted"</automated>
</verify>
<acceptance_criteria>
- `test ! -f backend/api/auth.py` exits 0
- `test ! -f backend/api/auth_OLD_REMOVE_IN_TASK_2.py` exits 0
- `test -d backend/api/auth` exits 0
- `cd backend && pytest tests/test_auth.py -x` exits 0 (includes the three promoted CR tests)
- `cd backend && pytest tests/test_auth_api.py -x` exits 0
- `cd backend && pytest tests/test_auth_totp.py -x` exits 0
- `cd backend && pytest tests/test_totp_replay.py -x` exits 0
- `cd backend && pytest tests/test_security_headers.py -x` exits 0
- `cd backend && pytest tests/test_auth.py::test_change_password_revokes_other_sessions tests/test_auth.py::test_enable_totp_revokes_other_sessions tests/test_auth.py::test_disable_totp_revokes_other_sessions -v` shows all three as PASSED
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline
- `grep -rn "^class RegisterRequest" backend/api/auth/` returns exactly one match (shared.py)
- `grep -rn "^class LoginRequest" backend/api/auth/` returns exactly one match (shared.py)
- No file in `backend/tests/` was modified by this plan (test imports still use `from api.auth import limiter`)
</acceptance_criteria>
<done>Old auth.py deleted; full backend suite green; CR-01/02/03 tests still PASSED; rate-limit / TOTP / security headers tests all green; no test file touched.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Unauthenticated client → /api/auth/login, /api/auth/register, /api/auth/refresh | Rate-limited via `@limiter.limit("10/minute")`; limiter must be the SAME instance app.state.limiter references |
| Authenticated user → /api/auth/change-password, /api/auth/totp/enable, /api/auth/totp (DELETE) | CR-01/CR-02/CR-03 require `revoke_all_refresh_tokens` with `skip_token_hash` for the current session — preserved verbatim |
| Authenticated user → /api/auth/refresh | Refresh-token rotation + family-revocation on reuse — preserved verbatim from monolith |
| Authenticated user → /api/auth/logout-all | Revoke all sessions including current; sets Redis user_nbf — preserved verbatim |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-06-01 | Spoofing | limiter re-export | mitigate | `__init__.py` re-exports `limiter` from `shared.py` so the SAME Limiter instance is accessible via the legacy import path. Acceptance criterion greps for the re-export line. |
| T-08-06-02 | Repudiation | CR-01/CR-02/CR-03 audit log entries | mitigate | `write_audit_log(..., metadata_={"sessions_revoked": revoked}, ...)` calls in change_password / enable_totp / disable_totp copied verbatim; passing tests from plan 08-03 verify behavior end-to-end. |
| T-08-06-03 | Tampering | Refresh-token rotation logic | mitigate | `refresh_token` handler in tokens.py copied verbatim including JTI, fgp, family-revocation, Redis nbf. |
| T-08-06-04 | Information Disclosure | Sub-router prefix doubling | mitigate | All three sub-routers declare `router = APIRouter()` with NO prefix; only `__init__.py` carries `prefix="/api/auth"`. |
| T-08-06-05 | Denial of Service | Circular import via `__init__.py` | mitigate | `__init__.py` does ONLY router aggregation + limiter re-export; shared models in `shared.py`. |
| T-08-06-06 | Elevation of Privilege | Session revocation skip | mitigate | `skip_token_hash=skip_hash` argument preserved in all three CR handlers; grep acceptance criterion counts occurrences. |
| T-08-06-SC | Supply Chain | No new packages | accept | This plan installs zero new packages. |
</threat_model>
<verification>
- `cd backend && pytest tests/test_auth.py tests/test_auth_api.py tests/test_auth_totp.py tests/test_totp_replay.py tests/test_security_headers.py -x -v` — zero failures
- `cd backend && pytest -v` — full suite zero failures
- `cd backend && python -c "from main import app; auth_paths = sorted({r.path for r in app.routes if r.path.startswith('/api/auth')}); print('\\n'.join(auth_paths))"` lists at minimum: `/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`, `/api/auth/logout`, `/api/auth/logout-all`, `/api/auth/me`, `/api/auth/me/quota`, `/api/auth/me/preferences`, `/api/auth/totp/setup`, `/api/auth/totp/enable`, `/api/auth/totp`, `/api/auth/change-password`, `/api/auth/password-reset`, `/api/auth/password-reset/confirm`
- `cd backend && python -c "from main import app; from api.auth import limiter; assert app.state.limiter is limiter"` — confirms identity (same instance, not a copy)
</verification>
<success_criteria>
- `backend/api/auth/` package with 5 files
- `backend/api/auth.py` deleted
- All auth URL paths unchanged
- limiter still importable via `from api.auth import limiter` (5 test files + main.py unchanged)
- CR-01/CR-02/CR-03 tests still PASSED (not regressed by decomposition)
- All tests pass
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-06-SUMMARY.md` when done. Include: (a) full list of auth paths emitted by `app.routes` before vs. after (must be identical), (b) test counts for each of the 5 covered files, (c) confirmation that `app.state.limiter is api.auth.limiter` (identity check), (d) confirmation that no file under `backend/tests/` was modified.
</output>
@@ -0,0 +1,348 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 07
type: execute
wave: 2
depends_on: [08-03]
files_modified:
- frontend/src/api/utils.js
- frontend/src/api/documents.js
- frontend/src/api/auth.js
- frontend/src/api/admin.js
- frontend/src/api/folders.js
- frontend/src/api/shares.js
- frontend/src/api/cloud.js
- frontend/src/api/topics.js
- frontend/src/api/client.js
autonomous: true
requirements: [CODE-04, CODE-08]
tags: [frontend-decomposition, api-client, barrel-reexport]
must_haves:
truths:
- "frontend/src/api/utils.js exists, exports request(), exports fetchWithRetry() — the consolidated 401-retry helper"
- "Each of documents.js / auth.js / admin.js / folders.js / shares.js / cloud.js / topics.js exists and imports `request` from './utils.js'"
- "fetchWithRetry() consolidates the 3 blob-download patterns (adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent) into one helper"
- "client.js is reduced to a barrel re-export: `export * from './documents.js'` etc., plus `export { fetchWithRetry, request } from './utils.js'`"
- "Every function name from the old client.js is exported by exactly one domain module (no duplication, no missing names)"
- "Zero consumer files (35+) under frontend/src/stores/ frontend/src/components/ frontend/src/views/ are modified — all `import * as api from '../api/client.js'` and `import { name } from '../api/client.js'` continue to resolve"
- "Frontend test suite passes"
artifacts:
- path: "frontend/src/api/utils.js"
provides: "HTTP transport: request() with bearer injection + 401-refresh-retry, fetchWithRetry() for raw Response endpoints"
contains: "export async function request"
- path: "frontend/src/api/documents.js"
provides: "Document domain functions including fetchDocumentContent (now using fetchWithRetry)"
contains: "export function listDocuments"
- path: "frontend/src/api/auth.js"
provides: "Auth domain functions: login, register, refreshToken, logout, totp*, password*, preferences, quota"
contains: "export function login"
- path: "frontend/src/api/admin.js"
provides: "Admin domain functions including blob-download admin endpoints"
contains: "export function adminListUsers"
- path: "frontend/src/api/folders.js"
provides: "Folder domain functions"
contains: "export function listFolders"
- path: "frontend/src/api/shares.js"
provides: "Share domain functions"
contains: "export function createShare"
- path: "frontend/src/api/cloud.js"
provides: "Cloud connection domain functions including initiateOAuth"
contains: "export function listCloudConnections"
- path: "frontend/src/api/topics.js"
provides: "Topic domain functions"
contains: "export function listTopics"
- path: "frontend/src/api/client.js"
provides: "Barrel re-export — preserves zero-change consumer contract"
contains: "export * from './documents.js'"
key_links:
- from: "frontend/src/api/client.js"
to: "all 7 domain modules + utils.js"
via: "export * from"
pattern: "export \\* from './(documents|auth|admin|folders|shares|cloud|topics)\\.js'"
- from: "each domain module"
to: "frontend/src/api/utils.js"
via: "import { request } from './utils.js'"
pattern: "import \\{ request"
- from: "blob-download functions (admin.js, documents.js)"
to: "frontend/src/api/utils.js fetchWithRetry"
via: "import { fetchWithRetry } from './utils.js'"
pattern: "import \\{.*fetchWithRetry"
---
<objective>
Decompose the 635-line `frontend/src/api/client.js` monolith into 7 domain modules + `utils.js` (HTTP transport + 401-retry consolidation) per locked D-12/D-13/D-14. `client.js` is reduced to a barrel re-export so the 35+ consumer files do not require any edits. The 3 blob-download 401-retry copy-paste patterns are consolidated into a single `fetchWithRetry()` helper in `utils.js`.
Purpose: CODE-04 (decomposition) + CODE-08 (single definition of the auth+retry pattern instead of 3 copies).
Output: 1 new `utils.js`, 7 new domain modules, `client.js` reduced to ~12 lines.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md
@frontend/src/api/client.js
@CLAUDE.md
<interfaces>
Function to domain map (locked per RESEARCH.md §"Frontend API Client Decomposition" and PATTERNS.md §"frontend/src/api/*"):
utils.js:
request(path, options) — moved from client.js lines 11-57 to break circular dep
fetchWithRetry(url, options) — new consolidator for blob-download 401-retry
documents.js (consumers: stores/documents.js, DocumentCard.vue, DocumentView.vue, DocumentPreviewModal.vue, etc):
listDocuments, getDocument, deleteDocument, deleteDocumentRemoveOnly,
classifyDocument, getUploadUrl, confirmUpload, uploadToCloud,
fetchDocumentContent (refactored to use fetchWithRetry), getDocumentContentUrl
auth.js (consumers: stores/auth.js, SettingsAccountTab.vue, LoginView.vue, etc):
login, register, refreshToken, logout, logoutAll, getMe,
changePassword, totpSetup, totpEnable, totpDisable,
passwordResetRequest, passwordResetConfirm,
getMyPreferences, updateMyPreferences, getMyQuota
admin.js (consumers: AdminUsersTab.vue, AdminQuotasTab.vue, AdminAiConfigTab.vue, AuditLogTab.vue, etc):
adminListUsers, adminCreateUser, adminDeactivateUser, adminReactivateUser,
adminResetUserPassword, adminGetUserQuota, adminUpdateQuota, adminUpdateAiConfig,
adminDeleteUser, getAiConfig, saveAiConfig, testAiConnection, getAiModels,
adminListAuditLog, adminExportAuditLogCsv (refactored to use fetchWithRetry),
adminListDailyExports, adminDownloadDailyExport (refactored to use fetchWithRetry)
folders.js (consumers: stores/folders.js, FolderTreeItem.vue, AppSidebar.vue):
listFolders, createFolder, getFolder, renameFolder, deleteFolder, moveDocument
shares.js (consumers: SharedView.vue, ShareModal.vue):
createShare, updateSharePermission, listShares, deleteShare, getSharedWithMe
cloud.js (consumers: stores/cloudConnections.js, SettingsCloudTab.vue, CloudCredentialModal.vue, CloudProviderTreeItem.vue, CloudFolderTreeItem.vue):
listCloudConnections, disconnectCloud, connectWebDav, updateDefaultStorage,
getCloudFolders, initiateOAuth, getConnectionConfig
topics.js (consumers: stores/topics.js, SearchableModelSelect.vue is unrelated):
listTopics, createTopic, updateTopic, deleteTopic, suggestTopics
client.js (after refactor — barrel only):
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'
Critical: All function names across these 8 files are unique — no `export *` collisions (RESEARCH.md §Pitfall 5 confirms).
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create utils.js with request() and fetchWithRetry()</name>
<files>frontend/src/api/utils.js</files>
<read_first>
- frontend/src/api/client.js lines 11-57 (current `request` function — copy verbatim)
- frontend/src/api/client.js lines 428-471 (adminExportAuditLogCsv blob pattern)
- frontend/src/api/client.js lines 492-529 (adminDownloadDailyExport blob pattern)
- frontend/src/api/client.js lines 552-581 (fetchDocumentContent blob pattern)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/api/utils.js" (full code template for both functions)
</read_first>
<action>
Create `frontend/src/api/utils.js`. Add a top-of-file JSDoc comment explaining: "HTTP transport + 401-retry consolidator. `request()` moved from client.js to break circular dependency (domain modules import request from here; client.js re-exports from domain modules). `fetchWithRetry()` consolidates 3 blob-download patterns. Security: Bearer from authStore memory only (CLAUDE.md)."
(A) Export `async function request(path, options = {})` — copy the body verbatim from `client.js` lines 11-57. Keep the lazy `await import('../stores/auth.js')` to avoid the Pinia bootstrap cycle. Keep the `noRefreshPaths` array as a local const inside the function (or as a module-level const above the function — either works, but match the original pattern from client.js).
(B) Export `async function fetchWithRetry(url, options = {}, _retry = false)` per PATTERNS.md §"frontend/src/api/utils.js" template. The function: lazy-imports `useAuthStore`, copies `headers`, injects `Authorization: Bearer ${authStore.accessToken}` if present, calls `fetch(url, { ...options, headers, credentials: 'include' })`, on 401-and-not-already-retried calls `authStore.refresh()` and recurses with `_retry=true`, on refresh failure clears `authStore.accessToken` + `authStore.user` and throws `'Session expired'`, otherwise returns the raw `Response` (caller decides how to consume it — text(), blob(), etc.).
Do NOT modify `client.js` in this task — task 9 owns the barrel rewrite.
</action>
<verify>
<automated>cd frontend && node -e "import('./src/api/utils.js').then(m => { if (typeof m.request !== 'function') throw new Error('request missing'); if (typeof m.fetchWithRetry !== 'function') throw new Error('fetchWithRetry missing'); console.log('ok'); }).catch(e => { console.error(e); process.exit(1); })"</automated>
</verify>
<acceptance_criteria>
- File `frontend/src/api/utils.js` exists
- `grep -c "export async function request" frontend/src/api/utils.js` returns 1
- `grep -c "export async function fetchWithRetry" frontend/src/api/utils.js` returns 1
- `grep -c "await import('../stores/auth.js')" frontend/src/api/utils.js` returns at least 1 (lazy import preserved)
- `grep -c "noRefreshPaths" frontend/src/api/utils.js` returns at least 1
- `grep -c "_retry" frontend/src/api/utils.js` returns at least 2 (request uses options._retry, fetchWithRetry uses positional _retry)
</acceptance_criteria>
<done>utils.js exists with both helpers, identifiable by export grep, module loads without error.</done>
</task>
<task type="auto">
<name>Task 2: Create domain modules documents.js, auth.js, topics.js</name>
<files>frontend/src/api/documents.js, frontend/src/api/auth.js, frontend/src/api/topics.js</files>
<read_first>
- frontend/src/api/client.js (full file — confirm exact function bodies and signatures for each function listed in the function-to-domain map in &lt;interfaces&gt;)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/api/documents.js" and §"frontend/src/api/auth.js"
</read_first>
<action>
For each file, write a small header docstring naming the domain (e.g., "Document API — listing, fetching, uploading, content streaming"). Add `import { request, fetchWithRetry } from './utils.js'` for documents.js (because fetchDocumentContent uses fetchWithRetry); for auth.js and topics.js use only `import { request } from './utils.js'`.
(A) `documents.js`: move these functions VERBATIM from client.js (preserve every parameter, every default value, every URL path, every body shape): `listDocuments`, `getDocument`, `deleteDocument`, `deleteDocumentRemoveOnly`, `classifyDocument`, `getUploadUrl`, `confirmUpload`, `uploadToCloud`, `getDocumentContentUrl`. Then refactor `fetchDocumentContent` to use `fetchWithRetry`: the new body becomes `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; }` — preserving the public contract (returns raw Response, throws on non-ok).
(B) `auth.js`: move these functions VERBATIM: `login`, `register`, `refreshToken`, `logout`, `logoutAll`, `getMe`, `changePassword`, `totpSetup`, `totpEnable`, `totpDisable`, `passwordResetRequest`, `passwordResetConfirm`, `getMyPreferences`, `updateMyPreferences`, `getMyQuota`.
(C) `topics.js`: move these functions VERBATIM: `listTopics`, `createTopic`, `updateTopic`, `deleteTopic`, `suggestTopics`.
Do NOT remove the functions from `client.js` yet — task 9 owns the barrel rewrite and deletion of the original bodies. This task only adds new files.
</action>
<verify>
<automated>cd frontend && node -e "Promise.all([import('./src/api/documents.js'), import('./src/api/auth.js'), import('./src/api/topics.js')]).then(([d, a, t]) => { ['listDocuments','getDocument','deleteDocument','classifyDocument','getUploadUrl','fetchDocumentContent'].forEach(n => { if (typeof d[n] !== 'function') throw new Error('documents.' + n + ' missing'); }); ['login','register','refreshToken','logout','getMe','changePassword','totpSetup','passwordResetRequest','getMyQuota'].forEach(n => { if (typeof a[n] !== 'function') throw new Error('auth.' + n + ' missing'); }); ['listTopics','createTopic','suggestTopics'].forEach(n => { if (typeof t[n] !== 'function') throw new Error('topics.' + n + ' missing'); }); console.log('ok'); }).catch(e => { console.error(e); process.exit(1); })"</automated>
</verify>
<acceptance_criteria>
- Files exist: `frontend/src/api/documents.js`, `frontend/src/api/auth.js`, `frontend/src/api/topics.js`
- `grep -c "^export function listDocuments\\|^export async function fetchDocumentContent" frontend/src/api/documents.js` returns at least 2
- `grep -c "import { request, fetchWithRetry } from './utils.js'" frontend/src/api/documents.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/auth.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/topics.js` returns 1
- `grep -c "^export function login\\|^export function register" frontend/src/api/auth.js` returns at least 2
- The node -e import check above prints `ok` (all named exports resolvable)
</acceptance_criteria>
<done>Three domain files exist with all expected exports; verified by node import.</done>
</task>
<task type="auto">
<name>Task 3: Create domain modules admin.js, folders.js, shares.js, cloud.js</name>
<files>frontend/src/api/admin.js, frontend/src/api/folders.js, frontend/src/api/shares.js, frontend/src/api/cloud.js</files>
<read_first>
- frontend/src/api/client.js (full file — confirm exact function bodies and signatures)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/api/admin.js" (blob refactor pattern using fetchWithRetry)
</read_first>
<action>
For each file, add a header docstring naming the domain. Import statements:
- admin.js: `import { request, fetchWithRetry } from './utils.js'` (audit-log CSV + daily-export use fetchWithRetry)
- folders.js, shares.js, cloud.js: `import { request } from './utils.js'`
(A) `admin.js`: move these functions VERBATIM from client.js: `adminListUsers`, `adminCreateUser`, `adminDeactivateUser`, `adminReactivateUser`, `adminResetUserPassword`, `adminGetUserQuota`, `adminUpdateQuota`, `adminUpdateAiConfig`, `adminDeleteUser`, `getAiConfig`, `saveAiConfig`, `testAiConnection`, `getAiModels`, `adminListAuditLog`, `adminListDailyExports`. Then refactor `adminExportAuditLogCsv` and `adminDownloadDailyExport` to use `fetchWithRetry` (per PATTERNS.md §"frontend/src/api/admin.js"). The new `adminExportAuditLogCsv(params = {})` body: build the URLSearchParams exactly as before, call `const res = await fetchWithRetry('/api/admin/audit-log/export?' + searchParams)`, on `!res.ok` throw `'Export failed: ' + res.status`, then call `res.text()`, create the Blob, trigger the download via temporary anchor click, revokeObjectURL on a setTimeout. The new `adminDownloadDailyExport(date)` body: `const res = await fetchWithRetry('/api/admin/audit-log/daily-exports/' + date)`, on `!res.ok` throw, then download the blob the same way. Drop the `_retry` boilerplate — fetchWithRetry handles it.
(B) `folders.js`: move these functions VERBATIM: `listFolders`, `createFolder`, `getFolder`, `renameFolder`, `deleteFolder`, `moveDocument`.
(C) `shares.js`: move these functions VERBATIM: `createShare`, `updateSharePermission`, `listShares`, `deleteShare`, `getSharedWithMe`.
(D) `cloud.js`: move these functions VERBATIM: `listCloudConnections`, `disconnectCloud`, `connectWebDav`, `updateDefaultStorage`, `getCloudFolders`, `initiateOAuth`, `getConnectionConfig`.
Do NOT remove the functions from `client.js` yet — task 4 owns the barrel rewrite.
</action>
<verify>
<automated>cd frontend && node -e "Promise.all([import('./src/api/admin.js'), import('./src/api/folders.js'), import('./src/api/shares.js'), import('./src/api/cloud.js')]).then(([ad, fo, sh, cl]) => { ['adminListUsers','adminCreateUser','adminUpdateQuota','getAiConfig','saveAiConfig','testAiConnection','adminListAuditLog','adminExportAuditLogCsv','adminDownloadDailyExport'].forEach(n => { if (typeof ad[n] !== 'function') throw new Error('admin.' + n + ' missing'); }); ['listFolders','createFolder','moveDocument'].forEach(n => { if (typeof fo[n] !== 'function') throw new Error('folders.' + n + ' missing'); }); ['createShare','updateSharePermission','getSharedWithMe'].forEach(n => { if (typeof sh[n] !== 'function') throw new Error('shares.' + n + ' missing'); }); ['listCloudConnections','initiateOAuth','getConnectionConfig'].forEach(n => { if (typeof cl[n] !== 'function') throw new Error('cloud.' + n + ' missing'); }); console.log('ok'); }).catch(e => { console.error(e); process.exit(1); })"</automated>
</verify>
<acceptance_criteria>
- Files exist: `frontend/src/api/admin.js`, `frontend/src/api/folders.js`, `frontend/src/api/shares.js`, `frontend/src/api/cloud.js`
- `grep -c "import { request, fetchWithRetry } from './utils.js'" frontend/src/api/admin.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/folders.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/shares.js` returns 1
- `grep -c "import { request } from './utils.js'" frontend/src/api/cloud.js` returns 1
- `grep -c "fetchWithRetry" frontend/src/api/admin.js` returns at least 2 (one each for adminExportAuditLogCsv and adminDownloadDailyExport)
- `grep -c "_retry" frontend/src/api/admin.js` returns 0 (boilerplate dropped — fetchWithRetry owns retry)
- `grep -c "^export function initiateOAuth" frontend/src/api/cloud.js` returns 1 (named import is used by SettingsCloudTab.vue)
- The node -e import check above prints `ok`
</acceptance_criteria>
<done>Four domain files exist with all expected exports; blob-download functions use fetchWithRetry; node import succeeds.</done>
</task>
<task type="auto">
<name>Task 4: Rewrite client.js as barrel re-export and run frontend tests</name>
<files>frontend/src/api/client.js</files>
<read_first>
- frontend/src/api/client.js (the current 635-line file — you are about to replace its contents)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md §"frontend/src/api/client.js (transport + barrel, request-response) — MODIFIED" (the full final-state template)
</read_first>
<action>
Replace the ENTIRE contents of `frontend/src/api/client.js` with this final form (preserves all previous exports via `export *`):
/**
* API client — barrel re-export.
*
* The HTTP transport (request) and 401-retry consolidator (fetchWithRetry) live in utils.js
* to avoid the circular import that would arise if domain modules imported request from here
* while this file re-exported from those same domain modules.
*
* All 35+ consumer files continue using one of:
* import * as api from '...api/client.js' — namespace pattern
* import { funcName } from '...api/client.js' — named import pattern
* without any changes.
*/
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'
After writing the new file contents, run `cd frontend && npm test` to confirm the test suite passes. The frontend tests use the `import { ... } from '../api/client.js'` and `import * as api from '../api/client.js'` patterns — both must resolve every previously-exported name through the barrel. If any test fails with `TypeError: ... is not a function` or similar, the symptom is a missing or misspelled export in one of the domain modules — diagnose by re-checking the function-to-domain map.
After tests pass, smoke the dev build: `cd frontend && npm run build` (Vite production build). Vite will fail loudly if `export * from` produces ambiguous names or unresolved modules. If the build fails, fix and rerun.
</action>
<verify>
<automated>cd frontend && wc -l src/api/client.js && npm test 2>&1 | tail -20 && npm run build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- `wc -l < frontend/src/api/client.js` returns less than 25 (was 635; barrel is ~15-20 lines)
- `grep -c "^export \\* from " frontend/src/api/client.js` returns 7
- `grep -c "export { fetchWithRetry, request } from './utils.js'" frontend/src/api/client.js` returns 1
- `grep -c "async function request" frontend/src/api/client.js` returns 0 (request moved to utils.js)
- `grep -c "noRefreshPaths" frontend/src/api/client.js` returns 0 (lived inside request, now in utils.js)
- `cd frontend && npm test` exits 0
- `cd frontend && npm run build` exits 0
- `grep -rn "from '../api/client.js'\\|from '../../api/client.js'\\|from '../../../api/client.js'" frontend/src/stores/ frontend/src/components/ frontend/src/views/ 2>/dev/null | wc -l` returns same count as before this plan (35+ files; none modified)
- No file under `frontend/src/stores/`, `frontend/src/components/`, or `frontend/src/views/` is listed in git diff for this plan
</acceptance_criteria>
<done>client.js is a thin barrel; frontend tests pass; production build succeeds; zero consumer files modified.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → backend API | All requests carry Bearer token from authStore memory (never localStorage per CLAUDE.md) |
| Domain module → utils.js request | In-process import; no untrusted data crosses |
| fetchWithRetry → authStore.refresh on 401 | Refresh flow uses httpOnly cookie; on failure the in-memory access token is cleared |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-07-01 | Spoofing | Bearer token injection | mitigate | `request` body copied verbatim from client.js — preserves `if (authStore.accessToken) headers['Authorization'] = ...` and lazy import of authStore |
| T-08-07-02 | Tampering | Circular import producing undefined exports | mitigate | `request` lives in utils.js; client.js never re-exports request from itself; domain modules import from utils.js (PATTERNS.md §"Frontend domain module import line") |
| T-08-07-03 | Repudiation | 401-refresh-retry loop | mitigate | `_retry` flag is preserved in both `request` and `fetchWithRetry`; recursion is bounded |
| T-08-07-04 | Information Disclosure | Token stored in JS storage | mitigate | CLAUDE.md rule preserved: token from `authStore.accessToken` (memory only), never read from localStorage/sessionStorage |
| T-08-07-05 | Tampering | `export *` name collision | mitigate | RESEARCH.md §Pitfall 5 verified all current client.js function names are unique; acceptance criterion runs frontend tests + production build which would fail on collision |
| T-08-07-06 | Denial of Service | Consumer files break | mitigate | Barrel re-export preserves every named export; acceptance criterion verifies zero consumer files in stores/components/views were modified |
| T-08-07-SC | Supply Chain | No new npm packages | accept | This plan installs zero new packages |
</threat_model>
<verification>
- `cd frontend && npm test` — zero failures
- `cd frontend && npm run build` — succeeds
- `wc -l frontend/src/api/client.js` — under 25 lines
- `grep -rn "import.*from '../api/client.js'\\|import.*from '../../api/client.js'\\|import.*from '../../../api/client.js'" frontend/src/stores/ frontend/src/components/ frontend/src/views/` returns the same line count as before this plan
- `git diff --stat HEAD frontend/src/stores/ frontend/src/components/ frontend/src/views/` returns no entries
</verification>
<success_criteria>
- 8 new files in `frontend/src/api/` (utils.js + 7 domain modules)
- client.js reduced to ~15 lines
- Frontend tests + production build pass
- Zero consumer files modified (the entire point of the barrel pattern)
- fetchWithRetry consolidates 3 blob-download patterns (CODE-08)
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-07-SUMMARY.md` when done. Include: (a) line counts before/after for client.js and the 8 new files, (b) list of all consumer files (stores + components + views) confirmed untouched, (c) confirmation that the 3 old blob-download _retry boilerplate blocks were consolidated into the single `fetchWithRetry` helper.
</output>
@@ -0,0 +1,255 @@
---
phase: 08-stack-upgrade-backend-decomposition
plan: 08
type: execute
wave: 3
depends_on: [08-04, 08-05, 08-06, 08-07]
files_modified:
- frontend/package.json
- frontend/package-lock.json
- frontend/tailwind.config.js
- backend/requirements.txt
autonomous: false
requirements: [PERF-01]
tags: [dependencies, perf, requirements-pinning, vite-6, tailwind-forms]
user_setup:
- service: npm-registry-verification
why: "Two PERF-01 packages are install-only verified; the rest must be manually verified on npmjs.com because slopcheck was unavailable at research time"
dashboard_config:
- task: "Verify @vueuse/core, @vueuse/integrations, sortablejs, @tailwindcss/forms, rollup-plugin-visualizer, @types/sortablejs, vite, @vitejs/plugin-vue on npmjs.com — each must show legitimate maintainer, weekly downloads > 10k, and no recent malware advisories"
location: "npmjs.com/package/{name}"
must_haves:
truths:
- "frontend/package.json declares all PERF-01 packages at the locked version constraints"
- "frontend/tailwind.config.js wires the @tailwindcss/forms plugin so VISUAL-02 in Phase 11 can rely on its base styles"
- "frontend/vite.config.js works with vite@^6.4.3 (no breaking changes apply per RESEARCH.md analysis)"
- "backend/requirements.txt uses exact == pins for every package (no floating >= ranges)"
- "cd backend && pytest -v exits 0 (no regression from pinning)"
- "cd frontend && npm install completes cleanly with no peer-dep warnings on vite ↔ @vitejs/plugin-vue"
- "cd frontend && npm run build succeeds with the new Vite 6"
artifacts:
- path: "frontend/package.json"
provides: "Updated dependency manifest with PERF-01 packages"
contains: "@vueuse/core"
- path: "frontend/tailwind.config.js"
provides: "Tailwind config with @tailwindcss/forms plugin wired"
contains: "@tailwindcss/forms"
- path: "backend/requirements.txt"
provides: "Exact-pinned backend dependency list per D-17"
contains: "fastapi=="
key_links:
- from: "frontend/tailwind.config.js"
to: "@tailwindcss/forms"
via: "plugins array"
pattern: "plugins:.*forms"
- from: "frontend/package.json"
to: "vite, @vitejs/plugin-vue, @vueuse/core, sortablejs, @tailwindcss/forms, rollup-plugin-visualizer"
via: "dependencies / devDependencies"
pattern: "(vite|@vueuse|sortablejs|@tailwindcss/forms|rollup-plugin-visualizer)"
---
<objective>
Land PERF-01: bump Vite 5 → 6 (and its plugin), install the new frontend packages (`@vueuse/core`, `@vueuse/integrations`, `sortablejs`, `@tailwindcss/forms`, `rollup-plugin-visualizer`, `@types/sortablejs`), wire `@tailwindcss/forms` into `tailwind.config.js`, and pin `backend/requirements.txt` from floating `>=` ranges to exact `==` pins per D-17. Includes a blocking human checkpoint to manually verify the 8 npm packages on npmjs.com because slopcheck was unavailable at research time.
Purpose: PERF-01 plus the reproducibility benefit of exact pinning. Tailwind plugin wiring is required NOW so Phase 11's VISUAL-02 can rely on `@tailwindcss/forms` base styles being active.
Output: Updated `package.json`, `package-lock.json`, `tailwind.config.js`, and `requirements.txt`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md
@.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@frontend/package.json
@frontend/vite.config.js
@frontend/tailwind.config.js
@backend/requirements.txt
<interfaces>
PERF-01 package set (locked):
dependencies (production):
vue ^3.5.0 (currently 3.5.34 — already satisfies; no install needed but bump declared range)
@vueuse/core ^14.3.0 (install)
@vueuse/integrations ^14.3.0 (install)
sortablejs ^1.15.7 (install)
@tailwindcss/forms ^0.5.11 (install + wire in tailwind.config.js)
devDependencies:
vite ^6.4.3 (upgrade from ^5.2.0)
@vitejs/plugin-vue ^6.0.7 (upgrade from ^5.0.0 — required for Vite 6 compatibility)
rollup-plugin-visualizer ^7.0.1 (install — Phase 11 wires it into vite.config.js for PERF-02 bundle analysis)
@types/sortablejs latest (install — TypeScript types for sortablejs)
tailwind.config.js wiring (per @tailwindcss/forms docs):
/** @type {import('tailwindcss').Config} */
import forms from '@tailwindcss/forms'
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: { extend: {} },
plugins: [forms],
}
backend/requirements.txt: convert every `>=` constraint to `==` using the currently installed version per D-17. RESEARCH.md confirmed installed versions for: fastapi==0.128.8, anthropic==0.104.0, sqlalchemy[asyncio]==2.0.49, psycopg[binary]==3.2.13, alembic==1.16.5 (note: lower than the previous floating min of 1.18.4 — pin to INSTALLED per D-17), celery[redis]==5.6.3, PyJWT==2.13.0, cryptography==48.0.0, structlog==25.5.0. All other packages tagged [ASSUMED] in RESEARCH.md — discover their installed version via `pip show <pkg>` during execution.
</interfaces>
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 1: Package Legitimacy Checkpoint — manually verify 8 npm packages on npmjs.com</name>
<what-built>
The researcher tagged all 8 PERF-01 npm packages as [ASSUMED] because slopcheck was unavailable in the research environment. Before any install runs, manually verify on npmjs.com that each package is legitimate.
</what-built>
<how-to-verify>
For each package below, open the link, check (a) maintainer is a known org or individual not a fresh account, (b) weekly downloads &gt; 10,000, (c) no malware advisory banner, (d) most recent publish date is reasonable (not "1 day ago" for an established lib), (e) the GitHub repo linked in "Repository" exists and is active:
1. https://www.npmjs.com/package/vite — expect: vitejs maintainer org; millions of weekly downloads
2. https://www.npmjs.com/package/@vitejs/plugin-vue — expect: vitejs org; millions of weekly downloads
3. https://www.npmjs.com/package/@vueuse/core — expect: antfu/vueuse maintainer; millions of weekly downloads
4. https://www.npmjs.com/package/@vueuse/integrations — expect: antfu/vueuse maintainer
5. https://www.npmjs.com/package/sortablejs — expect: SortableJS org; millions of weekly downloads
6. https://www.npmjs.com/package/@tailwindcss/forms — expect: tailwindlabs org
7. https://www.npmjs.com/package/rollup-plugin-visualizer — expect: btd/btmurrell maintainer; hundreds of thousands weekly
8. https://www.npmjs.com/package/@types/sortablejs — expect: DefinitelyTyped org
If any package fails any check, ABORT and re-evaluate the package choice with the project owner.
</how-to-verify>
<resume-signal>Type "approved" to proceed with installs, or list any rejected package by name.</resume-signal>
</task>
<task type="auto">
<name>Task 2: Install PERF-01 frontend packages and wire @tailwindcss/forms</name>
<files>frontend/package.json, frontend/package-lock.json, frontend/tailwind.config.js</files>
<read_first>
- frontend/package.json (current dependency versions)
- frontend/tailwind.config.js (currently `plugins: []` with no extensions)
- frontend/vite.config.js (confirm it has no custom `resolve.conditions` — RESEARCH.md verified Vite 6 migration is clean for this minimal config)
</read_first>
<action>
Run two npm install commands in `frontend/`:
cd frontend && npm install vue@^3.5.0 @vueuse/core@^14.3.0 @vueuse/integrations@^14.3.0 sortablejs@^1.15.7 @tailwindcss/forms@^0.5.11
cd frontend && npm install -D vite@^6.4.3 @vitejs/plugin-vue@^6.0.7 rollup-plugin-visualizer@^7.0.1 @types/sortablejs
These two commands update both `package.json` (declared ranges) and `package-lock.json` (resolved exact versions). Do NOT install `vue@^3.5.0` separately if npm warns the existing 3.5.34 satisfies the new range — the bump-then-deduplicate behavior is automatic.
After npm install completes, modify `frontend/tailwind.config.js` to wire the forms plugin. The CURRENT file uses ESM with `export default`. Update it to:
/** @type {import('tailwindcss').Config} */
import forms from '@tailwindcss/forms'
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: { extend: {} },
plugins: [forms],
}
Do NOT modify `frontend/vite.config.js` — RESEARCH.md verified the existing config is compatible with Vite 6 (no `resolve.conditions`, no Sass, no library mode). `rollup-plugin-visualizer` is installed but NOT wired into `vite.config.js` yet — Phase 11's PERF-02 will wire it when bundle measurements are taken.
After config wiring: run `cd frontend && npm test` to confirm tests still pass on Vite 6, then `cd frontend && npm run build` to confirm a clean production build. If either fails, diagnose and fix before proceeding.
</action>
<verify>
<automated>cd frontend && grep -c '"vite": "\\^6' package.json && grep -c '"@vitejs/plugin-vue": "\\^6' package.json && grep -c '"@vueuse/core": "\\^14' package.json && grep -c '"sortablejs"' package.json && grep -c '"@tailwindcss/forms"' package.json && grep -c '"rollup-plugin-visualizer"' package.json && grep -c "@tailwindcss/forms" tailwind.config.js && grep -c "plugins: \\[forms\\]" tailwind.config.js && npm test 2>&1 | tail -5 && npm run build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `grep -c '"vite": "\^6' frontend/package.json` returns 1
- `grep -c '"@vitejs/plugin-vue": "\^6' frontend/package.json` returns 1
- `grep -c '"@vueuse/core": "\^14' frontend/package.json` returns 1
- `grep -c '"@vueuse/integrations": "\^14' frontend/package.json` returns 1
- `grep -c '"sortablejs": "\^1.15' frontend/package.json` returns 1
- `grep -c '"@tailwindcss/forms": "\^0.5' frontend/package.json` returns 1
- `grep -c '"rollup-plugin-visualizer": "\^7' frontend/package.json` returns 1
- `grep -c '"@types/sortablejs"' frontend/package.json` returns 1
- `grep -c "import forms from '@tailwindcss/forms'" frontend/tailwind.config.js` returns 1
- `grep -c "plugins: \[forms\]" frontend/tailwind.config.js` returns 1
- `cd frontend && npm test` exits 0
- `cd frontend && npm run build` exits 0
- `cd frontend && npm list vite | grep -c "vite@6"` returns at least 1
</acceptance_criteria>
<done>All PERF-01 packages installed; tailwind forms plugin wired; tests + production build pass on Vite 6.</done>
</task>
<task type="auto">
<name>Task 3: Pin backend/requirements.txt to exact installed versions (D-17)</name>
<files>backend/requirements.txt</files>
<read_first>
- backend/requirements.txt (current floating-range constraints)
- .planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md §"requirements.txt: Exact Pinning (D-17)" (already-verified versions: fastapi==0.128.8, anthropic==0.104.0, sqlalchemy[asyncio]==2.0.49, psycopg[binary]==3.2.13, alembic==1.16.5, celery[redis]==5.6.3, PyJWT==2.13.0, cryptography==48.0.0, structlog==25.5.0)
</read_first>
<action>
For each package in `backend/requirements.txt` that is tagged [ASSUMED] in RESEARCH.md (uvicorn, python-multipart, pydantic-settings, pydantic, openai, PyMuPDF, python-docx, pytesseract, Pillow, aiofiles, httpx, pytest, pytest-asyncio, minio, redis, aiosqlite, pwdlib, pyotp, slowapi, google-auth-oauthlib, google-api-python-client, msal, webdavclient3, cachetools), run `cd backend && pip show <pkg> 2>/dev/null | grep '^Version:'` to capture the installed version. If a package returns no output (not installed in the local env), run `cd backend && python -c "import importlib.metadata as m; print(m.version('<pkg>'))"` as a fallback. Record each version.
Then rewrite `backend/requirements.txt` so every line uses an exact `==` pin. Preserve the existing section comments (e.g. "# Cloud Storage Backends (Phase 5)", "# Observability (Phase 6 — D-01)"). For the already-verified packages from RESEARCH.md, use the locked versions: fastapi==0.128.8, anthropic==0.104.0, sqlalchemy[asyncio]==2.0.49, psycopg[binary]==3.2.13, alembic==1.16.5, celery[redis]==5.6.3, PyJWT==2.13.0, cryptography==48.0.0, structlog==25.5.0. For everything else, use the version captured by `pip show`. If `alembic` is shown as 1.16.5 (lower than the previous floating min of 1.18.4), pin to 1.16.5 per D-17 — but also append a single-line comment immediately before the alembic line: `# alembic pinned to currently-installed 1.16.5 (was >=1.18.4); D-17 mandates pinning to installed`.
After rewriting, run `cd backend && pip install -r requirements.txt --dry-run 2>&1 | tail -20` to confirm pip considers the file valid and all pins are satisfiable. Then run `cd backend && pytest -v` to confirm no regression from re-resolving deps.
</action>
<verify>
<automated>cd backend && grep -cE '>=' requirements.txt; cd backend && grep -cE '==' requirements.txt; cd backend && pip install -r requirements.txt --dry-run 2>&1 | tail -5; cd backend && pytest -v --tb=no -q 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `grep -c '^[^#].*>=' backend/requirements.txt` returns 0 (every non-comment line uses == or is bare)
- `grep -cE '^[a-zA-Z][a-zA-Z0-9_.\\[\\]-]*==' backend/requirements.txt` returns at least 30 (count of pinned packages — file has ~32 packages)
- `grep -c "^fastapi==0.128.8" backend/requirements.txt` returns 1
- `grep -c "^alembic==1.16.5" backend/requirements.txt` returns 1
- `grep -c "^sqlalchemy\\[asyncio\\]==2.0.49" backend/requirements.txt` returns 1
- `grep -c "^PyJWT==2.13.0" backend/requirements.txt` returns 1
- `grep -c "^cryptography==48.0.0" backend/requirements.txt` returns 1
- `grep -c "alembic pinned to currently-installed" backend/requirements.txt` returns 1
- `cd backend && pytest -v` exits 0 with no new failures vs. baseline
- `cd backend && pip install -r requirements.txt --dry-run` reports no resolution conflicts (exit 0)
</acceptance_criteria>
<done>requirements.txt fully pinned with == ; alembic discrepancy commented; pytest still green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| npmjs.com → frontend node_modules | Supply chain: any of the 8 packages could be malicious if a name was typosquatted or a maintainer account was compromised |
| pypi.org → backend venv | Same supply-chain consideration for any unpinned package; D-17 pinning narrows the attack window |
| Vite 6 build pipeline → bundled JS | Major version bump may introduce regressions; npm test + production build verify integrity |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-08-01 | Supply Chain (Tampering) | 8 npm package installs | mitigate | Blocking human checkpoint (task 1) requires manual npmjs.com verification because slopcheck was unavailable; gate flagged blocking-human and never auto-approvable. T-{phase}-SC retained per Package Legitimacy Gate protocol. |
| T-08-08-02 | Tampering | Vite 6 migration breaking behavior | mitigate | RESEARCH.md analyzed all Vite 5→6 breaking changes against the project's `vite.config.js` and confirmed none apply (no custom `resolve.conditions`, no Sass, no library mode); npm test + npm run build acceptance criteria verify post-install. |
| T-08-08-03 | Information Disclosure | requirements.txt leaks installed versions | accept | Versions are not secrets; reproducibility benefit outweighs disclosure |
| T-08-08-04 | Denial of Service | alembic 1.16.5 lower than 1.18.4 | mitigate | D-17 mandates pinning to installed; the inline comment documents the discrepancy so a future developer can decide to bump deliberately |
| T-08-08-05 | Tampering | @vitejs/plugin-vue peer-dep mismatch | mitigate | Both vite@^6 and @vitejs/plugin-vue@^6 installed in same `npm install -D` command per RESEARCH.md §Pitfall 6 |
| T-08-08-SC | Supply Chain | npm packages [ASSUMED] | mitigate | Blocking human checkpoint (task 1) is the gate; checkpoint is blocking-human and cannot be auto-approved |
</threat_model>
<verification>
- `cd frontend && npm test` — zero failures
- `cd frontend && npm run build` — succeeds with Vite 6
- `cd frontend && npm list vite @vitejs/plugin-vue @vueuse/core @vueuse/integrations sortablejs @tailwindcss/forms rollup-plugin-visualizer @types/sortablejs` — all listed
- `cd backend && pytest -v` — zero failures
- `cd backend && pip install -r requirements.txt --dry-run` — no resolution errors
- `grep -v '^#' backend/requirements.txt | grep -cE '>='` returns 0 (no remaining floating constraints in production-package lines)
</verification>
<success_criteria>
- 8 PERF-01 packages installed and verified
- `@tailwindcss/forms` wired in tailwind.config.js (ready for Phase 11 VISUAL-02)
- Vite 6 production build succeeds
- backend/requirements.txt fully `==` pinned per D-17
- All tests pass
- Human checkpoint approved on npm package legitimacy
</success_criteria>
<output>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-08-SUMMARY.md` when done. Include: (a) before/after `package.json` dependency table, (b) full pinned `requirements.txt` content, (c) the human checkpoint approval timestamp and any package that required follow-up.
</output>
@@ -0,0 +1,133 @@
# Phase 8: Stack Upgrade & Backend Decomposition — Context
**Gathered:** 2026-06-07
**Status:** Ready for planning
<domain>
## Phase Boundary
Phase 8 delivers: (1) Phase 7.1 session-revocation work completed first as Wave 1, (2) three backend router monoliths split into focused sub-packages (`api/admin/`, `api/documents/`, `api/auth/`), (3) the frontend API client decomposed into domain modules behind a re-export barrel, (4) shared Pydantic schemas extracted to `api/schemas.py`, (5) router-defined validators migrated to `services/`, (6) `requirements.txt` pinned to exact versions, and (7) PERF-01 frontend dependencies installed. Zero URL changes, zero behavior changes — invisible to consumers and tests except for Phase 7.1's new session-revocation behavior.
</domain>
<decisions>
## Implementation Decisions
### Phase 7.1 — Session Revocation (Wave 1, before decomposition)
- **D-01:** Phase 7.1 is absorbed into Phase 8 as the first wave. It must complete (backend + tests + frontend) before the decomposition work begins.
- **D-02:** Phase 7.1 scope: `revoke_all_refresh_tokens()` gets a `skip_token_hash` param; the function is wired into `change_password`, `enable_totp`, and `disable_totp` with a `sessions_revoked` field in each response shape; audit log entries written for each revocation.
- **D-03:** Frontend toast implementation: create an empty `useToastStore` Pinia store stub during Phase 7.1. Phase 7.1 components (`SettingsAccountTab.vue`, `TotpEnrollment.vue`) call `toastStore.show(...)` when `sessions_revoked > 0`. Phase 10 fills in the full toast implementation — Phase 7.1 only creates the stub and wires the call sites.
### Backend Router Decomposition
- **D-04:** Sub-routers in all three new packages MUST have NO prefix on `APIRouter()`. The parent prefix registered in `main.py` propagates. Any sub-router prefix causes doubled URL segments (confirmed pitfall from v0.1).
- **D-05:** `api/admin/` split: `users.py`, `quotas.py`, `ai.py` — exact names already locked by ROADMAP.
- **D-06:** `api/documents/` split: 4 sub-modules matching the ROADMAP's 4 functional areas (upload flow, content proxy, document CRUD, search/listing). Exact file names are researcher/planner's choice based on reading the actual code.
- **D-07:** `api/auth/` split: 4 sub-modules (login/tokens, TOTP, password management, session management). Module names must mirror the logical groupings found in `services/auth.py` for navigability. The JTI revocation (7.2), ES256 key handling (7.3), and fgp fingerprint validation (7.4) code goes wherever the researcher determines it fits cleanest within those groupings.
- **D-08:** Re-classify endpoint (`POST /api/documents/{id}/classify`) placement within `api/documents/` is researcher/planner's choice.
### Shared Pydantic Schemas (CODE-08)
- **D-09:** Pydantic models shared within a single package → `shared.py` inside that package.
- **D-10:** Pydantic models referenced by 2+ packages (e.g., a `UserOut` used by both `api/admin/` and `api/auth/`) → `backend/api/schemas.py` (new top-level schemas module). This eliminates circular imports between packages.
- **D-11:** Any validators currently defined inline in router files (rather than in `services/`) must be migrated to the appropriate `services/` module during the split. This enforces the CLAUDE.md rule that the service layer raises `ValueError`, and API files never define validators. Researcher identifies which validators qualify.
### Frontend API Client Decomposition (CODE-04)
- **D-12:** `client.js` becomes HTTP transport layer + re-export barrel only. `request()` and `noRefreshPaths` stay in `client.js`. Zero changes to any of the 35+ consumer files.
- **D-13:** `fetchWithRetry()` (consolidating the 3 blob-download 401-retry duplicates) lives in a new `frontend/src/api/utils.js`. `client.js` re-exports it.
- **D-14:** Domain sub-modules: `documents.js`, `auth.js`, `admin.js`, `folders.js`, `shares.js`, `cloud.js`, `topics.js`. Each function lives in exactly one domain file (researcher picks the most specific domain; no duplication). Researcher maps all consumer import sites to determine grouping.
### Frontend Dependencies (PERF-01)
- **D-15:** Vite 5→6 is a major version bump requiring research before execution. Researcher must check the Vite 6 migration guide against the existing `frontend/vite.config.js` and identify any required config changes before the bump is applied.
- **D-16:** For the remaining PERF-01 packages (`@vueuse/core@^14`, `@vueuse/integrations@^14`, `sortablejs`, `@tailwindcss/forms`, `rollup-plugin-visualizer`, dev type packages): researcher determines which packages require config wiring in `tailwind.config.js` or `vite.config.js` vs which are install-only.
### Backend Dependency Pinning
- **D-17:** Backend dep version bumping (FastAPI, SQLAlchemy, etc.) is out of scope for Phase 8. However, `requirements.txt` must be converted from floating `>=` ranges to exact `==` pins for reproducible builds. Pin the currently-installed versions (no version changes).
### Claude's Discretion
- Exact file names for `api/documents/` sub-modules (researcher reads `api/documents.py` and chooses names that reflect the actual endpoint groupings)
- Exact file names for `api/auth/` sub-modules (researcher mirrors `services/auth.py` logical groupings)
- Re-classify endpoint placement within `api/documents/`
- Which validators in router files qualify for migration to `services/`
- Domain grouping of each `client.js` function (researcher maps all 35+ consumer import sites)
- Which PERF-01 packages need config wiring vs install-only
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase Goals and Requirements
- `.planning/ROADMAP.md` §"Phase 8: Stack Upgrade & Backend Decomposition" — goal, implementation notes, success criteria, PITFALLS
- `.planning/REQUIREMENTS.md` §CODE-01, CODE-02, CODE-03, CODE-04, CODE-08, PERF-01 — formal requirement definitions
### Phase 7.1 Scope (absorbed into this phase)
- `.planning/ROADMAP.md` §"Phase 7.1: Security: session revocation on privilege change (CR-01..03)" — the two plan descriptions; requirements CR-01, CR-02, CR-03
### Backend Architecture and Conventions
- `.planning/codebase/ARCHITECTURE.md` — component responsibilities, layer boundaries, data flow; identifies what lives in `api/` vs `services/`
- `.planning/codebase/CONVENTIONS.md` — import organization, naming patterns, service-vs-API error handling separation; the rule that validators live in `services/`
- `CLAUDE.md` §"Backend: shared module map" — `deps/utils.py`, `storage/exceptions.py`, `ai/utils.py`, `services/auth.py` shared module rules (must not be violated during split)
- `CLAUDE.md` §"Key Architectural Rules" — ownership checks, atomic quota pattern, JWT memory-only rules (must be preserved through refactor)
### Frontend Architecture
- `.planning/codebase/ARCHITECTURE.md` §"Frontend Store Layer" — stores own data-fetching, views delegate to stores
- `CLAUDE.md` §"Frontend: shared module map" — `utils/formatters.js`, `TreeItem.vue`, `StorageBrowser.vue` rules
- `CLAUDE.md` §"Component architecture" — View → Smart component → Presentational component layering
### Stack Details
- `.planning/codebase/STACK.md` — current package versions (for pinning to exact == in requirements.txt)
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `backend/api/admin.py` (934L), `backend/api/documents.py` (852L), `backend/api/auth.py` (825L) — the three monoliths being decomposed; researcher must read these to determine sub-module boundaries
- `frontend/src/api/client.js` (635L) — the frontend client being decomposed; researcher must read all import sites
- `backend/services/auth.py` — structure mirrors how `api/auth/` sub-modules should be named
### Established Patterns
- `backend/api/admin.py` already contains AI config endpoints added in Phase 7 — `api/admin/ai.py` absorbs this
- `backend/main.py` router registration pattern: `app.include_router(router, prefix="/api/...")` — each new package `__init__.py` must aggregate sub-routers and export a single `router` object
- `asyncio.run()` bridge in Celery tasks — never import from router modules; this constraint applies to the new sub-packages too
- Circular import constraint: `backend/celery_app.py` intentionally avoids importing `config`; new sub-packages must not create import cycles
### Integration Points
- `backend/main.py` — only file that registers routers; must be updated to import from new package `__init__.py` files
- `frontend/src/api/client.js` — must re-export every function from domain sub-modules; no consumer file changes
- `backend/deps/auth.py``get_current_user`, `get_current_admin`, `get_regular_user` imported by all router sub-modules; dependency imports must work from new sub-package paths
- `frontend/src/stores/` — all 5 stores import from `api/client.js`; barrel re-export must preserve all named exports
</code_context>
<specifics>
## Specific Ideas
- Wave ordering: Phase 7.1 completion → backend decomposition (CODE-01/02/03 in parallel) + CODE-04 (independent) → requirements.txt pinning + PERF-01 deps
- The `useToastStore` stub created in Phase 7.1 is a deliberate forward-reference contract: Phase 10's toast implementation must honor the same `show()` API shape
- `backend/api/schemas.py` is a NEW file — it doesn't exist yet; researcher should identify exact cross-package model candidates before planning
</specifics>
<deferred>
## Deferred Ideas
- Backend package version bumps (FastAPI to 0.136+, SQLAlchemy, PyJWT, etc.) — deferred; only exact-pin the current versions
- Composition API migration (Vue components) — explicitly out of scope for all v0.2 phases per PROJECT.md decision
- Virtual scrolling, dark mode, folder reordering, multi-select batch ops — already in REQUIREMENTS.md "Future" section
</deferred>
---
*Phase: 8-Stack-Upgrade-Backend-Decomposition*
*Context gathered: 2026-06-07*
@@ -0,0 +1,177 @@
# Phase 8: Stack Upgrade & Backend Decomposition — Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-07
**Phase:** 8-Stack-Upgrade-Backend-Decomposition
**Areas discussed:** documents.py split structure, auth.py split structure, Phase 7.1 deferred gap, Vite 5→6 migration scope, client.js domain grouping, Phase 7.1 toast implementation, Backend dep bumps, CODE-08 shared.py scope
---
## documents.py split structure
| Option | Description | Selected |
|--------|-------------|----------|
| Merge search with CRUD → 3 files | upload.py, proxy.py, documents.py (list+search merged) | |
| Keep 4 files as described | 4 sub-modules matching ROADMAP's functional areas | ✓ |
**User's choice:** Keep 4 files as described
| Option | Description | Selected |
|--------|-------------|----------|
| Into CRUD / documents.py | Re-classify operates on a single document by ID | |
| Into upload.py | Re-triggers the Celery pipeline, adjacent to confirm | |
| You decide | Researcher/planner places it based on code | ✓ |
**User's choice:** Researcher decides re-classify placement
| Option | Description | Selected |
|--------|-------------|----------|
| upload.py, proxy.py, crud.py, listing.py | Explicit names for 4 areas | |
| upload.py, content.py, documents.py, search.py | Alternative naming | |
| You decide | Researcher picks names based on actual code | ✓ |
**User's choice:** Researcher picks file names
---
## auth.py split structure
| Option | Description | Selected |
|--------|-------------|----------|
| Keep 4 files, JTI/ES256/fgp fold into tokens.py | All token-security concerns together | |
| Keep 4 files as originally described | JTI/ES256/fgp placed by researcher | |
| You decide | Researcher reads actual code and picks cleanest split | ✓ |
**User's choice:** Researcher decides auth split
| Option | Description | Selected |
|--------|-------------|----------|
| Independent — router split is about HTTP grouping | api/auth/ groups by HTTP endpoint families | |
| Aligned — mirror services structure in API split | Module names match services/auth.py logical groupings | ✓ |
**User's choice:** api/auth/ sub-module names mirror services/auth.py logical groupings
---
## Phase 7.1 deferred gap
| Option | Description | Selected |
|--------|-------------|----------|
| Intentionally deferred — keep it separate | Phase 8 proceeds as pure decomposition/upgrade | |
| Include in Phase 8 | Phase 8 absorbs the 2 Phase 7.1 plans before decomposition | ✓ |
**User's choice:** Include Phase 7.1 in Phase 8
| Option | Description | Selected |
|--------|-------------|----------|
| First wave — do 7.1 before the refactor | 7.1 completes first, then decomposition follows | ✓ |
| Separate plan within Phase 8 | 7.1 as a dedicated plan that can run independently | |
**User's choice:** Phase 7.1 as Wave 1, decomposition follows
---
## Vite 5→6 migration scope
| Option | Description | Selected |
|--------|-------------|----------|
| Assume it just works — bump and verify | Bump, run dev+build, fix anything that breaks | |
| Research first — there may be config changes | Check Vite 6 migration guide against existing vite.config.js | ✓ |
**User's choice:** Research migration guide against vite.config.js before bumping
| Option | Description | Selected |
|--------|-------------|----------|
| All install-only for now | No integration in Phase 8 | |
| @tailwindcss/forms needs tailwind.config.js wiring | Must register plugin even without styles | |
| You decide | Researcher determines which need config wiring | ✓ |
**User's choice:** Researcher determines PERF-01 package integration requirements
---
## client.js domain grouping
| Option | Description | Selected |
|--------|-------------|----------|
| Most specific domain file only | Every function in exactly one file, no duplication | |
| You decide | Researcher maps all 35+ consumer imports | ✓ |
**User's choice:** Researcher maps imports and picks grouping
| Option | Description | Selected |
|--------|-------------|----------|
| In client.js alongside request() | Both transport helpers in same file | |
| New api/utils.js | fetchWithRetry in a separate utility module | ✓ |
**User's choice:** fetchWithRetry → new `frontend/src/api/utils.js`
---
## Phase 7.1 toast implementation
| Option | Description | Selected |
|--------|-------------|----------|
| Simple local implementation | Local reactive message + v-if in the two components | |
| Stub the global store early | Create empty useToastStore now; Phase 10 fills it in | ✓ |
| Skip the toast for now | Backend + tests only; toast waits for Phase 10 | |
**User's choice:** Create `useToastStore` stub in Phase 7.1; Phase 10 implements it
**Notes:** The stub establishes the `show()` API contract that Phase 10 must honor.
---
## Backend dep bumps
| Option | Description | Selected |
|--------|-------------|----------|
| Include backend bumps in Phase 8 | Bump FastAPI, SQLAlchemy, PyJWT + pip-audit | |
| Backend deps out of scope | PERF-01 is frontend-only; backend version management is separate | ✓ |
**User's choice:** Backend dep bumps out of scope for Phase 8
| Option | Description | Selected |
|--------|-------------|----------|
| Pin to exact versions | Convert >= to == for reproducible builds | ✓ |
| Leave floating for now | Only fix if bumping is in scope | |
**User's choice:** Pin current versions to == (no version changes, just reproducibility)
---
## CODE-08 shared.py scope
| Option | Description | Selected |
|--------|-------------|----------|
| Cross-package models → top-level api/schemas.py | New backend/api/schemas.py for models used by 2+ packages | ✓ |
| Duplicate across packages OK for now | Independent definition in each package's shared.py | |
| You decide | Researcher identifies actual cross-package overlap | |
**User's choice:** New `backend/api/schemas.py` for cross-package models; per-package `shared.py` for intra-package sharing
| Option | Description | Selected |
|--------|-------------|----------|
| Move any router-defined validators to services/ during split | Enforce CLAUDE.md rule during the split | ✓ |
| Split only — no validator migrations | Structural changes only | |
**User's choice:** Router-defined validators migrated to services/ during the split
---
## Claude's Discretion
- Exact file names for `api/documents/` sub-modules
- Exact file names for `api/auth/` sub-modules
- Re-classify endpoint placement within `api/documents/`
- Which validators in router files qualify for migration to `services/`
- Domain grouping of each `client.js` function (full import site mapping needed)
- Which PERF-01 packages require config wiring vs install-only
## Deferred Ideas
- Backend package version bumps (FastAPI to 0.136+, SQLAlchemy, PyJWT) — future phase
- Composition API migration for Vue components — explicitly out of scope for all v0.2 phases
- Virtual scrolling, dark mode, folder reordering, multi-select batch ops — already in REQUIREMENTS.md "Future"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,950 @@
# Phase 8: Stack Upgrade & Backend Decomposition — Research
**Researched:** 2026-06-07
**Domain:** FastAPI router decomposition, Python package decomposition, frontend API client decomposition, Vite 5→6 migration, requirements.txt pinning
**Confidence:** HIGH
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Phase 7.1 — Session Revocation (Wave 1)**
- D-01: Phase 7.1 is absorbed into Phase 8 as the first wave.
- D-02: Phase 7.1 scope: `revoke_all_refresh_tokens()` gets a `skip_token_hash` param; wired into `change_password`, `enable_totp`, and `disable_totp` with `sessions_revoked` field in each response shape; audit log entries written for each revocation.
- D-03: Frontend toast implementation: create an empty `useToastStore` Pinia store stub during Phase 7.1. Phase 7.1 components (`SettingsAccountTab.vue`, `TotpEnrollment.vue`) call `toastStore.show(...)` when `sessions_revoked > 0`. Phase 10 fills in the full toast implementation — Phase 7.1 only creates the stub and wires the call sites.
**Backend Router Decomposition**
- D-04: Sub-routers MUST have NO prefix on `APIRouter()`. Parent prefix propagates. Any sub-router prefix causes doubled URL segments.
- D-05: `api/admin/` split: `users.py`, `quotas.py`, `ai.py` — exact names locked.
- D-06: `api/documents/` split: 4 sub-modules. Exact names are researcher/planner choice.
- D-07: `api/auth/` split: 4 sub-modules (login/tokens, TOTP, password management, session management). Module names must mirror `services/auth.py` logical groupings.
- D-08: Re-classify endpoint placement within `api/documents/` is researcher/planner choice.
**Shared Pydantic Schemas (CODE-08)**
- D-09: Pydantic models shared within a single package → `shared.py` inside that package.
- D-10: Pydantic models referenced by 2+ packages → `backend/api/schemas.py`.
- D-11: Validators currently defined inline in router files must migrate to appropriate `services/` module.
**Frontend API Client Decomposition (CODE-04)**
- D-12: `client.js` becomes HTTP transport layer + re-export barrel. `request()` and `noRefreshPaths` stay in `client.js`. Zero changes to any of the 35+ consumer files.
- D-13: `fetchWithRetry()` (consolidating 3 blob-download 401-retry duplicates) lives in `frontend/src/api/utils.js`. `client.js` re-exports it.
- D-14: Domain sub-modules: `documents.js`, `auth.js`, `admin.js`, `folders.js`, `shares.js`, `cloud.js`, `topics.js`.
**Frontend Dependencies (PERF-01)**
- D-15: Vite 5→6 is a major version bump requiring migration research.
- D-16: Researcher determines which PERF-01 packages need config wiring vs install-only.
**Backend Dependency Pinning**
- D-17: `requirements.txt` must be converted from floating `>=` ranges to exact `==` pins using currently-installed versions. No version changes.
### Claude's Discretion
- Exact file names for `api/documents/` sub-modules
- Exact file names for `api/auth/` sub-modules
- Re-classify endpoint placement within `api/documents/`
- Which validators in router files qualify for migration to `services/`
- Domain grouping of each `client.js` function
- Which PERF-01 packages need config wiring vs install-only
### Deferred Ideas (OUT OF SCOPE)
- Backend package version bumps (FastAPI, SQLAlchemy, etc.) — only exact-pin current versions
- Composition API migration (Vue components)
- Virtual scrolling, dark mode, folder reordering, multi-select batch ops
</user_constraints>
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| CR-01 | `change_password` revokes other sessions, returns `sessions_revoked` | ALREADY IMPLEMENTED in auth.py L518-536 — code exists, tests and toast needed |
| CR-02 | `enable_totp` revokes other sessions, returns `sessions_revoked` | ALREADY IMPLEMENTED in auth.py L613-636 — code exists, tests and toast needed |
| CR-03 | `disable_totp` revokes other sessions, returns `sessions_revoked` | ALREADY IMPLEMENTED in auth.py L659-682 — code exists, tests and toast needed |
| CODE-01 | `api/admin.py` (934L) decomposed into `api/admin/` package | Sub-module boundaries identified below |
| CODE-02 | `api/documents.py` (852L) decomposed into `api/documents/` package | Sub-module boundaries identified below |
| CODE-03 | `api/auth.py` (825L) decomposed into `api/auth/` package | Sub-module boundaries identified below |
| CODE-04 | `frontend/src/api/client.js` (635L) decomposed into domain modules | Function mapping identified below |
| CODE-08 | No duplicated Pydantic models across router files; shared schemas extracted | Cross-package models identified below |
| PERF-01 | Frontend deps bumped/added: vue@^3.5, vite@^6.4.3, @vueuse/core@^14.3, etc. | Migration analysis complete |
</phase_requirements>
---
## Summary
Phase 8 is a structural refactoring phase — zero behavior changes, all URL contracts preserved. The phase absorbs Phase 7.1 (session revocation) as Wave 1 before any decomposition work begins.
**Critical finding on Wave 1 (CR-01, CR-02, CR-03):** Reading `backend/api/auth.py` confirms that all three session-revocation calls (`revoke_all_refresh_tokens` with `skip_token_hash`) are **already implemented** in the codebase at lines 518, 613, and 663. The `sessions_revoked` field is already in all three response shapes. The Wave 1 backend work is complete. What remains for Wave 1 is: (1) tests verifying the revocation behavior, and (2) the `useToastStore` stub + call-site wiring in `SettingsAccountTab.vue` and `TotpEnrollment.vue`. The frontend components already have inline `sessionRevokedToast` refs with 5s auto-dismiss — D-03 says to replace these with `toastStore.show(...)` calls and a stub store.
**Critical finding on `CloudConnectionOut`:** This Pydantic model is defined in `api/admin.py` and imported by `api/cloud.py` at line 35. When `api/admin.py` is split into `api/admin/users.py`, `api/admin/quotas.py`, `api/admin/ai.py`, this import in `cloud.py` must update to the new location. Since `CloudConnectionOut` is used by 2+ packages (admin + cloud), it belongs in `api/schemas.py` per D-10.
**Backend decomposition:** The three monoliths have clear natural boundaries based on reading their full source. Sub-module boundaries are documented below with exact line ranges.
**Frontend decomposition:** 35+ consumer files import from `client.js` using three patterns: `import * as api`, named imports, and lazy `await import()`. The barrel re-export pattern preserves all patterns without touching consumer files.
**Vite 5→6 migration:** For this project's `vite.config.js` (only `plugins`, `build.target`, `server.proxy`), the migration is low-risk. One potentially required change: `resolve.conditions` defaults changed. Since there is no custom `resolve.conditions` in this config, the default behavior change may or may not affect the project — the safest approach is to test after bump before committing.
**Primary recommendation:** Execute in wave order — Wave 1 (CR tests + toast stub), Wave 2 (backend decomposition in parallel, CODE-04 independent), Wave 3 (pinning + PERF-01 deps).
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Router decomposition | API layer (`backend/api/`) | None | Pure structural split; no service layer involvement |
| Shared Pydantic schemas | API layer (`api/schemas.py`) | None | Response models live at API boundary |
| Validators migration | Service layer (`services/`) | None | CLAUDE.md rule: validators raise ValueError, not HTTPException |
| Frontend API decomposition | Frontend API layer (`src/api/`) | Stores (consumers) | Barrel pattern ensures stores see no change |
| `useToastStore` stub | Frontend store layer (`src/stores/`) | Components (callers) | Pinia stores own application state |
| requirements.txt pinning | Infrastructure | None | Reproducibility, no behavior change |
| Vite 6 upgrade | Frontend build | None | Build tooling only; no runtime behavior change |
---
## Wave 1: Session Revocation (CR-01, CR-02, CR-03) — BACKEND ALREADY COMPLETE
### What is already implemented
Reading `backend/api/auth.py` confirms all three revocation calls exist:
**`change_password` (L475-537):**
- `skip_token_hash` computed from `request.cookies.get("refresh_token")` at L516-517
- `revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` at L518
- `sessions_revoked` in response at L537: `return {"message": "Password updated", "sessions_revoked": revoked}`
- Audit log with `metadata_={"sessions_revoked": revoked}` at L526
**`enable_totp` (L579-636):**
- `skip_token_hash` computed at L613-614
- `revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` at L615
- `sessions_revoked` in response at L636: `return {"backup_codes": plain_codes, "sessions_revoked": revoked}`
- Audit log at L619 with `metadata_={"sessions_revoked": revoked}`
**`disable_totp` (L641-682):**
- `skip_token_hash` computed at L660-661
- `revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` at L662
- `sessions_revoked` in response at L682: `return {"message": "TOTP disabled", "sessions_revoked": revoked}`
- Audit log at L664 with `metadata_={"sessions_revoked": revoked}`
`revoke_all_refresh_tokens` signature in `services/auth.py` (L250-273):
```python
async def revoke_all_refresh_tokens(
session: AsyncSession, user_id: uuid.UUID, skip_token_hash: Optional[str] = None
) -> int:
```
The `skip_token_hash` parameter already exists and is already implemented correctly.
### What Wave 1 actually needs to do
1. **Tests only (backend):** Write 3 new tests confirming the revocation behavior:
- `test_change_password_revokes_other_sessions` — change password, verify other refresh tokens revoked, current session preserved
- `test_enable_totp_revokes_other_sessions`
- `test_disable_totp_revokes_other_sessions`
2. **Frontend `useToastStore` stub:** Create `frontend/src/stores/toast.js` with a stub `show()` method (Phase 10 fills in the implementation). The stub must export `useToastStore` and accept the same `show(message, type)` call shape that Phase 10 will implement.
3. **Frontend call-site rewiring:** In `SettingsAccountTab.vue` and `TotpEnrollment.vue`, replace the existing `sessionRevokedToast` ref + `setTimeout` pattern with `toastStore.show("Other sessions have been terminated.", "success")`. The existing inline toast HTML blocks in these two components can be removed since the stub store will handle display in Phase 10.
### Existing inline toast locations
`SettingsAccountTab.vue`:
- Lines 1-25: inline toast HTML (fixed top-right, `v-if="sessionRevokedToast"`)
- Line 211: `const sessionRevokedToast = ref(false)`
- Lines 225-228: `changePassword` sets toast, 5s timeout
- Lines 261-264: `disableTotp` sets toast, 5s timeout
`TotpEnrollment.vue`:
- Lines 1-23: inline toast HTML (inline, not fixed-position)
- `sessionRevokedToast` ref and `setTimeout` in `confirmEnrollment` at line 174-177
**Important:** The existing tests in `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` already test for `sessions_revoked > 0` behavior and expect the toast text "Other sessions have been terminated." — these tests must still pass after the refactor, just via the store rather than the ref.
---
## Backend Router Decomposition
### api/admin.py → api/admin/ package
**Current structure (934L):**
- Response helpers: `_ai_config_to_dict()`, `_user_to_dict()` (L58-91)
- Pydantic models: `UserCreate`, `UserStatusUpdate`, `QuotaUpdate`, `UserAiConfigUpdate`, `SystemAiConfigUpdate`, `TestConnectionRequest`, `SystemTopicCreate`, `UserDeleteConfirm`, `CloudConnectionOut` (L96-223)
- Endpoints by functional area:
| Function | Endpoint | Target sub-module |
|---|---|---|
| `list_users`, `create_user`, `update_user_status`, `initiate_password_reset`, `delete_user` | `/api/admin/users*` | `users.py` |
| `get_user_quota`, `update_user_quota` | `/api/admin/users/{id}/quota` | `quotas.py` |
| `update_ai_config` (per-user) | `/api/admin/users/{id}/ai-config` | `users.py` |
| `create_system_topic` | `/api/admin/topics` | `users.py` (admin utility) or `ai.py` |
| `get_ai_config_models`, `test_ai_connection`, `get_ai_config`, `update_system_ai_config` | `/api/admin/ai-config*` | `ai.py` |
**Recommended sub-module assignment:**
`api/admin/users.py`: `list_users`, `create_user`, `update_user_status`, `initiate_password_reset`, `delete_user`, `update_ai_config` (per-user), `create_system_topic`
`api/admin/quotas.py`: `get_user_quota`, `update_user_quota`
`api/admin/ai.py`: `get_ai_config_models`, `test_ai_connection`, `get_ai_config`, `update_system_ai_config`
**Pydantic model placement:**
- `CloudConnectionOut``api/schemas.py` (used by both `api/admin/` and `api/cloud.py`)
- `UserCreate`, `UserStatusUpdate`, `UserAiConfigUpdate`, `UserDeleteConfirm``api/admin/users.py` (only used in users.py)
- `QuotaUpdate``api/admin/quotas.py` (only used in quotas.py)
- `SystemAiConfigUpdate`, `TestConnectionRequest``api/admin/ai.py` (only used in ai.py)
- `SystemTopicCreate``api/admin/users.py` (only used in create_system_topic)
- `_user_to_dict()` helper → `api/admin/shared.py` (used by users.py and quotas.py both need user info)
- `_ai_config_to_dict()` helper → `api/admin/ai.py` (only used by ai.py)
**Validators that must migrate to services/ (D-11):**
`UserCreate.password_strength` (L102-106): This `@field_validator` calls `validate_password_strength(v)` which already lives in `services/auth.py`. The validator itself is just a thin call-through — it is acceptable to keep it as-is in the Pydantic model since it delegates to the service. No migration needed for this one.
`QuotaUpdate.must_be_positive` (L116-120): This `@field_validator` validates `limit_bytes > 0`. This is a Pydantic input validation rule (belongs at API boundary), NOT business logic. Keep in model per CLAUDE.md: "Pydantic `@field_validator` used for complex field constraints." No migration needed.
`SystemAiConfigUpdate.provider_must_be_known` (L147-155) and `TestConnectionRequest.provider_must_be_known` (L172-179): These validate `provider_id` against `PROVIDER_DEFAULTS`. Since `PROVIDER_DEFAULTS` is an `ai/` layer concern, migrating this check to `services/ai_config.py` as `validate_provider_id(v: str) -> str` (raises `ValueError`) would be cleaner. The `@field_validator` in both models would then call `ai_config_service.validate_provider_id(v)`. This is the one migration that qualifies under D-11 since it's duplicated across two models.
`DocumentPatch.filename_no_path_separators` (documents.py L83-88): Security validator — belongs in the Pydantic model at the API boundary. No migration needed.
**Summary:** Only `provider_must_be_known` in `SystemAiConfigUpdate` + `TestConnectionRequest` qualifies for migration to `services/ai_config.py`. All other validators are appropriate Pydantic field constraints.
### api/documents.py → api/documents/ package
**Current structure (852L):**
- Helper: `_parse_range()` (L744-760) — used only by `stream_document_content`
- Constant: `_CLOUD_PROVIDERS` frozenset (L59)
- Pydantic models: `UploadUrlRequest`, `DocumentPatch` (L65-88)
| Endpoint | HTTP | Target sub-module |
|---|---|---|
| `request_upload_url` | POST /upload-url | `upload.py` |
| `upload_document` | POST /upload | `upload.py` |
| `confirm_upload` | POST /{id}/confirm | `upload.py` |
| `list_documents` | GET / | `crud.py` |
| `get_document` | GET /{id} | `crud.py` |
| `patch_document` | PATCH /{id} | `crud.py` |
| `delete_document` | DELETE /{id} | `crud.py` |
| `classify_document` | POST /{id}/classify | `crud.py` |
| `stream_document_content` | GET /{id}/content | `content.py` |
**Recommended sub-module assignment:**
`api/documents/upload.py`: `request_upload_url`, `upload_document`, `confirm_upload` — all deal with the presigned-URL + cloud upload flow
`api/documents/crud.py`: `list_documents`, `get_document`, `patch_document`, `delete_document`, `classify_document` — standard document CRUD + re-classify
`api/documents/content.py`: `stream_document_content`, `_parse_range` helper — content proxy + range header handling
`api/documents/shared.py`: `_CLOUD_PROVIDERS` frozenset, `UploadUrlRequest`, `DocumentPatch` — shared by upload.py and crud.py
Re-classify endpoint (`POST /api/documents/{id}/classify`) → `crud.py` (it operates on an existing document, same ownership check pattern as get/patch/delete).
### api/auth.py → api/auth/ package
**Current structure (825L):**
| Endpoint | HTTP | Target sub-module |
|---|---|---|
| `register`, `login`, `refresh_token`, `logout`, `logout_all`, `get_me`, `get_my_quota` | `/api/auth/*` | `tokens.py` |
| `totp_setup`, `enable_totp` | `/api/auth/totp/*` | `totp.py` |
| `password_reset_request`, `password_reset_confirm` | `/api/auth/password-reset*` | `password.py` |
| `change_password` | `/api/auth/change-password` | `password.py` |
| `disable_totp` | `/api/auth/totp` (DELETE) | `totp.py` |
| `get_my_preferences`, `update_my_preferences` | `/api/auth/me/preferences` | `tokens.py` (profile management) |
**Recommended sub-module assignment:**
`api/auth/tokens.py`: `register`, `login`, `refresh_token`, `logout`, `logout_all`, `get_me`, `get_my_quota`, `get_my_preferences`, `update_my_preferences` — token issuance, session management, profile
`api/auth/totp.py`: `totp_setup`, `enable_totp`, `disable_totp` — TOTP lifecycle
`api/auth/password.py`: `change_password`, `password_reset_request`, `password_reset_confirm` — password management
`api/auth/shared.py`: `_set_refresh_cookie`, `_user_dict`, `RegisterRequest`, `LoginRequest`, `ChangePasswordRequest`, `TotpEnableRequest`, `PasswordResetRequest`, `PasswordResetConfirmRequest`, `PreferencesUpdate`, `limiter` (the Limiter instance) — shared across all sub-modules
**JTI/ES256/fgp placement:** These live entirely in `services/auth.py` and `deps/auth.py` — not in the router files. No placement decision needed for decomposition.
### main.py updates required
Current imports from monoliths:
```python
from api.auth import limiter as auth_limiter # L21 — used for app.state.limiter
from api.documents import router as documents_router # L22
from api.auth import router as auth_router # L320
from api.admin import router as admin_router # L321
```
After decomposition, each package's `__init__.py` aggregates sub-routers and exports `router`. `main.py` only changes to import from `api/auth/__init__.py`, `api/documents/__init__.py`, `api/admin/__init__.py`. The `limiter` from `api.auth` becomes `api.auth.shared.limiter` or re-exported from `api/auth/__init__.py`.
### Circular import risk analysis
**Risk 1: `api/cloud.py` imports `CloudConnectionOut` from `api/admin.py`**
- Current: `from api.admin import CloudConnectionOut` (L35 of cloud.py)
- After split: `api/admin/__init__.py` no longer directly defines it
- Fix: Move `CloudConnectionOut` to `api/schemas.py` BEFORE splitting admin.py. Update cloud.py to `from api.schemas import CloudConnectionOut`. Both packages import from schemas — no circular dependency.
**Risk 2: `backend/main.py` imports `limiter` from `api/auth.py`**
- Current: `from api.auth import limiter as auth_limiter` (L21)
- After split: `limiter` moves to `api/auth/shared.py`
- Fix: `api/auth/__init__.py` re-exports `limiter`. `main.py` import unchanged.
**Risk 3: `celery_app.py` import constraints**
- Celery task modules never import from router modules (ARCHITECTURE.md constraint)
- The new sub-packages must not be imported by `tasks/` or `celery_app.py`
- The tasks deferred-import pattern (e.g., `from tasks.email_tasks import send_reset_email`) must stay as local imports inside function bodies, not at module top level
**Risk 4: `tests/conftest.py` imports `auth_limiter`**
- `backend/tests/conftest.py` L222: `from api.auth import limiter as auth_limiter`
- After split: update to wherever `limiter` lands (via `api.auth` re-export, this stays unchanged)
---
## Frontend API Client Decomposition (CODE-04)
### client.js function inventory and domain mapping
**Functions that stay in `client.js` (transport layer):**
- `request()` (L11-57) — HTTP transport, Bearer injection, 401-refresh-retry
- `noRefreshPaths` (L26) — skip-refresh list
**`frontend/src/api/utils.js` (new, per D-13):**
The three blob-download 401-retry functions share identical structure — they bypass `request()` to return a raw `Response` instead of JSON. They should be consolidated into `fetchWithRetry(url, options, downloadHandler)`:
1. `adminExportAuditLogCsv` (L428-471) — fetch CSV, 401-retry, Blob download
2. `adminDownloadDailyExport` (L492-529) — fetch CSV, 401-retry, Blob download
3. `fetchDocumentContent` (L552-581) — fetch content, 401-retry, return raw Response
These three share identical authentication injection + 401-retry logic. The `fetchWithRetry` helper in `utils.js` handles the auth+retry boilerplate; each function provides its own `downloadHandler` or just returns the Response.
**Domain sub-module assignments:**
`api/documents.js`:
- `listDocuments`, `getDocument`, `deleteDocument`, `deleteDocumentRemoveOnly`
- `classifyDocument`, `getUploadUrl`, `confirmUpload`, `uploadToCloud`
- `fetchDocumentContent`, `getDocumentContentUrl`
`api/auth.js`:
- `login`, `register`, `refreshToken`, `logout`, `logoutAll`, `getMe`
- `changePassword`, `totpSetup`, `totpEnable`, `totpDisable`
- `passwordResetRequest`, `passwordResetConfirm`
- `getMyPreferences`, `updateMyPreferences`
- `getMyQuota`
`api/admin.js`:
- `adminListUsers`, `adminCreateUser`, `adminDeactivateUser`, `adminReactivateUser`
- `adminResetUserPassword`, `adminGetUserQuota`, `adminUpdateQuota`, `adminUpdateAiConfig`, `adminDeleteUser`
- `getAiConfig`, `saveAiConfig`, `testAiConnection`, `getAiModels`
- `adminListAuditLog`, `adminExportAuditLogCsv`, `adminListDailyExports`, `adminDownloadDailyExport`
`api/folders.js`:
- `listFolders`, `createFolder`, `getFolder`, `renameFolder`, `deleteFolder`, `moveDocument`
`api/shares.js`:
- `createShare`, `updateSharePermission`, `listShares`, `deleteShare`, `getSharedWithMe`
`api/cloud.js`:
- `listCloudConnections`, `disconnectCloud`, `connectWebDav`, `updateDefaultStorage`
- `getCloudFolders`, `initiateOAuth`, `getConnectionConfig`
`api/topics.js`:
- `listTopics`, `createTopic`, `updateTopic`, `deleteTopic`, `suggestTopics`
### Consumer import inventory
35+ consumer files import from `client.js` using three patterns:
**Pattern 1: `import * as api from '...api/client.js'`** (most common — namespace import)
- `stores/auth.js`, `stores/documents.js`, `stores/folders.js`, `stores/topics.js`, `stores/cloudConnections.js`
- `SettingsAccountTab.vue`, `SettingsPreferencesTab.vue`, `TotpEnrollment.vue`
- `AppSidebar.vue`, `AuditLogTab.vue`, `AdminAiConfigTab.vue`, `AdminQuotasTab.vue`, `AdminUsersTab.vue`
- `CloudProviderTreeItem.vue`, `CloudFolderTreeItem.vue`, `CloudCredentialModal.vue`
- `FolderTreeItem.vue`, `DocumentView.vue`, `SharedView.vue`, `CloudFolderView.vue`, `AccountView.vue`
- `NewPasswordView.vue`, `PasswordResetView.vue`
**Pattern 2: Named imports `import { funcName } from '...api/client.js'`**
- `SearchableModelSelect.vue`: `import { getAiModels }`
- `SettingsCloudTab.vue`: `import { initiateOAuth }`
- `AdminAiConfigTab.vue`: also `import { getAiConfig, saveAiConfig, testAiConnection }`
- `DocumentCard.vue`: `import { moveDocument, classifyDocument }`
- `DocumentPreviewModal.vue`: `import { fetchDocumentContent }`
- `DocumentView.vue`: also `import { fetchDocumentContent }` (in addition to `* as api`)
**Pattern 3: Lazy `await import('../stores/auth.js')`** (inside `request()` itself, stays in client.js)
The barrel re-export in `client.js` must re-export every function from every domain module so that all three patterns continue to work unchanged:
```javascript
// client.js (after decomposition)
// ... request() and noRefreshPaths stay here ...
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 } from './utils.js'
```
---
## PERF-01: Frontend Dependencies Analysis
### Currently installed vs. required
| Package | Currently installed | PERF-01 requires | Action |
|---|---|---|---|
| `vue` | 3.5.34 | `^3.5.0` | Already satisfies — no change |
| `vite` | 5.4.21 | `^6.4.3` | Upgrade required |
| `@vitejs/plugin-vue` | 5.2.4 | Must match Vite 6 | Upgrade to 6.x required |
| `@vueuse/core` | not installed | `^14.3.0` | Install required |
| `@vueuse/integrations` | not installed | `^14.3.0` | Install required |
| `sortablejs` | not installed | `^1.15.7` | Install required |
| `@tailwindcss/forms` | not installed | `^0.5.11` | Install required |
| `rollup-plugin-visualizer` | not installed (dev) | `^7.0.1` | Install required (devDep) |
| `@types/sortablejs` | not installed (dev) | latest | Install required (devDep) |
[VERIFIED: npm registry] — all package versions confirmed via `npm view <pkg> version`.
### Vite 5→6 migration for this project
[CITED: https://v6.vite.dev/guide/migration]
**Breaking changes that affect this project's `vite.config.js`:**
The current `vite.config.js` is minimal:
```javascript
export default defineConfig({
plugins: [vue()],
build: { target: 'esnext' },
server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://backend:8000', changeOrigin: true } } },
})
```
1. **`resolve.conditions` default values changed:** Defaults are no longer added internally for custom condition configs. Since this project has NO custom `resolve.conditions`, this change does NOT affect the project. No config change needed.
2. **`json.stringify` defaults to `'auto'`:** This project does not configure `json.stringify`. The `'auto'` default (stringifies only large JSON files) is safe — no change needed.
3. **CSS output filenames (library mode):** This is not a library mode build. No impact.
4. **Sass modern API default:** This project uses Tailwind (PostCSS), not Sass. No impact.
5. **`@vitejs/plugin-vue` must bump to 6.x:** Vite 6 requires `@vitejs/plugin-vue@^6.0.0`. The current `^5.0.0` is incompatible. [VERIFIED: npm registry] `@vitejs/plugin-vue@6.0.7` is the latest 6.x release.
**Config changes required:**
- Bump `vite` to `^6.4.3`
- Bump `@vitejs/plugin-vue` to `^6.0.7`
- No `vite.config.js` content changes required for this minimal config
**Config changes NOT required:**
- `build.target: 'esnext'` — still valid in Vite 6
- `server.proxy` — unchanged
- No `resolve.conditions` customization needed
### @tailwindcss/forms: config wiring required
Adding `@tailwindcss/forms` requires a one-line change to `tailwind.config.js`:
```javascript
import forms from '@tailwindcss/forms'
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: { extend: {} },
plugins: [forms],
}
```
This is used by VISUAL-02 in Phase 11. Installing in Phase 8 and wiring the plugin is the right time since VISUAL-02 needs it.
### Package config wiring summary
| Package | Install-only | Config wiring needed | Config file |
|---|---|---|---|
| `vue@^3.5.0` | Already installed | None | — |
| `vite@^6.4.3` | Yes (with plugin-vue bump) | Minimal: test after install | `package.json` only |
| `@vitejs/plugin-vue@^6.0.7` | Yes | None | `package.json` only |
| `@vueuse/core@^14.3.0` | Yes | None | — |
| `@vueuse/integrations@^14.3.0` | Yes | None | — |
| `sortablejs@^1.15.7` | Yes | None | — |
| `@tailwindcss/forms@^0.5.11` | No | Yes | `tailwind.config.js` |
| `rollup-plugin-visualizer@^7.0.1` | No | Yes | `vite.config.js` (for PERF-02) |
| `@types/sortablejs` | Yes (dev) | None | — |
`rollup-plugin-visualizer` is needed for PERF-02 (bundle analysis) in Phase 11. Install now, wire in Phase 11 when measurements are taken.
---
## requirements.txt: Exact Pinning (D-17)
**Current format:** All floating `>=` ranges.
**Required format:** Exact `==` pins at currently-installed versions.
Installed versions confirmed via `pip3 index versions`:
| Package | Current constraint | Pin to |
|---|---|---|
| `fastapi` | `>=0.111` | `==0.128.8` |
| `uvicorn[standard]` | `>=0.29` | [ASSUMED] — run `pip show uvicorn` to get exact |
| `python-multipart` | `>=0.0.27` | [ASSUMED] — run `pip show python-multipart` |
| `pydantic-settings` | `>=2.2` | [ASSUMED] — run `pip show pydantic-settings` |
| `pydantic[email]` | `>=2.0` | [ASSUMED] — run `pip show pydantic` |
| `anthropic` | `>=0.95.0` | `==0.104.0` (INSTALLED from pip3 output) |
| `openai` | `>=1.30` | [ASSUMED] — run `pip show openai` |
| `PyMuPDF` | `>=1.26.7` | [ASSUMED] — run `pip show PyMuPDF` |
| `python-docx` | `>=1.1` | [ASSUMED] — run `pip show python-docx` |
| `pytesseract` | `>=0.3` | [ASSUMED] — run `pip show pytesseract` |
| `Pillow` | `>=10.3` | [ASSUMED] — run `pip show Pillow` |
| `aiofiles` | `>=23.2` | [ASSUMED] — run `pip show aiofiles` |
| `httpx` | `>=0.27` | [ASSUMED] — run `pip show httpx` |
| `pytest` | `>=8.2` | [ASSUMED] — run `pip show pytest` |
| `pytest-asyncio` | `>=1.3.0` | [ASSUMED] — run `pip show pytest-asyncio` |
| `sqlalchemy[asyncio]` | `>=2.0.49` | `==2.0.49` (INSTALLED) |
| `psycopg[binary]` | `>=3.3.4` | `==3.2.13` (INSTALLED — note: 3.2.x series) |
| `alembic` | `>=1.18.4` | `==1.16.5` (INSTALLED — note: installed version lower than constraint) |
| `minio` | `>=7.2.20` | [ASSUMED] — run `pip show minio` |
| `celery[redis]` | `>=5.5.0` | `==5.6.3` (INSTALLED) |
| `redis` | `>=4.6.0` | [ASSUMED] — run `pip show redis` |
| `aiosqlite` | `>=0.20.0` | [ASSUMED] — run `pip show aiosqlite` |
| `PyJWT` | `>=2.8.0` | `==2.13.0` (INSTALLED) |
| `pwdlib[argon2]` | `>=0.2.1` | [ASSUMED] — run `pip show pwdlib` |
| `pyotp` | `>=2.9.0` | [ASSUMED] — run `pip show pyotp` |
| `slowapi` | `>=0.1.9` | [ASSUMED] — run `pip show slowapi` |
| `cryptography` | `>=41.0.0` | `==48.0.0` (INSTALLED) |
| `google-auth-oauthlib` | `>=1.3.1` | [ASSUMED] — run `pip show google-auth-oauthlib` |
| `google-api-python-client` | `>=2.196.0` | [ASSUMED] — run `pip show google-api-python-client` |
| `msal` | `>=1.36.0` | [ASSUMED] — run `pip show msal` |
| `webdavclient3` | `>=3.14.7` | [ASSUMED] — run `pip show webdavclient3` |
| `cachetools` | `>=5.3.0` | [ASSUMED] — run `pip show cachetools` |
| `structlog` | `>=25.5.0` | `==25.5.0` (INSTALLED) |
**Implementation:** The planner must include a task that runs `pip show <pkg> | grep Version` for all [ASSUMED] packages to get exact versions, then writes the pinned `requirements.txt`.
**Alembic discrepancy:** `requirements.txt` specifies `>=1.18.4` but `pip3 index versions` shows `1.16.5` installed. This is the installed version — D-17 says pin to currently installed. Pin to `==1.16.5`.
---
## Architecture Patterns
### Package __init__.py aggregation pattern
Every new package must have an `__init__.py` that aggregates sub-routers into a single exported `router` object with NO 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()
router.include_router(users_router)
router.include_router(quotas_router)
router.include_router(ai_router)
```
The parent prefix is set in `main.py`:
```python
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
```
Sub-routers have NO prefix — they just define routes without path prefix:
```python
# api/admin/users.py
router = APIRouter() # NO prefix here
@router.get("/users") # becomes /api/admin/users via parent
async def list_users(...):
...
```
**PITFALL:** `api/auth.py` currently sets `router = APIRouter(prefix="/api/auth", tags=["auth"])`. When decomposed, the parent `api/auth/__init__.py` must set the prefix, and sub-routers must have NO prefix.
**PITFALL for api/auth:** Currently `main.py` imports `limiter` from `api.auth` for rate limit state. After decomposition, `api/auth/shared.py` defines the limiter, `api/auth/__init__.py` re-exports it: `from api.auth.shared import limiter`. main.py import `from api.auth import limiter as auth_limiter` continues to work.
### api/schemas.py pattern
New file for cross-package Pydantic models:
```python
# api/schemas.py
from pydantic import BaseModel, field_validator
from typing import Optional
from datetime import datetime
class CloudConnectionOut(BaseModel):
"""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)
```
**Update required in `api/cloud.py`:** Change `from api.admin import CloudConnectionOut` to `from api.schemas import CloudConnectionOut`.
### Frontend barrel re-export pattern
```javascript
// src/api/client.js (after decomposition)
async function request(path, options = {}) { /* unchanged */ }
const noRefreshPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh']
// Re-export all domain modules — preserves all 3 consumer import patterns
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 } from './utils.js'
```
Domain modules use `request` from a shared location. Since `request` is not exported from `client.js`, domain modules need access to it. Two options:
1. Move `request` to `utils.js` and import from there in both `client.js` and domain files
2. Each domain file imports `request` from `client.js` — but this creates a circular dependency if `client.js` re-exports from domain files
**Correct approach (avoids circular imports):** Move `request` to `utils.js`. Domain files import from `utils.js`. `client.js` imports `request` from `utils.js` for its own internal use, and `export * from './domain.js'`.
```javascript
// src/api/utils.js
export async function request(path, options = {}) { /* ... */ }
export async function fetchWithRetry(url, onResponse, _retry = false) { /* consolidates 3 blob patterns */ }
```
```javascript
// src/api/documents.js
import { request } from './utils.js'
export function listDocuments(...) { return request('/api/documents?...') }
```
```javascript
// src/api/client.js
import { request } from './utils.js' // for noRefreshPaths compatibility if needed
export * from './documents.js'
export * from './auth.js'
// etc.
```
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Python package aggregation | Custom router merger | Standard FastAPI `include_router` | Built-in, prefix-aware, tag-aware |
| JS barrel re-export | Manually re-listing exports | `export * from './module.js'` | ES module spec, tree-shakeable |
| Blob download with auth | New fetch wrapper | Consolidate into `fetchWithRetry` in `utils.js` | 3 existing implementations share identical auth logic |
| requirements.txt pinning | pip freeze (wrong: includes dev deps) | `pip show <pkg>` per package | Avoids polluting with dev/transitive deps |
---
## Common Pitfalls
### Pitfall 1: Sub-router prefix doubling
**What goes wrong:** Adding `prefix="/api/admin"` to sub-router AND the parent `include_router` call doubles the URL: `/api/admin/api/admin/users`.
**Why it happens:** Developers copy the existing `router = APIRouter(prefix="/api/admin", tags=["admin"])` pattern into sub-routers.
**How to avoid:** Sub-routers must have `router = APIRouter()` with NO prefix. Only the `include_router` call in `main.py` carries the prefix.
**Warning signs:** Test suite returns 404 on all decomposed endpoints.
### Pitfall 2: Circular import via __init__.py
**What goes wrong:** `api/admin/__init__.py` imports from `api/admin/users.py` which imports from `api/admin/__init__.py` (e.g., shared helpers).
**Why it happens:** Putting shared helpers in `__init__.py` instead of `shared.py`.
**How to avoid:** All shared helpers go in `api/admin/shared.py`. `__init__.py` only does router aggregation.
**Warning signs:** `ImportError: cannot import name 'X' from partially initialized module`.
### Pitfall 3: `api/cloud.py` import breaks after admin split
**What goes wrong:** `api/cloud.py` imports `from api.admin import CloudConnectionOut`. After splitting, `CloudConnectionOut` is no longer in `api/admin/__init__.py`.
**Why it happens:** Not updating `cloud.py` when moving `CloudConnectionOut` to `api/schemas.py`.
**How to avoid:** Move `CloudConnectionOut` to `api/schemas.py` FIRST, update `cloud.py` import, verify tests pass, THEN split admin.py.
### Pitfall 4: JS circular import (request in client.js)
**What goes wrong:** Domain files import `request` from `client.js`, but `client.js` re-exports from domain files → circular dependency. Vite may silently produce undefined exports.
**Why it happens:** Keeping `request` in `client.js` while also re-exporting from domain files that need `request`.
**How to avoid:** Move `request` to `utils.js`. Domain files import from `utils.js`. `client.js` re-exports from domain files only.
### Pitfall 5: ES module `export *` name collision
**What goes wrong:** Two domain modules export a function with the same name (e.g., both `auth.js` and `admin.js` export `list()`). `export *` in `client.js` silently takes the last one.
**Why it happens:** Generic function names in domain modules.
**How to avoid:** All client.js functions already have unique, descriptive names (`listDocuments`, `adminListUsers`, etc.). Verify no name collisions before adding `export *`.
### Pitfall 6: Vite 6 `@vitejs/plugin-vue` version mismatch
**What goes wrong:** Running Vite 6 with `@vitejs/plugin-vue@^5.x` causes plugin incompatibility errors.
**Why it happens:** package.json allows `^5.0.0` which cannot satisfy Vite 6's peer dependency.
**How to avoid:** Bump both `vite` and `@vitejs/plugin-vue` in the same `npm install` command.
### Pitfall 7: Wave 1 toast stub breaks existing tests
**What goes wrong:** Replacing the `sessionRevokedToast` ref with `toastStore.show(...)` causes existing Vitest tests to fail because `useToastStore` is not mocked.
**Why it happens:** Tests that mock `api.changePassword` returning `{sessions_revoked: 2}` now need the store to be available.
**How to avoid:** The `useToastStore` stub must be importable and its `show()` must be a no-op by default (returns undefined). Tests that check for the toast text must be updated to check the store was called, or the stub renders nothing (which is fine for Phase 10).
---
## Code Examples
### Package __init__.py with sub-router aggregation
```python
# Source: FastAPI docs — include_router pattern (ASSUMED standard pattern)
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)
```
### Sub-router file structure
```python
# Source: pattern from existing api/folders.py, api/shares.py (established in project)
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from deps.auth import get_current_admin
from deps.db import get_db
router = APIRouter() # NO prefix — parent sets it
@router.get("/users") # Becomes /api/admin/users
async def list_users(
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
): ...
```
### useToastStore stub
```javascript
// Source: established Pinia pattern from auth.js, documents.js (project convention)
import { defineStore } from 'pinia'
export const useToastStore = defineStore('toast', () => {
function show(message, type = 'info') {
// Stub: Phase 10 fills in full implementation
// Phase 7.1 call sites use this; behavior visible after Phase 10
}
return { show }
})
```
### fetchWithRetry consolidation
```javascript
// Consolidates adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent
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
}
```
---
## Package Legitimacy Audit
slopcheck was not available in this environment (installation blocked by auto-mode classifier). All packages below are tagged [ASSUMED] per the graceful-degradation rule. The planner must gate each install behind a `checkpoint:human-verify` task.
| Package | Registry | Purpose | Disposition |
|---|---|---|---|
| `@vueuse/core@^14.3.0` | npm | Vue composition utilities | [ASSUMED] — verify before install |
| `@vueuse/integrations@^14.3.0` | npm | VueUse integrations | [ASSUMED] — verify before install |
| `sortablejs@^1.15.7` | npm | Drag-and-drop sorting | [ASSUMED] — verify before install |
| `@tailwindcss/forms@^0.5.11` | npm | Form base styles | [ASSUMED] — verify before install |
| `rollup-plugin-visualizer@^7.0.1` | npm | Bundle analysis | [ASSUMED] — verify before install |
| `@types/sortablejs` | npm | TypeScript types for sortablejs | [ASSUMED] — verify before install |
| `vite@^6.4.3` | npm | Build tool (major upgrade) | [ASSUMED] — verify before install |
| `@vitejs/plugin-vue@^6.0.7` | npm | Vue SFC compiler for Vite 6 | [ASSUMED] — verify before install |
**Packages removed due to slopcheck [SLOP] verdict:** none (slopcheck unavailable)
**Packages flagged as suspicious [SUS]:** none (slopcheck unavailable)
*All packages above are tagged [ASSUMED] because slopcheck was unavailable at research time. The planner must gate each install behind a `checkpoint:human-verify` task.*
---
## Runtime State Inventory
This is a refactoring phase — no renames, no migrations. This section is NOT applicable.
---
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Python 3.12 | Backend | ✓ | 3.12 (Docker) | — |
| Node.js 20 | Frontend | ✓ | 20 (Docker) | — |
| npm | PERF-01 package installs | ✓ | bundled with Node | — |
| pip3 | requirements.txt pinning | ✓ | system | — |
| pytest | Backend tests | ✓ | installed per requirements.txt | — |
| vitest | Frontend tests | ✓ | `^4.1.7` per package.json | — |
**Missing dependencies with no fallback:** None.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Backend framework | pytest with pytest-asyncio (asyncio_mode = auto) |
| Backend config file | `backend/pytest.ini` |
| Backend quick run | `cd backend && pytest tests/test_auth.py -x -v` |
| Backend full suite | `cd backend && pytest -v` |
| Frontend framework | Vitest 4.1.7 |
| Frontend config | `frontend/vitest.config.js` |
| Frontend quick run | `cd frontend && npm test` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CR-01 | `change_password` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_change_password_revokes_other_sessions -x` | ❌ Wave 0 |
| CR-02 | `enable_totp` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_enable_totp_revokes_other_sessions -x` | ❌ Wave 0 |
| CR-03 | `disable_totp` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_disable_totp_revokes_other_sessions -x` | ❌ Wave 0 |
| CODE-01 | All admin URLs still respond on same paths after split | integration | `pytest tests/test_admin.py -x` | ✅ |
| CODE-02 | All document URLs still respond on same paths after split | integration | `pytest tests/test_documents.py -x` | ✅ |
| CODE-03 | All auth URLs still respond on same paths after split | integration | `pytest tests/test_auth.py -x` | ✅ |
| CODE-04 | All existing consumer imports resolve; no 35+ files changed | smoke | `cd frontend && npm test` | ✅ |
| CODE-08 | No model defined twice | static/grep | `grep -rn "class CloudConnectionOut" backend/` | N/A |
| PERF-01 | npm list confirms all packages present | install verification | `cd frontend && npm list` | N/A |
### Wave 0 Gaps (new tests to write)
- [ ] `backend/tests/test_auth.py` — add 3 new tests for CR-01/02/03:
- `test_change_password_revokes_other_sessions` — create 2 tokens, change password with skip_hash for token 1, verify token 2 revoked
- `test_enable_totp_revokes_other_sessions`
- `test_disable_totp_revokes_other_sessions`
- [ ] Frontend: update `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` to mock `useToastStore` after stub creation
---
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (CR-01/02/03) | Session revocation on privilege change — already implemented |
| V3 Session Management | yes | `revoke_all_refresh_tokens` with `skip_token_hash` — no change |
| V4 Access Control | yes | `get_current_admin`, `get_regular_user` deps preserved through decomposition |
| V5 Input Validation | yes | Pydantic models moved to `shared.py` — no change in validation logic |
| V6 Cryptography | no | No crypto changes in this phase |
### Known Threat Patterns
| Pattern | Risk | Mitigation |
|---------|------|------------|
| Router prefix doubling | URL structure breaks, unintended route shadowing | Sub-routers MUST have no prefix (D-04) |
| `CloudConnectionOut` import break | `cloud.py` fails to start; 500 on all cloud endpoints | Move to `api/schemas.py` BEFORE splitting admin.py |
| Circular import crash | App fails to start entirely | `request()` moved to `utils.js`; shared.py not `__init__.py` |
| Admin endpoint leakage | After split, a sub-module accidentally lacks `get_current_admin` dep | Every admin sub-module handler must explicitly inject `_admin: User = Depends(get_current_admin)` |
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Most [ASSUMED]-tagged pip package versions in pinning table | requirements.txt pinning | Wrong exact version pinned — planner must run `pip show` per package |
| A2 | `fetchWithRetry` abstraction is sufficient to consolidate all 3 blob patterns | CODE-04 | Minor: may need separate functions if download triggers differ |
| A3 | `export *` from domain modules has no name collisions | CODE-04 pitfalls | Low risk: all current client.js exports have unique names |
| A4 | Vite 6 with `esnext` build target works without explicit `resolve.conditions` | PERF-01 | Low risk: project uses no custom conditions; test after upgrade |
| A5 | `@vitejs/plugin-vue@^6.0.7` is the correct peer for `vite@^6.4.3` | PERF-01 | Low risk: npm will warn on peer dep mismatch at install time |
**If this table is empty:** Not empty — A1 is the most significant: the planner must run `pip show` for all [ASSUMED] packages before writing pinned requirements.txt.
---
## Open Questions
1. **`create_system_topic` placement in admin split**
- What we know: It's a one-line admin utility that calls `services.storage.create_topic()`
- What's unclear: Does it belong in `users.py` (as an admin utility) or `ai.py` (topics are AI-adjacent)?
- Recommendation: Place in `users.py` — topic creation is a content management function, not AI config.
2. **`useToastStore` stub API shape**
- What we know: Phase 10 will implement the full toast system (UX-10). D-03 says Phase 7.1 only creates the stub with `show()`.
- What's unclear: Should `show()` accept `(message, type)` or `(message, options)`?
- Recommendation: `show(message, type = 'info')` — minimal two-arg API that Phase 10 can extend without breaking call sites.
3. **alembic version discrepancy**
- What we know: `requirements.txt` says `>=1.18.4` but `pip3 index versions` shows installed `1.16.5`.
- What's unclear: Is `1.18.4` the required minimum or a typo?
- Recommendation: Pin to `==1.16.5` (the installed version). If `1.18.4` was intentional, note the mismatch for the user.
---
## Sources
### Primary (HIGH confidence)
- `backend/api/auth.py` — full source read, CR-01/02/03 implementation verified
- `backend/api/admin.py` — full source read, sub-module boundaries identified
- `backend/api/documents.py` — full source read, sub-module boundaries identified
- `backend/services/auth.py``revoke_all_refresh_tokens` signature verified
- `frontend/src/api/client.js` — full source read, all 635 lines, consumer mapping complete
- `backend/main.py` — router registration pattern verified
- `frontend/package.json` — current dependency versions verified
- `frontend/vite.config.js` — current config verified (minimal)
- `backend/requirements.txt` — current floating constraints verified
- `.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md` — locked decisions
- `pip3 index versions` output — installed package versions for fastapi, sqlalchemy, celery, alembic, pyjwt, anthropic, cryptography, structlog, psycopg
### Secondary (MEDIUM confidence)
- [CITED: https://v6.vite.dev/guide/migration] — Vite 5→6 breaking changes
- `npm view vite version`, `npm view @vitejs/plugin-vue version` etc. — registry versions verified
- `npm list` in frontend — installed frontend package versions
### Tertiary (LOW confidence — marked [ASSUMED])
- Remaining pip package installed versions (uvicorn, python-multipart, pydantic-settings, etc.) — not verified via `pip show`, only `pip3 index versions` for latest available
---
## Metadata
**Confidence breakdown:**
- CR-01/02/03 backend status: HIGH — source code read directly, implementation confirmed complete
- Backend sub-module boundaries: HIGH — full source code read for all 3 monoliths
- CloudConnectionOut cross-package risk: HIGH — import chain verified in cloud.py
- Frontend function mapping: HIGH — full client.js read + all consumer imports grepped
- Vite 5→6 migration impact: MEDIUM — official migration guide consulted, no Sass/library mode used
- requirements.txt pinning: MEDIUM — some versions confirmed via pip3, most [ASSUMED]
- Package legitimacy: LOW — slopcheck unavailable, all npm packages [ASSUMED]
**Research date:** 2026-06-07
**Valid until:** 2026-07-07 (stable refactoring domain; package versions may drift)
@@ -0,0 +1,228 @@
---
phase: 8
slug: stack-upgrade-backend-decomposition
status: approved
shadcn_initialized: false
preset: none
created: 2026-06-07
reviewed_at: 2026-06-07
---
# Phase 8 — UI Design Contract
> Visual and interaction contract for Phase 8: Stack Upgrade & Backend Decomposition.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.
## Scope Note
Phase 8 is a refactoring phase. The backend router decomposition, frontend API client
decomposition, and dependency bumps are invisible to users. The ONLY user-facing UI
surface in this phase is:
1. **sessions-revoked notification**`SettingsAccountTab.vue` and `TotpEnrollment.vue`
already display inline session-revoked feedback using local `ref` state. Phase 8 (Wave 1 —
Phase 7.1 absorbed) replaces that local state with a call to `toastStore.show(...)`.
2. **`useToastStore` stub** — a new Pinia store with a defined `show()` API contract that
Phase 10 will implement fully. The stub must not render anything; it only defines the call
contract so Phase 7.1 call sites and Phase 10 implementation are aligned.
All other design contract sections (spacing, typography, color) document the **existing
design system** inherited by Phase 8. No new visual patterns are introduced.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none — Tailwind CSS utility classes only |
| Preset | not applicable |
| Component library | none (hand-rolled Vue 3 components) |
| Icon library | Heroicons — inline SVG paths (`stroke="currentColor"`, `stroke-width="1.5"` or `"2"`) |
| Font | system-ui / browser default (no custom font loaded) |
Source: `frontend/tailwind.config.js` (no plugins, no theme extensions), `App.vue`, existing component audit.
---
## Spacing Scale
Declared values (must be multiples of 4):
| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Icon gaps (`gap-1`, `gap-1.5`), badge inner padding |
| sm | 8px | Compact element spacing (`gap-2`, `mb-1`, `py-2`) |
| md | 16px | Default element spacing (`space-y-4`, `gap-4`, `px-3 py-3` inputs) |
| lg | 24px | Section padding (`p-6`), section gaps (`space-y-6`) |
| xl | 32px | Layout gaps (sidebar + main content separation) |
| 2xl | 48px | Not actively used in Phase 8 scope |
| 3xl | 64px | Not actively used in Phase 8 scope |
Exceptions: none for Phase 8 scope.
Source: `SettingsAccountTab.vue` (`p-6`, `space-y-6`, `space-y-4`, `gap-3`), `TotpEnrollment.vue` (`space-y-4`, `gap-2`, `px-6 py-2.5`).
---
## Typography
| Role | Size | Weight | Line Height |
|------|------|--------|-------------|
| Body | 14px (`text-sm`) | 400 (regular) | 1.5 |
| Label / caption | 12px (`text-xs`) | 400 (regular) | 1.5 |
| Section heading | 14px (`text-sm`) | 600 (`font-semibold`) | 1.2 |
| Form label | 14px (`text-sm`) | 600 (`font-semibold`) | 1.2 |
Note: Phase 8 introduces no new typography. The system uses exactly 2 sizes (14px, 12px)
and exactly 2 weights (400, 600) for the settings/auth component surface touched in Wave 1.
Source: `SettingsAccountTab.vue` (`text-sm font-semibold text-gray-800` for headings,
`text-sm text-gray-600/700` for body, `text-xs text-red-600` for error captions).
---
## Color
| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | `#ffffff` / `bg-white` | Page background, card surfaces |
| Secondary (30%) | `#f9fafb` / `bg-gray-50` | Input backgrounds, code blocks, secondary surfaces |
| Accent (10%) | `#4f46e5` / `bg-indigo-600` | Primary action buttons, focus rings (`focus:ring-indigo-500`) |
| Success semantic | `#16a34a` / `text-green-600`, `border-green-200` | Session-revoked notification, success confirmations |
| Destructive | `#dc2626` / `text-red-600`, `border-red-300` | Destructive action buttons ("Disable 2FA", "Sign out all devices"), error states |
Accent (`indigo-600`) reserved for:
- Primary submit buttons (`bg-indigo-600 hover:bg-indigo-700`)
- Input focus rings (`focus:ring-indigo-500 focus:border-indigo-500`)
- Role badge for admin users (`bg-indigo-100 text-indigo-700`)
Source: `SettingsAccountTab.vue`, `TotpEnrollment.vue` — exhaustive class audit.
---
## `useToastStore` API Contract
This is the primary design deliverable for Phase 8 (Wave 1). The stub Pinia store must
define and export exactly this `show()` signature. Phase 10 will implement the rendering.
### Store location
`frontend/src/stores/toast.js`
### `show()` method signature
```js
toastStore.show(message, type = 'success', duration = 4000)
```
| Parameter | Type | Values | Default | Notes |
|-----------|------|--------|---------|-------|
| `message` | `string` | Any non-empty string | required | Plain text only — no HTML |
| `type` | `string` | `'success'` \| `'error'` \| `'info'` | `'success'` | Controls icon and border color in Phase 10 |
| `duration` | `number` | Milliseconds until auto-dismiss | `4000` | `0` = persist until manually dismissed (Phase 10 contract) |
### Stub implementation contract
The stub MUST:
- Export `useToastStore` as a named export from `stores/toast.js`
- Expose `show(message, type, duration)` as a callable method
- NOT throw, NOT warn, NOT render anything — silently no-op in Phase 8
The stub MUST NOT:
- Accept an object argument shape (e.g. `show({ message, type })`) — positional parameters only, for simplicity
- Render a DOM element or inject CSS
- Import or depend on any component
### Phase 10 rendering contract (locked now to align implementor)
When Phase 10 implements the full store, it MUST honor the same `show()` signature without
modification to any Phase 8 call site. The rendering target is a fixed-positioned stack at
`top-4 right-4 z-50` (matching the existing inline toast placement in `SettingsAccountTab.vue`).
Auto-dismiss fires after `duration` ms. Manual dismiss on click. Toasts stack vertically with
`gap-2` between items. No interaction blocking.
---
## Sessions-Revoked Notification — Interaction Contract
### Current state (before Phase 8 Wave 1)
Both `SettingsAccountTab.vue` and `TotpEnrollment.vue` implement sessions-revoked feedback
with identical local state: `const sessionRevokedToast = ref(false)` + `setTimeout(..., 5000)`.
### Target state (after Phase 8 Wave 1)
The local `sessionRevokedToast` ref and `setTimeout` are removed from both components.
The API response handler calls `toastStore.show(...)` instead.
### Trigger conditions
| Component | Trigger | Call |
|-----------|---------|------|
| `SettingsAccountTab.vue``changePassword()` | `data.sessions_revoked > 0` | `toastStore.show('Other sessions have been terminated.', 'success')` |
| `SettingsAccountTab.vue``disableTotp()` | `data.sessions_revoked > 0` | `toastStore.show('Other sessions have been terminated.', 'success')` |
| `TotpEnrollment.vue``confirmEnrollment()` | `data.sessions_revoked > 0` | `toastStore.show('Other sessions have been terminated.', 'success')` |
### Message copy (locked)
`'Other sessions have been terminated.'`
This exact string is used in all three call sites. It matches the copy already displayed
by the existing inline implementation (confirmed in both component files).
### Notification visual spec (Phase 10 will render; locked here for alignment)
| Property | Value |
|----------|-------|
| Position | Fixed, `top-4 right-4`, `z-50` |
| Background | `bg-white` |
| Border | `border border-green-200` |
| Border radius | `rounded-xl` |
| Shadow | `shadow-lg` |
| Padding | `px-5 py-4` |
| Max width | `max-w-sm` |
| Icon | Heroicons `check-circle` (outline, `w-5 h-5 text-green-500`) |
| Text | `text-sm font-semibold text-gray-900` |
| Dismiss button | `text-gray-400 hover:text-gray-600`, Heroicons `x-mark` `w-4 h-4` |
| Auto-dismiss | After 4000ms (using `duration` param default) |
Source: existing inline implementation in `SettingsAccountTab.vue` lines 525, adapted to
use the `duration` param default of 4000ms instead of the current hardcoded 5000ms.
---
## Copywriting Contract
| Element | Copy |
|---------|------|
| Sessions-revoked notification | `Other sessions have been terminated.` |
| Toast dismiss aria-label | `Dismiss notification` |
No other new user-facing copy is introduced in Phase 8. All other interactions (backend
decomposition, client.js refactor, dependency bumps) are invisible to the user.
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none — not initialized | not applicable |
| Third-party | none | not applicable |
No third-party component registries are used. No new UI components are added beyond the
`useToastStore` stub (a store, not a component).
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS
**Approval:** approved 2026-06-07
@@ -0,0 +1,88 @@
---
phase: 8
slug: stack-upgrade-backend-decomposition
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-06-07
---
# Phase 8 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Backend framework** | pytest with pytest-asyncio (asyncio_mode = auto) |
| **Backend config file** | `backend/pytest.ini` |
| **Backend quick run** | `cd backend && pytest tests/test_auth.py -x -v` |
| **Backend full suite** | `cd backend && pytest -v` |
| **Frontend framework** | Vitest 4.1.7 |
| **Frontend config** | `frontend/vitest.config.js` |
| **Frontend quick run** | `cd frontend && npm test` |
| **Estimated runtime** | ~30 seconds (backend), ~10 seconds (frontend) |
---
## Sampling Rate
- **After every task commit:** Run `cd backend && pytest -x -v` (backend changes) or `cd frontend && npm test` (frontend changes)
- **After Wave 0 (test stubs):** Confirm all new stubs are xfail — `pytest --tb=no -q`
- **After decomposition plans:** Full suite `cd backend && pytest -v` — zero failures before advancing
---
## Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CR-01 | `change_password` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_change_password_revokes_other_sessions -x` | ❌ Wave 0 |
| CR-02 | `enable_totp` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_enable_totp_revokes_other_sessions -x` | ❌ Wave 0 |
| CR-03 | `disable_totp` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_disable_totp_revokes_other_sessions -x` | ❌ Wave 0 |
| CODE-01 | All admin URLs still respond on same paths after split | integration | `pytest tests/test_admin.py -x` | ✅ |
| CODE-02 | All document URLs still respond on same paths after split | integration | `pytest tests/test_documents.py -x` | ✅ |
| CODE-03 | All auth URLs still respond on same paths after split | integration | `pytest tests/test_auth.py -x` | ✅ |
| CODE-04 | All existing consumer imports resolve; no 35+ files changed | smoke | `cd frontend && npm test` | ✅ |
| CODE-08 | No model defined twice across packages | static/grep | `grep -rn "class CloudConnectionOut" backend/` | N/A |
| PERF-01 | npm list confirms all packages present | install verification | `cd frontend && npm list` | N/A |
---
## Wave 0 Gaps
New tests to write before decomposition work begins:
- [ ] `backend/tests/test_auth.py` — add 3 xfail stubs promoted to real tests for CR-01/02/03:
- `test_change_password_revokes_other_sessions`
- `test_enable_totp_revokes_other_sessions`
- `test_disable_totp_revokes_other_sessions`
- [ ] Frontend: verify `useToastStore` mock wiring in `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` after stub creation
---
## Critical Test Gates
| Gate | Command | Must pass before |
|------|---------|-----------------|
| Wave 0 stubs xfail | `pytest --tb=no -q` | Any decomposition work |
| Admin split | `pytest tests/test_admin.py -x -v` | Advancing past CODE-01 |
| Documents split | `pytest tests/test_documents.py -x -v` | Advancing past CODE-02 |
| Auth split | `pytest tests/test_auth.py -x -v` | Advancing past CODE-03 |
| Full backend suite | `pytest -v` | Phase gate |
| Frontend smoke | `npm test` | Phase gate |
| URL regression | `pytest tests/test_admin.py tests/test_documents.py tests/test_auth.py -v` | Phase complete |
---
## Security Validation
| Threat | Test | Evidence |
|--------|------|---------|
| Router prefix doubling | `pytest -v` — all existing route tests pass | URL paths unchanged |
| Admin endpoint auth leakage | `pytest tests/test_admin.py -k "not_admin"` | 403 on all admin routes for non-admin |
| Circular import crash | App starts cleanly: `uvicorn main:app --reload` exits 0 | No ImportError |
| CloudConnectionOut import break | `pytest tests/test_cloud.py -x` | cloud.py imports succeed |
File diff suppressed because it is too large Load Diff
+192 -384
View File
@@ -1,449 +1,257 @@
# Feature Research
# Feature Landscape — v0.2 UI Overhaul
**Domain:** SaaS Document Management Platform (multi-user, quota-enforced, privacy-first)
**Researched:** 2026-05-21
**Confidence:** MEDIUM (web fetch/search unavailable; based on training knowledge of Google Drive, OneDrive, Dropbox, Box, Notion, Paperless-ngx, DocuWare through Aug 2025 — all mature platforms with stable, well-documented feature sets)
**Domain:** Self-hosted SaaS document management (DocuVault)
**Researched:** 2026-06-07
**Scope:** UX patterns and UI feature decisions for v0.2. Existing functional features (upload, extraction, sharing, cloud backends, auth) are not re-researched — only their UX surface.
---
## Research Scope
## Table Stakes
Platforms surveyed: Google Drive, Microsoft OneDrive, Dropbox, Notion, Box, Paperless-ngx (self-hosted), DocuWare (enterprise DMS).
Features users expect. Missing = product feels incomplete or broken.
Eight areas analyzed per the brief:
1. Auth & access control
2. Storage & quota UX
3. Folder/organization UX
4. Sharing model
5. Cloud storage integration UX
6. Admin panel
7. Audit / compliance
8. Document viewer features
| Feature | Why Expected | Complexity | Depends On |
|---------|--------------|------------|------------|
| Standalone admin layout (`/admin/*` routes with own sidebar) | AdminView.vue is currently a tab sheet bolted to the user layout. Admin and user contexts share no chrome. Nextcloud, Mayan EDMS, and every self-hosted SaaS treat admin as a distinct area. | Medium | Router nested routes, new `AdminLayout.vue` |
| Admin sidebar with discrete nav items (Users, Quotas, AI Config, Audit Log) | Each admin tab is already a full standalone component. Route-based nav replaces in-view tabs — enables deep linking, browser back button, and future per-section permissions. | Low | Vue Router `/admin/users`, `/admin/quotas`, `/admin/ai`, `/admin/audit` child routes |
| Empty states for every zero-content context | File list empty, folder empty, search no-results, shared-with-me empty, topics empty — each needs a distinct, actionable state. Current implementation is plain gray text. Zero-content states are the first thing new users see. | Medium | `StorageBrowser.vue`, `SharedView.vue`, `TopicsView.vue` |
| Loading skeletons for document list and sidebar tree | The document list (`StorageBrowser`) and sidebar folder tree currently render a plain "Loading…" text string. Skeletons reduce perceived load time by 3050% on equivalent wait times per usability studies. The `QuotaBar` already uses `animate-pulse` — extend that pattern. | Medium | `StorageBrowser.vue`, `AppSidebar.vue`, new `SkeletonRow.vue` |
| Global search focus shortcut (`/` key) | Table stakes for document management tools. Paperless-ngx, Notion, GitHub, Linear all use `/` to focus search. Users who type `/` and nothing happens feel the app is unpolished. | Low | `SearchBar.vue`, `FileManagerView.vue` global `keydown` listener |
| Escape key closes modals and clears search | Universal browser convention. Escape should: close `ShareModal`, close `FolderDeleteModal`, close `DocumentPreviewModal`, clear the inline folder rename input, and clear search field. Several of these already handle `keydown.escape` in inputs but not at the modal overlay level. | Low | `ShareModal.vue`, `FolderDeleteModal.vue`, `DocumentPreviewModal.vue` |
| Sidebar collapses on mobile (hamburger toggle, overlay drawer) | On viewports below 1024px the fixed 256px sidebar eats 2540% of the viewport. The current layout has no mobile handling — sidebar overlaps content on small screens. Minimum viable mobile: hamburger button + slide-in overlay drawer. | Medium | `AppSidebar.vue`, `App.vue`, new mobile toggle state |
| Responsive document list column hiding | `StorageBrowser` grid already uses `hidden md:block` for Size and `hidden sm:block` for Modified. This is correct — verify it works and document the pattern as intentional. Ensure the icon + name + actions columns always render. | Low | `StorageBrowser.vue` (verify existing classes) |
| Upload trigger keyboard shortcut (`U` key when no input is focused) | Secondary table stakes. Windows Explorer, Nextcloud, and Dropbox support pressing a key to trigger upload when focus is on the file list. Makes keyboard-first workflows viable. | Low | `DropZone.vue` or `FileManagerView.vue` global `keydown` listener |
| Visible drag-and-drop affordance on the drop zone at all times | `DropZone.vue` already exists and handles dragover/drop. The gap: the drop zone is a full-width dashed box inside the content area, which is correct, but there is no whole-page drag overlay when a user drags a file from the OS file manager. Users miss the target and the drop is ignored. | Medium | `FileManagerView.vue` document-level dragenter/dragleave listener |
| Contextual breadcrumb with admin section label | Admin subroutes need breadcrumbs: `Admin > Users`, `Admin > Audit Log`. Without them, admin pages feel unmoored. The user-facing `FolderBreadcrumb` is already built — admin can use the same component with static segments. | Low | `FolderBreadcrumb.vue`, `AdminLayout.vue` |
---
## Feature Landscape
## Differentiators
### Table Stakes (Users Expect These)
Features that add genuine value beyond the baseline. Not expected by most users, but valued when present.
Features users assume exist. Missing these = product feels incomplete or broken.
#### 1. Auth & Access Control
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Email + password registration with validation | Every SaaS has this | LOW | Strength rules (length, complexity) expected; breach-check (HaveIBeenPwned API) is a notable addition |
| Persistent sessions with "remember me" | Users hate logging in every visit | LOW | JWT with refresh token; sliding expiry. Pure stateless JWT without refresh feels cheap. |
| Password reset via email | Users forget passwords constantly | LOW | Time-limited signed token; mandatory |
| TOTP 2FA (authenticator app) | Expected at any security-conscious SaaS | MEDIUM | PyOTP / HOTP RFC 6238. Users expect QR code setup + backup codes. Missing backup codes = major UX gap. |
| Forced logout / session revocation | Power users and security-conscious users expect this | MEDIUM | "Sign out all devices" is table stakes at Box and Google. Requires server-side session tracking (defeats pure JWT — use a token revocation list or short-lived JWTs + refresh token table). |
| Per-user isolated data space | Every cloud storage product does this | LOW | Violated = catastrophic trust failure. MinIO prefix isolation per user ID. |
| Account deletion with data wipe | GDPR + user trust | MEDIUM | Must cascade: documents, quotas, shares, cloud credentials, audit entries scoped to user. |
#### 2. Storage & Quota UX
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Visible quota indicator (used / total) | Google Drive, Dropbox, OneDrive all show this prominently | LOW | Progress bar + "X MB of Y MB used" in sidebar or settings. Missing = users feel blind. |
| Quota check at upload time with clear error | Every platform rejects over-quota uploads | LOW | Error must say "You've used X of Y MB — free up space or upgrade." Not a generic 500. |
| Per-file size shown in file list | Dropbox, Drive show sizes in list view | LOW | Users make quota decisions based on file size visibility. |
| Sort/filter by size | Users cleaning up quota expect to find large files | LOW | "Largest files first" sort is a common quota-management action. |
| Quota warning at 80% and 95% | Drive warns at 80%; Dropbox emails at 90% | LOW | In-app banner + optionally email. Users are surprised when they hit the wall without warning. |
| Storage usage breakdown by folder/category | Drive shows breakdown by file type | MEDIUM | "X MB in Documents, Y MB in Images" helps users understand what to delete. Folder-level usage is lower priority but expected by power users. |
| Delete confirmation that shows freed space | Minor but Dropbox/Drive do this | LOW | "Deleting this file will free 24 MB." Reinforces value of the action. |
#### 3. Folder / Organization UX
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Create folder | Universal | LOW | Standard. |
| Rename folder | Universal | LOW | Inline rename (click-to-edit) is expected; modal is acceptable. |
| Delete folder (with contents confirmation) | Universal | LOW | Must warn if non-empty: "This will delete N documents." Soft-delete preferred; hard-delete on confirm. |
| Move document to folder (drag-and-drop or context menu) | Drive, Dropbox have both | MEDIUM | Drag-and-drop is a strong UX expectation in desktop-class web apps. Context menu "Move to..." is the fallback minimum. |
| Move folder into another folder (nesting) | Drive, OneDrive support arbitrary depth | MEDIUM | Users expect arbitrary depth. Cap at 58 levels deep to avoid pathological nesting; unlimited is fine for v1. |
| Breadcrumb navigation | Universal in file managers | LOW | Clickable breadcrumb showing full path: Root > Invoices > 2025. Without this users get lost in nested folders. |
| Sort documents within folder (name, date, size, type) | All platforms have this | LOW | Default: date descending (newest first). |
| "Recent" / "Last accessed" virtual folder | Drive, OneDrive surface this prominently | LOW | Not a real folder — a filtered view. Users navigating back to recent work expect it. |
| Search across all documents | Every platform has global search | MEDIUM | Full-text or metadata search. For DocuVault, text is already extracted — expose it in search. Missing = product feels like a filing cabinet with no index. |
| Multi-select for batch operations | Drive, Dropbox support shift-click + checkbox | MEDIUM | Batch delete, batch move. Without this, managing many files is tedious. |
| "Shared with me" virtual folder | Drive has this; Box has it; Dropbox has it | LOW | Auto-populated when another user shares a document. Already in PROJECT.md. |
#### 4. Sharing Model
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Share with named user (by handle or email) | Drive, Box, Dropbox all do this | MEDIUM | Core sharing primitive. Must show: who has access, their permission level, revoke button. |
| View-only vs. edit permission distinction | Box, Drive, Dropbox all distinguish view/edit | LOW | For DocuVault, edit means "re-upload / annotate" — since no in-app editing, view-only is the primary mode. Still must distinguish the concept for future extensibility. |
| Revoke share immediately | All platforms do this | LOW | No delay. If the user is currently viewing the document, their next request should 403. |
| Share notification to recipient | Drive/Dropbox send email; Box shows in-app | LOW | At minimum: in-app notification. Email is table stakes for platforms where users may not be logged in. |
| See list of "what I've shared" | Drive, Box surface this | LOW | Users need to audit their own shares. A "Shared by me" list per document + a global shares list in settings. |
| Accept / decline incoming share | Box requires acceptance; Drive shows silently | MEDIUM | DocuVault's "Shared with me" folder appearing is implicit acceptance. An explicit accept/decline adds trust. Drive skips it; Box requires it for external shares. Given privacy-first positioning, explicit accept is the better default. |
| Share expiry date | Box supports this; Drive (with Workspace) does; Dropbox Business does | MEDIUM | Not universal at consumer tier but expected in security-conscious / business contexts. |
#### 5. Cloud Storage Integration UX
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| OAuth2 connect flow for Google Drive / OneDrive | Standard for these integrations | HIGH | Redirect-based OAuth2 PKCE. Users expect "Connect" → browser popup → granted → back to app. No credential entry. |
| Connection status indicator (connected / disconnected / error) | Any integration page shows this | LOW | Green dot = connected, red = error with message. Dropbox Business sync indicators are the model. |
| Disconnect / re-authenticate option | Users rotate tokens; credentials expire | LOW | "Disconnect" button. Re-auth if token expires (silent token refresh where possible). |
| Default storage selector | Users need to know where new uploads go | LOW | Clear radio/dropdown: "Local storage" vs "Google Drive" vs "OneDrive." Show current default prominently. |
| Upload routing confirmation | When cloud is default, users want to see it confirmed | LOW | "Stored in Google Drive" in upload success message. |
| Error state when cloud storage unreachable | Cloud services go down; credentials expire | MEDIUM | Graceful: queue locally, show warning, retry. Hard fail (lose document) = catastrophic. Minimum: "Storage unavailable — document saved locally." |
| Basic usage from cloud backend | Show how much cloud storage is used vs available | MEDIUM | Google Drive has 15 GB free; OneDrive 5 GB. Surfacing "You've used 3 GB of your 15 GB Google Drive" alongside local quota gives users a full picture. |
#### 6. Admin Panel
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| User list with search and pagination | Every admin panel has this | LOW | Show: username, email, registration date, last login, quota used/total, active/inactive status. |
| Create user account | Admin needs to onboard users manually (invite flow) | LOW | Form: email, temp password (force reset on first login), quota assignment. |
| Deactivate / reactivate user | Admin must be able to remove access without deleting data | LOW | Soft disable: user cannot log in; data preserved. Separate from delete. |
| Force password reset | Standard admin action | LOW | Invalidates current sessions; user gets reset email. |
| Quota adjustment per user | Already in PROJECT.md | LOW | Input: new quota in MB. Show current usage. Prevent setting quota below current usage (warn, not block). |
| System-wide default quota setting | Admins want to change the free tier baseline | LOW | Global default that new users inherit. Existing users keep their individually-set quota. |
| AI provider/model assignment per user | Already in PROJECT.md | MEDIUM | Dropdown of configured providers + models. Show "using system default" until overridden. |
| System-wide AI provider default | Already in PROJECT.md | LOW | Global fallback when no user override exists. |
| Audit log viewer | All enterprise DMS products have this | MEDIUM | See Audit section below. |
| Platform health indicators | Admins need to know if things are broken | MEDIUM | Storage backend connectivity, database connection, queue depth. A simple status page at /admin/health. |
#### 7. Audit / Compliance
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Audit log of security events | GDPR, SOC2 baseline, enterprise buyers expect this | MEDIUM | Mandatory events: login, failed login, password reset, 2FA enable/disable, session revoke. |
| Audit log of data events | Same compliance baseline | MEDIUM | Mandatory events: document upload, document delete, document move/rename, share create, share revoke, quota change. |
| Metadata-only audit (no content) | Privacy requirement — DocuVault's stated constraint | LOW | Log: actor_user_id, action, target_resource_id, timestamp, IP address. Never log document content or filenames in the log (filename is sensitive metadata). |
| Admin-viewable audit log with filters | Already in PROJECT.md | MEDIUM | Filter by: user, action type, date range. Export to CSV is expected by compliance-oriented admins. |
| Immutable audit log | Audit logs lose their value if they can be edited | MEDIUM | Append-only table. No UPDATE/DELETE on audit rows. Admin UI shows only read operations. |
| IP address logging | Standard for login events; expected by security teams | LOW | Log IP on all auth events. GDPR note: IP is PII — include in retention policy. |
| Log retention policy | GDPR requires defined retention | LOW | Configurable in admin settings. Default: 1 year. Automated purge of older entries. |
| GDPR data export (user's own data) | GDPR Article 20 right to portability | HIGH | User can request export of all their data: document list + metadata (not necessarily content), account info, audit log of their own actions. Full content export is optional but the metadata export is required. |
#### 8. Document Viewer Features
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| In-browser PDF preview | Drive, Dropbox, Box all have this | MEDIUM | Use browser's native PDF viewer or PDF.js. Without preview, users must download to read — major friction. |
| Document metadata panel | All DMS platforms show this | LOW | Show: upload date, file size, MIME type, AI-assigned topics, uploader, storage backend. |
| AI topics display | Core DocuVault feature | LOW | Show assigned topics with confidence if available. Allow manual topic override from document detail. |
| Download original file | Universal | LOW | Always available. Even if preview fails, download must work. |
| Document rename | Drive, Dropbox allow rename from detail view | LOW | Inline rename in detail view or list view. |
| Delete from detail view | Universal | LOW | With confirmation. Returns to parent folder. |
| "Shared with" indicator on document | Drive shows a "people" icon on shared items | LOW | Visual indicator in list view that document is shared. Click to see who. |
| Basic image preview (JPG, PNG) | All platforms preview images natively | LOW | Browser <img> tag is sufficient. |
| Text file preview | Drive, Dropbox show .txt inline | LOW | Simple <pre> render of extracted text is adequate. |
| Feature | Value Proposition | Complexity | Depends On |
|---------|-------------------|------------|------------|
| Whole-page drop zone (drag files anywhere on the screen to upload) | Removes the requirement to aim at the DropZone rectangle. Paperless-ngx supports this. Implementation: listen for `dragenter` at the document level, show a full-screen overlay with the drop target, then pipe the `drop` event into the existing upload emit chain. | Medium | `FileManagerView.vue`, new `PageDropOverlay.vue`, existing `DropZone` emit chain |
| Skeleton placeholders shaped like the real content (5-column grid rows) | The QuotaBar has a simple pulse bar. The document list skeleton should be 5-column grid rows matching the actual layout — icon placeholder, name placeholder, size placeholder, date placeholder. This makes the transition from loading to content feel instantaneous rather than a layout jump. | Medium | New `SkeletonRow.vue`, `StorageBrowser.vue` |
| Admin dashboard overview page (`/admin` root — stats at a glance) | User count, storage total across all users, recent audit log entries. Gives admins a reason to visit the admin panel rather than jumping straight to sub-sections. Self-hosted tools consistently provide this at the admin root. | Medium | New `AdminOverviewView.vue`, read-only aggregate API endpoints (may require backend work) |
| Inline toast/notification system for upload completion and errors | Currently errors appear in the UploadProgress inline list and are never dismissed. A toast system enables transient success/error messages for any action (rename, delete, share) — not just upload. Constraint: keep it simple, not a full notification center. | Medium | New `ToastManager.vue` in `App.vue`, composable `useToast()` |
| Document count badge polish on topic sidebar links | Already has `{{ topic.doc_count }}` in the sidebar. What's missing: show 0 counts as dimmed rather than hidden (users want to know the topic exists even if empty), and truncate to "9+" at double digits. | Low | `AppSidebar.vue` (minor change) |
| Upload from keyboard shortcut `U` with accessible file picker trigger | The keyboard shortcut fires `triggerInput()` on DropZone. Accessibility requirement: this is equivalent to clicking the hidden file input — screen readers announce the file dialog opening. Already partially supported by `DropZone.vue`'s `triggerInput()` — wire up the global shortcut. | Low | `DropZone.vue`, `FileManagerView.vue` |
| Tablet landscape layout: sidebar + content, no collapse needed | At 1024px (lg breakpoint), the sidebar is fully visible alongside content. This is the natural Tailwind `lg:flex` pattern. Verify it and make the breakpoint logic explicit in the layout wrapper. | Low | `App.vue` layout wrapper |
---
### Differentiators (Competitive Advantage)
## Anti-Features
Features that align with DocuVault's core value (privacy-first, self-hosted, AI-classified).
Features to explicitly NOT build in v0.2.
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Privacy-first admin model (admin cannot read documents) | Unique trust proposition vs Google Drive/OneDrive where the operator can read everything | HIGH | Encryption-at-rest with user-scoped keys; admin queries explicitly exclude content. No other mainstream cloud platform offers this by default. Strong differentiator for privacy-conscious users. |
| Bring-your-own cloud storage backend | Users keep their existing cloud storage; DocuVault is the intelligent layer on top | HIGH | Google Drive/OneDrive are the storage, DocuVault is the classifier/organizer. Removes the "I have to trust another cloud with my docs" objection. |
| AI topic classification on upload (automatic) | No other mainstream DMS auto-classifies by topic on upload | MEDIUM | Paperless-ngx has auto-tagging; DocuWare has rules-based classification. AI-driven flexible topics is differentiated for non-enterprise users. |
| Multiple AI provider support (local/private inference) | Users who want fully on-premises AI (Ollama) get privacy guarantee no cloud DMS offers | MEDIUM | Ollama / LMStudio means documents never leave the user's infrastructure. Strong appeal to privacy-first and regulated-industry users. |
| Per-user topic customization on top of system defaults | Users get personalized classification without admin overhead | MEDIUM | System topics + per-user overrides. No mainstream cloud DMS supports per-user AI taxonomy customization. |
| Share expiry with automatic revocation | Goes beyond basic sharing; prevents forgotten shares | MEDIUM | Dropbox Business and Box have this at paid tiers. Including it at v1 is differentiating for a free-tier self-hosted platform. |
| Explicit share accept/decline | Recipient has control; aligns with privacy-first positioning | LOW | Box has this; Drive doesn't require it. Gives recipients agency. |
| Storage backend per-document routing | Some documents go to Drive, others to local — user decides per-upload | HIGH | No mainstream platform does this. Users with mixed sensitivity needs can route sensitive docs to local and bulk docs to Drive. Complex to implement but uniquely valuable. |
| In-app audit log visible to the user (not just admin) | Users can see their own activity history | LOW | GDPR-aligned; builds trust. Google Activity Dashboard is the model. Most self-hosted DMS don't surface this to users. |
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Folder reorder by drag-and-drop (drag folder A above folder B to change sidebar order) | Requires a persistent `position` column in the DB, a backend migration, and drag state tracking across the sidebar tree. The sidebar is already collapsible. Payoff is marginal. | Keep alphabetical or creation-time ordering. Users can rename folders to impose their own order (e.g., "1. Contracts"). |
| Reorderable sidebar sections (drag "Folders" above "Cloud Storage") | Same DB persistence problem. Section order is fixed and meaningful — user folders first, then external cloud. No self-hosted document manager reorders sections. | Fixed section order in `AppSidebar.vue`. |
| Virtual scrolling for the document list | DocuVault is quota-capped at 100 MB per user. At typical document sizes (10500 KB each), a user has at most a few hundred documents before hitting quota. Virtual scrolling adds significant complexity (dynamic row height, scroll restoration) for an edge case that the quota cap prevents. | Standard `v-for`. Add server-side pagination in a later milestone if needed. |
| Rich text document annotations or in-app editing | Already scoped out in `PROJECT.md`. Mentioned here because UX redesigns often attract "what if we added..." scope creep. The preview modal shows PDF; that is the limit. | Stay read-only. Document content is immutable after upload. |
| Dark mode toggle | Requires a systematic color token system across all components plus full testing of the inverted palette. The current light-mode palette is inconsistent — fix that first. Tailwind makes dark mode mechanically possible but the v0.2 payoff is low relative to scope. | Design tokens and CSS variables in v0.2 that would support dark mode in a future milestone. |
| Drag-and-drop to reorder documents within a folder | Requires a persistent sort order column per user per folder. Documents are naturally sorted by creation date or name — both useful orderings. Custom ordering has high complexity and low frequency of use in a document vault. | Keep the existing sort controls (created_at, name, size). Server-side sort is already implemented. |
| Full-screen modal for file upload | The DropZone is embedded in the file browser content area, which is the correct placement. A modal upload workflow adds navigation overhead with no benefit for a simple file picker. | The whole-page drag overlay (differentiator above) is the right scope. |
| Notification center / inbox for async events | Building a polling-based notification center (classification done, quota warning) is a full feature, not UX polish. Celery classification runs in the background and its result is visible when the user next opens the document. | Toast on upload completion. Badge count on the document list is sufficient for v0.2. |
| Multi-select with batch operations (select 10 files, delete all) | Requires a selection state layer across the file list, a selection-aware toolbar, and confirmed batch-delete UX. Medium-to-high complexity for a feature that is rarely needed in a personal document vault. | Address in a future milestone if user feedback identifies it as a gap. |
| Drag documents from file list to sidebar folder entries | Cross-component drag state requires either a Pinia drag store or provide/inject. The sidebar `FolderTreeItem` and `StorageBrowser` have no shared parent that can coordinate state without significant refactoring. The "Move to folder" picker button already covers this use case. | The existing folder picker button in the file row actions menu. |
---
### Anti-Features (Deliberately Excluded)
## Admin Panel: Specific Pattern Decisions
Features that seem good but would create problems for this project.
| Feature | Why Requested | Why Problematic | Alternative |
|---------|---------------|-----------------|-------------|
| Public link sharing (unauthenticated) | Users want to share with people not on the platform | Creates a public attack surface; quota abuse; legal exposure if abused; hard to revoke at scale | Out of scope for v1 per PROJECT.md. Named-user sharing with accept/decline serves legitimate use cases. |
| In-app document editing | Users want a full office suite | Massive scope expansion (collaborative editing = CRDT, OT, conflict resolution); not core to document management; every vendor locks you into their editor | "View and organize" is the value; editing stays in the originating app. |
| Real-time collaborative editing (Google Docs model) | Feels modern | Requires WebSocket infrastructure, OT/CRDT algorithms, presence indicators — easily 3x the codebase complexity of everything else combined | Explicit non-goal; DocuVault stores files, not live documents. |
| Mobile app (iOS/Android) | Users want mobile access | React Native or Flutter doubles the implementation surface; mobile OAuth2 and background sync are non-trivial | Responsive web app is the minimum. PWA capabilities (offline-capable via service worker) are a future v2 differentiator. |
| SSO / SAML / OAuth enterprise federation | Enterprise buyers ask for it | Premature: adds Keycloak or similar dependency, requires session model changes, needs testing against multiple IdPs | TOTP 2FA first; SSO when subscription billing lands. Schema already designed for extension. |
| Subscription billing in-platform | Users want self-service upgrades | Payment processing is a separate product (Stripe integration, dunning, invoicing, tax). Doing it half-way creates billing bugs that destroy trust | Quota model is billing-ready (limit_bytes column); add Stripe when subscription tier is validated. |
| Soft-delete / Trash with restore | Users accidentally delete things | Reasonable feature, but complicates quota accounting (does trash count against quota?), storage lifecycle, and purge timing. Drive's 30-day trash has caused user confusion about "why is my quota still full?" | Clear confirmation dialog before hard delete is the v1 approach. Trash can be added in v1.x. |
| Document version history | Users want to see old versions | Correct for collaborative tools; for a personal document store where users own their files, versioning explodes storage use. Paperless-ngx doesn't have it. DocuWare does but it's enterprise | Hash-based deduplication (don't store the same file twice) is a better v1 primitive. Version history in v2 for users who need it. |
| AI-generated document summaries | Seems valuable given AI integration | Every summary requires an AI call → cost scales with uploads; summaries become stale when topics change; storing summaries consumes quota | AI is used for classification only. Summary generation can be a user-triggered on-demand action later. |
| Admin impersonation / "log in as user" | Admins want to debug user issues | Directly contradicts the privacy-first core value. If admin can impersonate, admin can access user documents. Trust collapses. | Structured audit logs give admins enough to diagnose issues without impersonation. Document this as an explicit architectural decision. |
---
## Feature Dependencies
### Route structure
```
[User Registration + Auth]
└──requires──> [Email verification] (optional at v1, needed for password reset)
└──requires──> [JWT session management]
└──requires──> [Token revocation list] (for forced logout)
[TOTP 2FA]
└──requires──> [Backup codes] (users get locked out without these)
└──requires──> [User Auth base]
[Document Upload]
└──requires──> [User Auth]
└──requires──> [Quota enforcement]
└──requires──> [Quota tracking (DB)]
[Folder Structure]
└──requires──> [Document Upload]
└──enhances──> [Search] (search within folder scope)
[Document Sharing]
└──requires──> [User accounts] (both sender and recipient)
└──requires──> [Folder Structure] ("Shared with me" virtual folder)
└──requires──> [Permission model in DB]
[Share expiry]
└──requires──> [Document Sharing base]
└──requires──> [Background job / cron] (to revoke expired shares)
[Cloud Storage Integration]
└──requires──> [User Auth] (OAuth2 tokens stored per user)
└──requires──> [Credential encryption] (Fernet / pgcrypto)
└──requires──> [StorageBackend adapter interface] (already planned)
└──enhances──> [Quota tracking] (cloud quota shown separately)
[Per-document storage routing]
└──requires──> [Cloud Storage Integration]
└──requires──> [Multiple backends connected]
[Admin Panel]
└──requires──> [User accounts]
└──requires──> [Quota DB model]
└──requires──> [Audit log]
[Audit Log]
└──requires──> [User Auth] (actor_user_id)
└──requires──> [All logged operations exist]
└──conflicts──> [Admin impersonation] (impersonation breaks log integrity)
[GDPR data export]
└──requires──> [Audit log]
└──requires──> [Document metadata model]
└──requires──> [Background export job] (large exports are async)
[AI classification]
└──requires──> [Document upload + text extraction] (already exists)
└──requires──> [Topic model] (already exists)
└──enhances──> [Search] (topic-filtered search)
[In-browser PDF preview]
└──requires──> [Document stored accessibly] (MinIO presigned URL or proxy endpoint)
└──independent of──> [Cloud storage] (preview proxied through app, not direct cloud URL)
/admin → AdminLayout.vue wrapper (persistent sidebar + router-view)
/admin/ → AdminOverviewView.vue (stats at a glance)
/admin/users → AdminUsersView.vue (wraps AdminUsersTab.vue)
/admin/quotas → AdminQuotasView.vue (wraps AdminQuotasTab.vue)
/admin/ai → AdminAiConfigView.vue (wraps AdminAiConfigTab.vue)
/admin/audit → AdminAuditView.vue (wraps AuditLogTab.vue)
```
### Dependency Notes
The existing tab components (`AdminUsersTab.vue`, `AdminQuotasTab.vue`, `AdminAiConfigTab.vue`, `AuditLogTab.vue`) are promoted to route-level views. The `AdminLayout.vue` replaces `AdminView.vue`. The tab strip becomes a sidebar with router-link active styling. No logic changes inside the tab components — only the container changes. `AdminView.vue` is deleted after the migration.
- **TOTP requires backup codes:** Without backup codes, users who lose their phone lose their account permanently. This is a support nightmare and a documented UX failure in many 2FA implementations.
- **Forced logout requires server-side token tracking:** Pure stateless JWT cannot support "sign out all devices." A revocation list (Redis or DB table) or short-lived JWTs (15 min) + refresh token table is required. This is a non-trivial architecture decision that must be resolved before auth is implemented.
- **Share expiry requires a background job:** Expiry cannot be purely DB-enforced. A cron job or Celery task must scan and revoke expired shares, or each share-check must evaluate expiry at access time (lazy revocation — simpler, acceptable for v1).
- **Per-document storage routing is independent of the default storage selector:** Default storage is "which backend do new uploads go to by default?" Routing is "for this specific upload, override the default." Routing is a differentiator; default selector is table stakes.
- **Admin impersonation conflicts with privacy-first model:** These two features are architecturally incompatible. Document this as an explicit decision in PROJECT.md.
- **GDPR export is async:** Exporting all user data (documents + metadata) can take minutes for large accounts. Must be a background job with "email me when ready" — not a synchronous HTTP download.
Vue Router 4 nested routes pattern (HIGH confidence — verified against official docs):
```js
{
path: '/admin',
component: AdminLayout, // contains <router-view />
meta: { requiresAdmin: true },
children: [
{ path: '', component: AdminOverviewView },
{ path: 'users', component: AdminUsersView },
{ path: 'quotas', component: AdminQuotasView },
{ path: 'ai', component: AdminAiConfigView },
{ path: 'audit', component: AdminAuditView },
],
}
```
The `requiresAdmin` guard moves from the child route to the parent. All children inherit it automatically.
### Admin sidebar nav items (in order)
1. Overview
2. Users
3. Quotas
4. AI Config
5. Audit Log
A "Back to app" link at the bottom of the admin sidebar returns to `/` — admin should not feel like a dead end.
### Admin sidebar does NOT share code with `AppSidebar.vue`
`AdminSidebar.vue` is a separate component. It has no folder tree, no cloud tree, no topics list, no quota bar. Those belong to the user interface. Sharing the sidebar component would create coupling between two logically separate contexts and conflict with the "thin view + smart component" architecture described in CLAUDE.md.
### Breadcrumb
Use the existing `FolderBreadcrumb.vue` or a simpler static variant: `Admin > [Section Name]`. No dynamic nesting required. The admin sidebar's active link already signals location; the breadcrumb adds skimmability for users who navigate via URL.
---
## MVP Definition (This Milestone)
## Empty State Patterns: Specific Decisions
This is a subsequent milestone — the core document + AI system exists. MVP for this milestone is everything in PROJECT.md Active section, implemented in a way that doesn't create rework.
Each zero-content context gets a distinct implementation. Generic "No items" text is the anti-pattern.
### Must Ship (v1 — this milestone)
| Context | Headline | Subtext | CTA |
|---------|----------|---------|-----|
| Root folder (no folders yet) | "Start organizing your documents" | "Create a folder to group related files together." | "New folder" button |
| Folder is empty (exists but has no contents) | "This folder is empty" | "Upload files here or move documents into this folder." | "Upload files" link (triggers DropZone click) |
| Search returns no results | "No matches for '{query}'" | "Try a different search term or clear your search." | "Clear search" button |
| Shared with me (no shares yet) | "Nothing shared with you yet" | "When someone shares a document with your handle, it appears here." | None (passive state) |
| Topics list empty | "No topics yet" | "Topics are created automatically when you upload and classify a document." | None |
| Audit log (admin, no entries yet) | "No audit events recorded" | "Events are recorded when users log in, upload documents, or make changes." | None |
| Cloud storage — no connections | "No cloud storage connected" | "Connect OneDrive, Google Drive, Nextcloud, or WebDAV in Settings." | "Open Settings" link |
- [ ] Email + password registration with strength validation — table stakes, nothing works without it
- [ ] JWT sessions with refresh tokens + forced logout capability — security baseline
- [ ] TOTP 2FA with backup codes — PROJECT.md requires it; backup codes prevent lockouts
- [ ] Password reset via email — users will forget passwords
- [ ] Per-user isolated storage (MinIO prefix per user ID) — without this, multi-user is unsafe
- [ ] Quota tracking (DB) + enforcement at upload + visible indicator — table stakes for any quota system
- [ ] Quota warnings at 80% and 95% — prevents users hitting the wall blindly
- [ ] Create / rename / delete folders with breadcrumb navigation — table stakes file management
- [ ] Move document to folder (context menu minimum; drag-drop in v1.x) — without this folder structure is useless
- [ ] Sort by name/date/size — basic list management
- [ ] Global search (metadata + extracted text) — without this the product is a dumb file cabinet
- [ ] Share document with named user by handle (view-only default) — core sharing primitive
- [ ] Revoke share with immediate effect — security; users must be able to undo shares
- [ ] "Shared with me" virtual folder — PROJECT.md requires it
- [ ] Cloud storage connect flow (Google Drive + OneDrive OAuth2) — PROJECT.md requires it
- [ ] Connection status indicator + error state with local fallback — users need to know if cloud is broken
- [ ] Default storage backend selector — users need to control where uploads go
- [ ] Credential encryption (Fernet + env key) — PROJECT.md hard requirement
- [ ] Admin: user list, create, deactivate, quota adjustment — PROJECT.md requires it
- [ ] Admin: AI provider/model assignment per user + system default — PROJECT.md requires it
- [ ] Audit log (append-only): auth events + data events — PROJECT.md requires it
- [ ] Admin audit log viewer with date/user/action filters — PROJECT.md requires it
- [ ] In-browser PDF preview (PDF.js or native) — without preview, users must download to read
- [ ] Document metadata panel (size, date, topics, storage backend) — contextual information users expect
Rule: every empty state that the user can resolve with an action gets exactly one CTA. Empty states that are passive (shared-with-me before anyone shares, topics before first upload, audit before any events) get explanatory subtext only — no CTA that leads nowhere useful.
### Add After Core Validated (v1.x)
- [ ] Share expiry date — valuable for security-conscious users; requires background job
- [ ] Explicit share accept/decline — adds trust; low complexity
- [ ] "What I've shared" global list in settings — users need to audit their shares
- [ ] In-app share notification — without email infrastructure, in-app is the fallback
- [ ] Storage usage breakdown by folder — power user feature; quota bar is sufficient at v1
- [ ] CSV export of audit log — compliance teams want this; not day-one critical
- [ ] Log retention policy configuration — needed for GDPR compliance; default 1yr is fine for v1
- [ ] Drag-and-drop document move — UX polish; context menu is the functional minimum
- [ ] "Recent documents" virtual view — convenience; not blocking
### Future (v2+)
- [ ] GDPR data export (Article 20) — required eventually; complex (async job); defer until user base exists
- [ ] Per-document storage routing (override default per upload) — strong differentiator but complex; v2
- [ ] User-facing activity log (own audit trail) — GDPR-aligned trust feature; v2
- [ ] Soft-delete / Trash with restore — adds quota accounting complexity; solve properly in v2
- [ ] Document version history — storage-intensive; needs a clear retention policy first
- [ ] SSO / SAML — per PROJECT.md, after subscription billing
- [ ] Public link sharing — per PROJECT.md, explicitly out of scope for v1
- [ ] Platform health dashboard at /admin/health — operational convenience; Docker healthchecks handle v1
Implementation: extract a shared `EmptyState.vue` presentational component that accepts `headline`, `subtext`, and an optional `action` slot. Use it in all seven contexts above instead of duplicating the layout.
---
## Feature Prioritization Matrix
## Loading State Patterns: Specific Decisions
| Feature | User Value | Implementation Cost | Priority |
|---------|------------|---------------------|----------|
| Per-user auth (registration, login, JWT) | HIGH | LOW | P1 |
| TOTP 2FA + backup codes | HIGH | MEDIUM | P1 |
| Password reset | HIGH | LOW | P1 |
| Quota tracking + enforcement + indicator | HIGH | LOW | P1 |
| Quota warnings (80%, 95%) | HIGH | LOW | P1 |
| Folder CRUD + breadcrumb | HIGH | LOW | P1 |
| Move document to folder | HIGH | LOW | P1 |
| Global search | HIGH | MEDIUM | P1 |
| Share with named user (view-only) | HIGH | MEDIUM | P1 |
| Revoke share | HIGH | LOW | P1 |
| "Shared with me" folder | HIGH | LOW | P1 |
| Cloud connect flow (OAuth2) | HIGH | HIGH | P1 |
| Connection status + error state + local fallback | HIGH | MEDIUM | P1 |
| Default storage selector | HIGH | LOW | P1 |
| Credential encryption | HIGH | MEDIUM | P1 |
| Admin user management (list/create/deactivate/quota) | HIGH | LOW | P1 |
| Admin AI config | MEDIUM | LOW | P1 |
| Audit log (append-only) | HIGH | MEDIUM | P1 |
| Admin audit log viewer | MEDIUM | MEDIUM | P1 |
| In-browser PDF preview | HIGH | MEDIUM | P1 |
| Document metadata panel | MEDIUM | LOW | P1 |
| Forced logout / session revocation | MEDIUM | MEDIUM | P2 |
| Share expiry date | MEDIUM | MEDIUM | P2 |
| Explicit share accept/decline | MEDIUM | LOW | P2 |
| "What I've shared" list | MEDIUM | LOW | P2 |
| Storage usage breakdown by folder | MEDIUM | MEDIUM | P2 |
| CSV export of audit log | MEDIUM | LOW | P2 |
| Log retention policy | MEDIUM | LOW | P2 |
| Drag-and-drop move | MEDIUM | MEDIUM | P2 |
| Recent documents view | LOW | LOW | P2 |
| Per-document storage routing | HIGH | HIGH | P3 |
| GDPR data export | MEDIUM | HIGH | P3 |
| Soft-delete / Trash | MEDIUM | HIGH | P3 |
| User-facing activity log | MEDIUM | LOW | P3 |
| Document version history | LOW | HIGH | P3 |
| Platform health dashboard | LOW | MEDIUM | P3 |
| Component | Current | Target | Rationale |
|-----------|---------|--------|-----------|
| Document list (`StorageBrowser`) | `<div>Loading…</div>` text | 68 skeleton rows (5-col grid, `animate-pulse`) | List loads on every folder navigation. Skeletons match the grid layout exactly, preventing layout jump. |
| Sidebar folder tree | `<div>Loading…</div>` text | 3 skeleton nav items (indent + placeholder bar) | Sidebar loads once on mount. Short skeleton is sufficient. |
| Sidebar topics list | `<div>Loading…</div>` text | 4 skeleton topic pills (color dot + bar) | Same rationale as folder tree. |
| Admin user table | No loading state | Skeleton table rows (35 rows, matching column count) | Admin tables are data-heavy; skeleton preserves table chrome during load. |
| Audit log table | No loading state | Skeleton rows matching timestamp/user/action columns | Same. |
| Upload progress item | Spinner + progress bar (correct) | Keep as-is | Upload is a blocking action, not a content load. Spinners are correct for actions. |
| QuotaBar | `animate-pulse` bar (correct) | No change | Already implemented correctly. Document as the canonical skeleton pattern. |
| Document preview modal | None | Spinner centered in modal body | PDF proxy fetch is a single-request action. Spinner is correct. |
Rule: if the loading UI represents content the user will read (list, table, tree), use a skeleton. If the loading UI represents a system action (save, upload, submit, process), use a spinner. Never show a loading state that will be visible for less than 300ms — add a minimum display delay.
---
## Competitor Feature Analysis
## Drag-and-Drop: Scope Boundaries
| Feature | Google Drive | Dropbox | Box | Paperless-ngx | DocuVault Plan |
|---------|--------------|---------|-----|----------------|----------------|
| Share by named user | Yes (email) | Yes (email) | Yes (email + internal) | No (single-user) | Yes (by handle) |
| Share permission levels | View / Comment / Edit | View / Edit | View / Edit / Co-owner | N/A | View-only v1; edit later |
| Share expiry | Google Workspace only | Business+ only | Business+ free tier | N/A | Include in v1.x — differentiator at free tier |
| Public link sharing | Yes | Yes | Yes | No | Explicitly excluded v1 |
| Quota indicator | Yes (prominent) | Yes | Yes | N/A (local disk) | Yes — progress bar + text |
| Quota warnings | Yes (email at 80%) | Yes (email at 90%) | Yes | N/A | In-app banner at 80%, 95% |
| Folder organization | Yes (arbitrary depth) | Yes | Yes | Yes | Yes (arbitrary depth) |
| Drag-and-drop move | Yes | Yes | Yes | Yes | v1.x (context menu at v1) |
| Global search | Yes (full-text) | Yes (full-text) | Yes (full-text) | Yes (extracted text) | Yes (extracted text — already extracted) |
| AI classification | No (manual labels) | No | No | Basic rules-based | Yes — core differentiator |
| Audit log (admin) | Google Workspace | Business+ | All tiers | No | Yes — all tiers |
| TOTP 2FA | Yes | Yes | Yes | Yes | Yes |
| Backup codes | Yes | Yes | Yes | Yes | Yes — required with TOTP |
| Bring-your-own storage | No | No | No | Partially (local FS) | Yes — core differentiator |
| Privacy-first admin model | No (Google can read) | No | No | N/A (self-hosted) | Yes — core differentiator |
| In-browser PDF preview | Yes | Yes | Yes | Yes | Yes (PDF.js) |
| Document version history | Yes | Yes (30 days free) | Yes (30 days free) | No | v2 |
| GDPR data export | Yes (Google Takeout) | Yes | Yes | N/A | v2 |
### Build in v0.2
1. **Whole-page drop overlay for file upload** — listen for `dragenter` at the document level, show a full-screen overlay, delegate the `drop` event to the existing DropZone emit chain. The drop must only activate when files are being dragged (check `e.dataTransfer.types.includes('Files')`).
2. **Move a document to a folder by dragging the file row onto a folder row** — already implemented in `StorageBrowser.vue`. Verify it functions correctly and polish the drop highlight: `ring-2 ring-inset ring-amber-300` on the folder row is correct. Ensure the file row shows reduced opacity during drag (`opacity-50` already applied).
### Do Not Build in v0.2
- Dragging files from the file list onto sidebar folder entries (requires cross-component drag state via Pinia or provide/inject — the "Move to folder" picker already covers this)
- Touch drag-and-drop on mobile (HTML5 drag events do not fire on iOS Safari without a polyfill library; defer to the move picker button)
- Drag folders to reorder them
### Required for every drag interaction
- Visual affordance on dragover (already done on DropZone; add to whole-page overlay)
- Non-drag alternative for every drag action: the "Move to folder" picker button covers the drag-to-move case
- Drop must be clearly confirmed: file row disappears from source location and appears in target on success
---
## Critical UX Patterns to Follow
## Keyboard Shortcuts: Final List
### Quota UX (from Drive / Dropbox study)
| Shortcut | Action | Condition |
|----------|--------|-----------|
| `/` | Focus search bar | Not in an input, textarea, or contenteditable |
| `Escape` | Close modal or clear active inline input | Modal open, or search focused with content |
| `U` | Trigger file picker (upload) | Not in any input, on a folder/file manager view |
| `N` | Start new folder inline input | Not in any input, on a folder/file manager view |
**Sidebar quota bar pattern** (Drive): Always-visible storage indicator at the bottom of the left sidebar. Shows "X.X MB of Y MB used" with a color-coded bar (green → yellow → red as quota fills). This is the pattern users are conditioned to expect.
### Do Not Build
**Upload rejection UX**: Never show a generic error. Show: current usage, quota limit, how much the rejected file would have added, and a direct link to storage settings. Dropbox does this well; many self-hosted tools fail here.
**Quota warning banner**: Non-modal, dismissable, but persistent. Appears at 80% — amber. At 95% — red. At 100% — blocks upload with inline error (not just a banner).
### Folder UX (from Drive / Explorer pattern)
**Breadcrumb is mandatory**: Any folder deeper than one level without a breadcrumb creates navigation confusion. Users instinctively hit "back" in their browser and are surprised to leave the app.
**Empty folder state**: Show a clear empty state ("This folder has no documents yet. Upload one to get started.") — not a blank white space.
**Delete folder confirmation must list contents**: "Are you sure? This will permanently delete 47 documents." — not just a generic "are you sure?"
### Sharing UX (from Box / Drive)
**Share dialog anti-pattern to avoid**: Auto-sending share notifications without preview. Drive's "A notification will be sent" with an optional message is the correct pattern — user controls whether to notify.
**Shared document visual indicator**: An icon overlay (people icon) in list view on shared items. Without this, users lose track of what they've shared. Drive and Box both do this.
**Revoke is immediate and feedback is given**: "Access revoked for user@handle" toast. Not silent.
### Cloud Integration UX (from Dropbox Business / Google Drive)
**Connection health must be persistent**: Don't only show errors at connect time. Show ongoing status in storage settings. A disconnected cloud backend that silently fails uploads is a data-loss scenario.
**Token expiry is silent without handling**: OAuth2 tokens expire. If the app doesn't handle refresh silently (or at least alert the user clearly), users will think their uploads succeeded when they didn't. **This is a critical pitfall.**
- `J`/`K` for row-by-row navigation — requires focus management, ARIA selected state, and keyboard event bubbling control. Over-engineered for a vault where opening a document is secondary to browsing and uploading.
- `Ctrl+A` / `Cmd+A` for select all — requires multi-select, which is an anti-feature.
- Arrow key tree navigation in the sidebar — screen readers handle this natively; custom arrow-key navigation would conflict with browser defaults.
- `?` shortcut for a shortcut reference modal — too few shortcuts to warrant a help overlay.
---
## What We Might Miss (Gap Analysis)
## Responsive Layout: Breakpoint Decisions
Items that competitors have which aren't in PROJECT.md Active requirements and are easy to overlook:
| Viewport | Tailwind breakpoint | Sidebar | Document list | Admin |
|----------|---------------------|---------|--------------|-------|
| Mobile portrait | `< 640px` (below `sm`) | Hidden; hamburger button + overlay drawer | Icon + name only; Size (`hidden md:block`) and Modified (`hidden sm:block`) hidden | Same drawer |
| Mobile landscape / small tablet | `640767px` (`sm`) | Hidden; hamburger still active | Icon + name + Modified visible; Size hidden | Same |
| Tablet portrait | `7681023px` (`md`) | Hidden; hamburger still active | Full 5-col grid | Full columns |
| Tablet landscape / small desktop | `1024px+` (`lg`) | Always visible (256px fixed) | Full 5-col grid | Full admin sidebar + content |
| Desktop | `1280px+` (`xl`) | Always visible | Full grid + comfortable padding | Full layout |
1. **Backup codes for TOTP** — PROJECT.md mentions TOTP but not backup codes. Without backup codes, a lost phone = permanently locked-out account. Every 2FA implementation must include these. HIGH severity omission.
Key decision: sidebar collapses at the `lg` breakpoint (1024px), using `hidden lg:flex` on the sidebar and `lg:hidden` on the hamburger button. At 1024px the sidebar (256px) leaves 768px for content — sufficient for the 5-column grid with room for padding.
2. **Quota warning thresholds** — PROJECT.md says "quota enforced; uploads rejected." It doesn't mention pre-emptive warnings at 80%/95%. Users who hit the wall without warning give negative reviews. Easy to implement; easy to forget.
Touch targets: all inline action icon buttons are currently `p-1.5` (approximately 30px). Increase to `p-2` on `sm:` and below using responsive padding classes. 44px is the WCAG target — not achievable for inline buttons without breaking the grid layout, but 36px is a reasonable minimum.
3. **Session revocation / forced logout** — Not in PROJECT.md. JWT-based auth has no built-in revocation. If a user believes their account is compromised, they need "sign out everywhere." Requires either short-lived JWTs + refresh token table, or a server-side revocation list.
---
4. **Breadcrumb navigation** — Easy to forget in folder implementation. The folder CRUD is in PROJECT.md but the navigation UX isn't. Without breadcrumbs, nested folders become unusable.
## Feature Dependencies (summary)
5. **"What I've shared" list** — PROJECT.md covers sharing mechanics but doesn't cover share auditability from the sharer's perspective. Users who've shared many documents need a way to manage them all.
```
Admin route subtree
→ AdminLayout.vue (new) contains AdminSidebar.vue (new) + <router-view>
→ Child views wrap existing AdminUsersTab, AdminQuotasTab, AdminAiConfigTab, AuditLogTab
→ AdminView.vue deleted after migration
6. **Upload error when cloud backend is unreachable** — PROJECT.md says "documents stored in cloud backend accessed via app." What happens if the cloud backend is down at upload time? Needs explicit handling: local fallback with a flag, or queue with retry, or reject with explanation. Silence = data loss.
Empty states
→ New EmptyState.vue (presentational, accepts headline/subtext/action slot)
→ Used in StorageBrowser.vue, SharedView.vue, TopicsView.vue, CloudStorageView.vue, AuditLogTab.vue
7. **MinIO presigned URL vs proxy for document access** — Not a feature gap but an architecture gap that affects features: if documents in cloud backends are accessed via presigned URLs, the URL leaks the storage path. If proxied through the app, privacy is preserved but adds load to the backend. For a privacy-first platform, proxy is the correct choice — but it must be a conscious decision before the cloud integration is built.
Skeleton rows
→ New SkeletonRow.vue (5-col grid, animate-pulse)
→ Used in StorageBrowser.vue (document list), AppSidebar.vue (folder tree, topics list)
8. **Empty folder state and confirmation dialogs** — Micro-UX that competitors all get right. Easy to skip in implementation. Users notice.
Keyboard shortcuts
→ Global keydown listener in FileManagerView.vue
→ SearchBar.vue exposes focus() method
→ DropZone.vue exposes triggerInput() (already exists as triggerInput())
→ StorageBrowser.vue exposes startNewFolder() (already defineExposed)
9. **Shared document icon in list view** — Small visual indicator but prevents users from losing track of what they've shared. Three lines of CSS; easy to miss in sprint planning.
Mobile sidebar
→ App.vue holds sidebarOpen boolean state
→ AppSidebar.vue accepts isOpen prop + emits close
→ Hamburger button in App.vue header (mobile only, hidden lg:hidden)
10. **Admin cannot set quota below current usage without warning** — Obvious in hindsight but easy to leave as a silent truncation. Must warn and require explicit confirmation.
Whole-page drop overlay
→ New PageDropOverlay.vue (full-screen overlay, accepts active prop)
→ FileManagerView.vue listens for document dragenter/dragleave
→ On drop, delegates to existing upload flow via onFilesSelected
```
---
## Sources
- Google Drive feature set: training knowledge through Aug 2025 (stable, well-documented product)
- Microsoft OneDrive feature set: training knowledge through Aug 2025
- Dropbox feature set: training knowledge through Aug 2025
- Box feature set: training knowledge through Aug 2025 (enterprise DMS reference)
- Notion: training knowledge through Aug 2025 (organization patterns)
- Paperless-ngx: training knowledge through Aug 2025 (self-hosted DMS reference — open source, well-documented)
- DocuWare: training knowledge through Aug 2025 (enterprise DMS reference)
- Web fetch and web search unavailable during this research session; confidence reduced to MEDIUM for specific UI detail claims; HIGH for feature existence claims (these are all mature, stable platforms)
---
*Feature research for: DocuVault — SaaS Document Management Platform*
*Researched: 2026-05-21*
- Vue Router 4 nested routes: https://router.vuejs.org/guide/essentials/nested-routes
- Skeleton screens vs. spinners UX: https://www.onething.design/post/skeleton-screens-vs-loading-spinners
- Skeleton loading screen design: https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/
- SaaS empty state patterns: https://pixxen.com/saas-empty-state-design/
- IBM Carbon Design System empty states: https://carbondesignsystem.com/patterns/empty-states-pattern/
- Drag-and-drop SaaS UX: https://www.eleken.co/blog-posts/drag-and-drop-ui
- Drag-and-drop NN/g guidelines: https://www.nngroup.com/articles/drag-drop/
- Responsive SaaS breakpoints: https://tryhoverify.com/blog/responsive-design-breakpoints-2024-guide/
- Keyboard shortcut design for web apps: https://sashika.medium.com/j-k-or-how-to-choose-keyboard-shortcuts-for-web-applications-a7c3b7b408ee
- Nextcloud layout patterns: https://docs.nextcloud.com/server/latest/developer_manual/design/layout.html
- Paperless-ngx frontend architecture: https://deepwiki.com/paperless-ngx/paperless-ngx/2-frontend-architecture
+205 -465
View File
@@ -1,550 +1,290 @@
# Pitfalls Research
# Domain Pitfalls — v0.2 UI Overhaul and Optimization
**Domain:** Multi-user SaaS document management platform (FastAPI + Vue 3, PostgreSQL + MinIO, cloud storage integrations)
**Researched:** 2026-05-21
**Confidence:** HIGH (auth/storage/multi-tenancy patterns are well-established; specific FastAPI + MinIO combination is MEDIUM — no web search available)
**Project:** DocuVault
**Milestone:** v0.2 — UI Overhaul and Optimization on existing FastAPI + Vue 3 app
**Researched:** 2026-06-07
**Confidence:** HIGH (all pitfalls grounded in actual source files: router/index.js, App.vue, main.py, client.js, StorageBrowser.vue, DocumentCard.vue, DropZone.vue, AppSidebar.vue, and all api/*.py router files)
---
## Critical Pitfalls
### Pitfall 1: JWT in localStorage — XSS Gives Full Account Takeover
Mistakes that cause regressions, security gaps, or rewrites.
---
### Pitfall 1: FastAPI Router Splitting — Prefix Already Embedded in APIRouter, Not in include_router
**Phase:** Backend refactoring (split large router files)
**What goes wrong:** Every existing router in this codebase declares its prefix on the `APIRouter` instance itself — `router = APIRouter(prefix="/api/documents", tags=["documents"])` — NOT on the `include_router(router, prefix=...)` call in `main.py`. When splitting a large file (e.g. `documents.py` at 852 lines, `admin.py` at 934 lines) into sub-modules, the new sub-routers pick up their endpoints via an `include_router` call inside the original module, or they are wired directly in `main.py`. Either way, if you add a prefix on `include_router` in addition to the prefix on the inner `APIRouter`, all URLs double-prefix to `/api/documents/api/documents/...`. Likewise, if you strip the prefix from the inner router and forget to add it to `include_router`, all URLs return 404.
**Why it happens:** The original pattern (`router = APIRouter(prefix=...)` in the module + `app.include_router(router)` without a prefix in `main.py`) is unusual but consistent in this project. Developers splitting a file expect the standard pattern where prefixes go on `include_router`, not on the router definition.
**Consequences:** 404 on all endpoints in the split module; existing tests and the frontend `client.js` (which hard-codes `/api/documents`, `/api/admin`, etc.) silently break.
**Prevention:** When splitting, create sub-routers with NO prefix: `sub = APIRouter(tags=["documents-upload"])`. Register them inside the parent module with `router.include_router(sub)` so the parent's prefix propagates. The parent module keeps its existing prefix and its existing `app.include_router(parent)` call in `main.py` is unchanged.
**Detection warning signs:** Any test that calls `/api/documents/*` returns 404 immediately after the split; Swagger UI shows doubled path segments.
---
### Pitfall 2: FastAPI Router Splitting — Circular Import via Shared Dependencies
**Phase:** Backend refactoring
**What goes wrong:** `folders.py` already exports a `document_move_router = APIRouter(prefix="/api/documents")` — a cross-cutting concern documented in the file (line 439-442). When splitting `documents.py`, if the new sub-module imports anything from `api/folders.py` (e.g. a shared validator or Pydantic schema), Python raises `ImportError: cannot import name X` at startup because both modules are in partial initialization state.
**Why it happens:** FastAPI modules that share models and validators tend to grow cross-references. The `document_move_router` in `folders.py` under the `/api/documents` prefix is the existing example — any new import in the opposite direction closes the import cycle.
**Consequences:** Application fails to start. Docker container exits immediately.
**Prevention:** Shared Pydantic models and validators must live in `db/models.py` or a dedicated `schemas/` module — not in router files. Before splitting, grep for any `from api.X import` inside the router files being split and move the shared symbols to a neutral module first.
**Detection warning signs:** `ImportError` or `cannot import name` in the uvicorn startup log.
---
### Pitfall 3: AdminLayout Vue Router Integration — beforeEach Guard Misses /admin/* Children
**Phase:** Admin panel rearchitecture
**What goes wrong:** The current guard in `router/index.js` (lines 81-93) checks `to.meta.requiresAdmin` on the matched route object. The current `/admin` route is a flat route with that meta set directly. When `/admin` becomes a parent route with nested children (`/admin/users`, `/admin/audit`, etc.), the children do NOT automatically inherit `meta: { requiresAdmin: true }` from the parent in Vue Router 4 unless the guard iterates `to.matched`. If the children are added without repeating the meta, the guard's `to.meta.requiresAdmin` check evaluates to `undefined` (falsy) for every child route — non-admin users can navigate to `/admin/users` directly.
**Why it happens:** Vue Router 4 does NOT merge parent `meta` into child `meta` by default. `to.meta` reflects only the deepest matched route's own meta, not the parent's.
**Consequences:** Security regression — the admin guard that currently works correctly stops protecting all admin child routes. Non-admin users can access `/admin/users`, `/admin/audit`, etc.
**Prevention:** Update the guard to check `to.matched.some(r => r.meta.requiresAdmin)` instead of `to.meta.requiresAdmin`. This is the correct Vue Router 4 idiom for inherited meta and requires one change to the guard. Apply the same fix to the `!to.meta.public` check if any auth routes gain children.
```javascript
// router/index.js — change:
if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') {
// to:
if (to.matched.some(r => r.meta.requiresAdmin) && authStore.user?.role !== 'admin') {
```
---
### Pitfall 4: AdminLayout Integration — App.vue Layout Switch Breaks for /admin/* Routes
**Phase:** Admin panel rearchitecture
**What goes wrong:** `App.vue` currently switches between `AuthLayout` (when `route.meta.layout === 'auth'`) and the main app shell (sidebar + `<router-view>`) for everything else. The admin panel needs a third layout (`AdminLayout`) with its own nav. The naive approach — `v-else-if="route.meta.layout === 'admin'"` in `App.vue` rendering `AdminLayout` which then renders `<router-view />` — creates double rendering: `App.vue`'s `<router-view>` already resolved the full route. The inner `<router-view>` inside `AdminLayout` gets nothing because the route is already consumed.
**Consequences:** Admin layout renders with an empty content area, or the admin page renders without the admin chrome.
**Prevention:** The correct structure for a standalone admin layout in Vue Router 4:
```javascript
// router/index.js
{
path: '/admin',
component: AdminLayout, // resolves to the layout shell, which contains <router-view />
meta: { requiresAdmin: true },
children: [
{ path: '', redirect: '/admin/users' },
{ path: 'users', component: AdminUsersView },
{ path: 'quotas', component: AdminQuotasView },
{ path: 'ai', component: AdminAiView },
{ path: 'audit', component: AdminAuditView },
]
}
```
`AdminLayout.vue` contains `<router-view />` where page content renders. `App.vue` remains unchanged — it renders the component that the route resolves to, which is now `AdminLayout`. No third branch in `App.vue` is needed.
---
### Pitfall 5: client.js Splitting — noRefreshPaths Breaks When Paths Move to Sub-modules
**Phase:** Frontend refactoring (client.js split)
**What goes wrong:** `client.js` has a hardcoded `noRefreshPaths` list inside `request()` (line 26) that guards against infinite refresh loops. If `request()` is moved to a shared `api/core.js` module and the path list stays there, it works. But the three blob-download functions (`adminExportAuditLogCsv`, `adminDownloadDailyExport`, `fetchDocumentContent`) each contain a duplicated copy of the 401-refresh-retry logic. When splitting `client.js` into domain files, those duplicates will be copied into new sub-files. This is the correct moment to eliminate them — but if missed, the bug surface multiplies.
**Additionally:** If components are changed to import from sub-files directly (e.g. `import { listDocuments } from '../api/documents.js'`) instead of the barrel, every other component that imports from `client.js` still works — but a future refactor that renames a function in the sub-file will miss the barrel re-export and break components that still use the barrel. Both import paths exist simultaneously and diverge.
**Prevention:** Split in this order:
1. Extract `request()` + `noRefreshPaths` into `api/core.js`. Export `request` from there.
2. Extract the 401-retry pattern from the three blob-download functions into a single `fetchWithRetry(url, headers)` helper also in `api/core.js`.
3. Create domain files (`api/documents.js`, `api/admin.js`, `api/auth.js`, etc.) that import from `api/core.js`.
4. Keep `api/client.js` as a re-export barrel: `export * from './documents.js'; export * from './admin.js'` etc. This preserves all existing `import { listDocuments } from '../api/client.js'` calls without touching any component. Never change component imports to point at sub-files directly.
---
### Pitfall 6: Drag-and-Drop on DocumentCard — dragstart vs @click Conflict
**Phase:** UX improvements (drag-and-drop)
**What goes wrong:** `DocumentCard.vue` navigates on `@click="$router.push('/document/' + doc.id)"` (line 4). Browsers fire `click` after `dragend` when the drag distance is below the browser's drag-initiation threshold — typically ~4px. A user who clicks and releases without moving far enough triggers both `dragstart` and then `click`: the card navigates to the document even though the user intended to start a drag.
**Also:** The action buttons inside `DocumentCard` use `@click.stop`. If a drag handle overlaps an action button area, `@click.stop` prevents the click from propagating but does not prevent `dragstart` from bubbling — behavior becomes inconsistent depending on which pixel the user starts from.
**Note:** `StorageBrowser.vue` already implements drag-and-drop on list rows correctly (lines 141-145: `draggable`, `@dragstart`, `@dragend`) and does not have this problem because the row's `@click` emits `file-open` rather than navigating imperatively. The risk is specific to adding drag to `DocumentCard.vue` (card grid used in `TopicsView`).
**Prevention:**
1. Track drag state with a component-level `dragging` ref. Set `dragging = true` in `@dragstart`, reset to `false` in `@dragend`. Guard `@click`: `if (this.dragging) return`.
2. Preferred: use a dedicated drag handle element (grip icon, `cursor-grab`) and set `draggable="true"` only on that element. Add `@click.stop` on the handle. The rest of the card retains click-to-navigate.
---
### Pitfall 7: Virtual Scrolling — StorageBrowser DOM Assumptions Break
**Phase:** Frontend performance (virtual scrolling)
**What goes wrong:**
The Vue 3 SPA stores the JWT access token in `localStorage`. Any JavaScript injected via XSS (in file names, document content previewed in the UI, a compromised dependency) can call `localStorage.getItem('token')` and exfiltrate a long-lived credential. The attacker then impersonates the user from any origin, bypasses TOTP entirely (the token is post-authentication), and can access all documents, including cloud storage credentials.
**Why it happens:**
`localStorage` is the path of least resistance in SPAs. It survives page reloads, works with Axios interceptors trivially, and requires no server-side session state. FastAPI tutorials almost universally use `Authorization: Bearer` headers set from localStorage.
`StorageBrowser.vue` renders its item list with `divide-y divide-gray-100` (line 76). The Tailwind `divide-y` utility inserts borders using adjacent-sibling CSS (`& > * + * { border-top-width: 1px }`). This only works when all children are present in the DOM simultaneously. Virtual scroll libraries keep only visible rows in the DOM — `divide-y` produces borders only between the visible subset, leaving gaps at virtual boundaries.
**How to avoid:**
- Issue the JWT as an **httpOnly, SameSite=Strict, Secure cookie** — JavaScript cannot read it.
- Use a short-lived access token (15 minutes) in the httpOnly cookie.
- Issue a separate refresh token (httpOnly cookie, longer TTL, `/auth/refresh` path-scoped) to rotate access tokens silently.
- The Vue frontend never holds the raw token string. Axios is configured with `withCredentials: true`; the browser attaches cookies automatically.
- CSRF protection: because `SameSite=Strict` blocks cross-site cookie submission, CSRF tokens are not strictly required for same-origin SPAs, but add a CSRF header check (`X-Requested-With: XMLHttpRequest`) as defence-in-depth.
The folder picker dropdown inside file rows (lines 185-213) is `absolute right-0 top-full` — positioned relative to its parent row. Virtual scroll repositions rows via `transform: translateY()`. Absolutely positioned children escape their transformed parent and appear at wrong viewport positions.
**Warning signs:**
- `localStorage.getItem` anywhere in auth-related frontend code
- JWT decode functions in frontend code (the frontend should not need to decode a token it can't read)
- `Authorization: Bearer ${token}` header set manually in Axios interceptor
CSS transitions on rows (`transition-all`, `transition-colors`) work with `v-for` because Vue animates DOM insertion/removal. Virtual scroll does not insert/remove — it repositions. Vue's `<transition-group>` cannot wrap a virtually scrolled list and will throw warnings.
**Phase to address:** Auth phase (Phase 1 — Users & Auth)
**Prevention:**
1. Replace `divide-y` with `border-b border-gray-100` on each individual row element. Rows become self-contained and do not depend on DOM adjacency.
2. The folder picker dropdown must use `<Teleport to="body">` with `position: fixed` coordinates computed from `getBoundingClientRect()`. This is a prerequisite for virtual scroll adoption, not optional.
3. Remove any `<transition-group>` wrapping a virtually scrolled list. Use per-row fade-in via a CSS class added on first mount instead.
---
### Pitfall 2: TOTP Bypass via Password Reset Flow
**What goes wrong:**
The password reset flow issues a one-time token by email. After the user resets their password, the system logs them in and redirects to the dashboard — skipping the TOTP prompt because the session was created through the reset path, not the login path. An attacker who compromises a user's email account can therefore completely bypass TOTP.
**Why it happens:**
Password reset and login are treated as separate code paths. The TOTP check lives in the login handler; the reset handler creates a session directly after credential update without going through the TOTP gate.
**How to avoid:**
- After a successful password reset, issue a partial session or a `password_reset_pending` state, not a full authenticated session.
- Force the user to complete the full login flow (including TOTP if enabled) from the new credentials.
- Alternatively, on password reset completion, invalidate all existing sessions and send the user to the login page (no auto-login).
- Log password resets in the audit trail with IP and user-agent.
**Warning signs:**
- Password reset handler calls the same session-creation function as login but omits 2FA state checks
- No `mfa_verified` flag on sessions (only `authenticated`)
- Users can reach protected endpoints via a token created from the reset path
**Phase to address:** Auth phase (Phase 1 — Users & Auth)
## Moderate Pitfalls
---
### Pitfall 3: Refresh Token Rotation — Stolen Refresh Token Not Detected
### Pitfall 8: Responsive Layout — w-64 Sidebar vs Flex-1 Content on Mobile
**What goes wrong:**
Refresh tokens are issued as single-use rotating credentials. If an attacker steals a refresh token and uses it before the legitimate user does, the server rotates the token and the attacker has a valid new one. The legitimate user's next refresh request fails — but without a detection mechanism the failure just looks like a session expiry, no alert is raised, and the attacker continues with the stolen session indefinitely.
**Phase:** Responsive / mobile layout
**Why it happens:**
Teams implement the "rotate on use" mechanic without implementing the "revoke family on reuse" detection. The `refresh_tokens` table lacks a `family_id` column linking reissued tokens.
**What goes wrong:** `App.vue` uses `flex h-screen overflow-hidden` with `AppSidebar` fixed at `w-64 shrink-0`. On screens narrower than ~320px the content area collapses below usable width. `StorageBrowser`'s 5-column grid `grid-cols-[2rem_1fr_6rem_8rem_6rem]` has `hidden sm:block` and `hidden md:block` to hide size and date columns — but the fixed `2rem` icon and `6rem` action columns still consume 128px on either side of the filename, leaving minimal space on a 375px screen.
**How to avoid:**
- Store refresh tokens in the database with a `family_id` (UUID for the original issuance chain) and a `revoked` flag.
- When a refresh token is presented: if it is already marked `revoked` (i.e., a previously rotated token), revoke the **entire family** — force logout of all sessions for that user.
- Emit a security alert (audit log + optionally email) when a reuse attempt is detected.
- Refresh tokens should be hashed before storage (bcrypt or SHA-256 with a per-row salt), same as passwords.
**Warning signs:**
- Refresh tokens stored as plain values in DB with no `revoked` column
- No `family_id` linking related rotation chains
- Stolen refresh token detection treated as "v2 feature"
**Phase to address:** Auth phase (Phase 1 — Users & Auth)
**Prevention:**
1. On mobile (`< lg`), hide the sidebar. Use a hamburger button in a mobile-only header bar. Toggle with `translate-x-0 / -translate-x-full` transition on the sidebar element.
2. The sidebar open/closed state needs to be in a Pinia store ref (or provided from `App.vue`) so the hamburger button in the header, the sidebar overlay, and the main content area all react to it. Do not put it in a component's `data()`.
3. On mobile, simplify the grid to `grid-cols-[2rem_1fr_4rem]` — filename and a single action column. Test at 375px explicitly.
---
### Pitfall 4: TOTP — Timing Attack on Code Verification
### Pitfall 9: Admin Panel + Responsive — Double Padding After Extracting Tab Content
**What goes wrong:**
TOTP verification uses Python `==` string comparison. Python's `==` on strings is not constant-time — it short-circuits on the first differing character. A sufficiently sophisticated timing oracle (millions of requests from a local network) can distinguish valid from invalid codes, reducing the 6-digit brute-force space. More practically: without rate limiting, an attacker can brute-force all 1,000,000 possible 6-digit codes during a 30-second window.
**Phase:** Admin panel rearchitecture + responsive layout (interaction pitfall)
**Why it happens:**
TOTP libraries (PyOTP) return a string; developers do `if provided == expected`. Rate limiting is added "later" and often never lands.
**What goes wrong:** `AdminView.vue` wraps all content in `p-8 max-w-5xl mx-auto`. Each tab component (`AdminUsersTab.vue`, `AdminQuotasTab.vue`, etc.) has its own internal padding. When tab components are extracted into standalone page views (`AdminUsersView.vue`, etc.), the `p-8 max-w-5xl mx-auto` wrapper moves to `AdminLayout.vue`. If the extracted page views retain their own inner padding, content is double-padded. If `AdminLayout` does not include the constraint, the new admin pages render full-bleed against the viewport.
**How to avoid:**
- Use `hmac.compare_digest(provided, expected)` instead of `==` for TOTP comparison.
- Rate-limit TOTP attempts: 5 attempts per 30-second window, 15-minute lockout on excess.
- The lockout must be stored server-side (Redis or DB), not client-side.
- Accept only the current window and optionally ±1 window for clock drift — do not accept wider ranges.
- Log every failed TOTP attempt with IP.
**Warning signs:**
- `if totp.verify(code):` without inspecting PyOTP's internal comparison method
- No rate limit on `POST /auth/totp/verify`
- TOTP window set to 2+ (default is ±1 window = 90 seconds valid — wider than needed)
**Phase to address:** Auth phase (Phase 1 — Users & Auth)
**Prevention:** When extracting tab components into page views, strip all top-level padding and `max-w` from the extracted component — those belong to `AdminLayout`'s content area. The page components should fill 100% of the space provided by the layout slot.
---
### Pitfall 5: Path Traversal in User File Access (MinIO Object Keys)
### Pitfall 10: Options API Refactoring — Prop Mutation via v-model
**What goes wrong:**
MinIO object keys are constructed from user input: `f"users/{user_id}/{filename}"`. If `filename` contains `../`, `../../`, or URL-encoded equivalents (`%2e%2e%2f`), the resulting key may escape the user's prefix and land in another user's namespace. A request for `../../other_user_id/secret.pdf` resolves to `users/other_user_id/secret.pdf`.
**Phase:** Frontend refactoring (Vue component decomposition)
**Why it happens:**
Developers trust that MinIO will sanitize paths. It does not — S3-compatible APIs treat object keys as arbitrary strings. The prefix-based isolation is only as safe as the key construction code.
**What goes wrong:** If a component receives a prop and binds it directly to `<input v-model="propName">`, Vue emits a prop mutation warning in development. In production (no warnings), the mutation silently fails to propagate: the component's displayed value updates (local vdom), but the parent never receives the change because no `update:propName` event was emitted.
**How to avoid:**
- Never use raw filenames as object key components. Generate a UUID (or UUID + original extension) as the stored key: `f"users/{user_id}/{uuid4()}{ext}"`.
- Store the human-readable filename in the database metadata row, completely decoupled from the storage key.
- If filenames must appear in keys for any reason, strip or reject any `/`, `\`, `..`, `%` characters before key construction.
- On retrieval, look up the object key from the database row (which is owned by `user_id`) rather than constructing it from user input.
**Specific risk in this codebase:** `AdminUsersTab`, `AdminQuotasTab`, and `AuditLogTab` currently own their filter/search state internally. When extracted into standalone views, these may receive initial state from route query params and accidentally bind those props directly to inputs.
**Warning signs:**
- `object_key = f"users/{user_id}/{request.filename}"`
- File download endpoint accepts a `path` or `filename` query parameter and constructs the key from it
- No database lookup intermediating between "user requests file" and "MinIO key used"
**Phase to address:** Storage migration phase (Phase 2 — DB + MinIO migration)
**Prevention:** Any input the component controls must bind to a local `data()` copy initialized from the prop: `data() { return { localFilter: this.initialFilter } }`. Emit `update:initialFilter` on change. Never write `v-model="$props.X"`.
---
### Pitfall 6: Quota Race Condition — Concurrent Uploads Bypass the Limit
### Pitfall 11: Options API Refactoring — data() vs computed() in Decomposed Components
**What goes wrong:**
Two upload requests arrive simultaneously for a user at 99 MB of a 100 MB quota. Both read quota usage as 99 MB, both pass the `99 + 1 < 100` check, both proceed to upload — the user ends at 101 MB. At larger scale (many simultaneous large uploads) the overage can be significant.
**Phase:** Frontend refactoring
**Why it happens:**
Quota enforcement is implemented as: read current usage → check → write file → update usage. This is a classic check-then-act race when the check and the write are not atomic.
**What goes wrong:** When extracting a large component, logic that was a `computed()` in the parent often gets copy-pasted into a child's `data()`. A `data()` value initializes once at mount — it does not react to prop changes. If the parent later passes a different prop value (e.g. the document list changes after a search), the child's stale `data()` copy never updates.
**How to avoid:**
- Enforce quota atomically using a `SELECT ... FOR UPDATE` on the quota row before uploading, or use a PostgreSQL advisory lock keyed on `user_id`.
- Better: use an optimistic update: `UPDATE quotas SET used_bytes = used_bytes + $new WHERE user_id = $uid AND used_bytes + $new <= limit_bytes RETURNING used_bytes`. If 0 rows are updated, the quota was exceeded — reject before touching MinIO.
- Only update `used_bytes` after the MinIO upload succeeds, but hold the lock/reservation through the upload, or use a two-phase: reserve bytes → upload → confirm, with a cleanup job for stuck reservations.
- Never read quota, do arithmetic in Python, then write back as two separate statements.
**Warning signs:**
- `current = get_quota(user_id); if current + size <= limit: upload()`
- No database transaction wrapping quota check and update
- Quota table updated with `SET used_bytes = $computed_value` (full overwrite) rather than `used_bytes + delta`
**Phase to address:** Storage migration phase (Phase 2 — DB + MinIO migration) and Quotas phase
**Prevention:** For every value in a newly created component, ask: "If the parent passes a different value for this prop, should the child update?" Yes → `computed()`. No → `data()`. Initialized from prop but locally modified afterward → `data()` with a `watch` on the prop to reset when it changes externally.
---
### Pitfall 7: Admin Privilege Escalation via Missing Ownership Checks
### Pitfall 12: Keyboard Shortcuts — Conflict with Text Inputs and Browser Defaults
**What goes wrong:**
An admin API endpoint is gated on `is_admin=True` but document-access endpoints only check `is_authenticated`. An admin user calling `GET /api/documents/{id}` with any document ID can read any user's document because the handler checks authentication but not `document.owner_id == current_user.id`. The privacy-first model is violated without any special exploit — just a correctly authenticated request.
**Phase:** UX improvements (keyboard shortcuts)
**Why it happens:**
Authorization is implemented as authentication + role checks ("is this user an admin?") without resource-level ownership verification ("does this user own this resource?"). FastAPI dependency injection makes it easy to write `current_user = Depends(get_current_user)` and forget to check `resource.user_id == current_user.id`.
**What goes wrong:** A global `keydown` listener on `window` fires regardless of focused element. Shortcuts like `/` (focus search), `n` (new folder), `Delete` (delete selected) are also typed characters. They must not fire when any `<input>`, `<textarea>`, `<select>`, or `[contenteditable]` element has focus. `Escape` conflicts with native `<dialog>` cancel events if `DocumentPreviewModal` uses a native dialog — both fire, causing a double-close bug.
**How to avoid:**
- Every document/file/folder endpoint must assert `resource.user_id == current_user.id` (or check share grants). This check cannot be optional or deferred.
- Admins access user account metadata via separate admin-scoped endpoints that explicitly exclude document content, file URLs, and cloud credentials.
- Write a test: log in as admin, attempt `GET /documents/{document_owned_by_other_user}`, assert `403`.
- Use a centralized `assert_document_access(document, current_user)` function rather than inline checks to prevent omissions.
**Prevention:**
**Warning signs:**
- Document endpoints that check `if not current_user` but not `if document.user_id != current_user.id`
- Admin endpoints that return document content or presigned URLs
- No explicit tests for cross-user access attempts
```javascript
function onKeydown(e) {
const tag = document.activeElement?.tagName
const isEditing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)
|| document.activeElement?.isContentEditable
if (isEditing) return
**Phase to address:** Auth phase (Phase 1) and every subsequent phase that adds resource-access endpoints
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
searchInputRef.value?.focus()
}
}
// Options API: register in mounted(), remove in beforeUnmount()
// Omitting the removeEventListener is the most common bug in this pattern.
```
Never call `preventDefault()` on `F5`, `Backspace`, `Ctrl+F`, or `Cmd+F` — these are browser-reserved. Use a Pinia store to track which shortcut context is active (file manager, document view, modal) so modal shortcuts suppress file manager shortcuts rather than both firing.
---
### Pitfall 8: Cloud Credential Leakage via Admin Query
**What goes wrong:**
Cloud storage credentials (OAuth tokens, Nextcloud passwords) are encrypted at rest. But a query like `SELECT * FROM cloud_connections WHERE user_id = $uid` returns the ciphertext. If an admin dashboard endpoint runs this query and serializes the full row to JSON, the ciphertext ships to the admin browser. An admin cannot decrypt it without the key — but the ciphertext is now in browser history, proxy logs, and admin audit records. If the encryption key is later exposed, all credentials decrypt retroactively.
**Why it happens:**
ORM models serialize all columns by default. Developers add `encrypted_credentials` to the model and forget to exclude it from admin-facing serializers.
**How to avoid:**
- The `cloud_connections` table's credential column (`encrypted_token`, `encrypted_refresh_token`) must be **excluded** from all serialization by default.
- Use explicit Pydantic response models for every endpoint — no `orm_mode` with full model pass-through.
- Admin endpoints for cloud connections return only: `provider`, `account_label`, `connected_at`, `last_used_at`, `status` — never the credential column, not even ciphertext.
- Audit log cloud credential access separately: the only code path that should ever read the encrypted column is the storage adapter, not admin or user-info endpoints.
**Warning signs:**
- `CloudConnection` Pydantic schema includes `encrypted_token` field
- Admin user-detail endpoint returns full cloud connection rows
- ORM model uses `model_config = ConfigDict(from_attributes=True)` without explicit field exclusions
**Phase to address:** Cloud storage integration phase (Phase 3 or 4)
## Minor Pitfalls
---
### Pitfall 9: Flat-file to PostgreSQL Migration — Data Loss During Cutover
### Pitfall 13: Loading Skeletons — Count Mismatch Causes Layout Shift
**What goes wrong:**
The migration script reads all JSON files, transforms them to DB rows, and then a flag switches the app to use PostgreSQL. Documents uploaded during the migration window are written to the old JSON store (because the flag has not flipped yet) and are missed by the migration script, which ran before they arrived. After cutover, those documents are invisible.
**Phase:** UX improvements (loading skeletons)
**Why it happens:**
Migrations are planned as offline events ("we'll take the app down for 5 minutes") and then discovered to be impractical — the app is used 24/7 or downtime feels risky. The team runs the migration online without dual-write.
**What goes wrong:** `StorageBrowser.vue` already has a `loading` prop (line 244) that shows "Loading…" text. Replacing this with skeleton rows requires a skeleton count. Hardcoding 5 rows when `per_page=20` causes a jump; using `per_page` as the skeleton count when only 3 items exist also jars. If skeleton rows are different heights from actual rows, the layout shifts when real content loads — this is a Core Web Vitals issue.
**How to avoid:**
- Plan the migration in three phases:
1. **Dual-write**: deploy code that writes to both JSON and PostgreSQL, reads from JSON. All new documents land in both stores.
2. **Backfill**: run migration script to copy historical JSON records to PostgreSQL. New records are already there.
3. **Cutover**: flip read source to PostgreSQL, verify counts match, remove JSON write path.
- The dual-write window can be as short as one deployment cycle.
- Include a reconciliation check: `assert doc_count_json == doc_count_db` before cutting over.
- Keep the old JSON store read-only for 1 week post-cutover as a rollback option.
**Warning signs:**
- Migration is planned as "take app down, run script, bring back up"
- No count-reconciliation step
- No rollback plan documented before migration begins
**Phase to address:** DB + MinIO migration phase (Phase 2)
**Prevention:** Render skeletons at the previous page's actual item count (stored in the parent store). Default to 6-8 on first load. Skeleton rows must match the height of `StorageBrowser`'s actual grid rows exactly — use the same `px-4 py-2.5 grid-cols-[2rem_1fr_6rem_8rem_6rem]` structure with gray placeholder blocks.
---
### Pitfall 10: Blocking I/O Inside Async FastAPI Handlers (Existing Issue)
### Pitfall 14: Tailwind Purging — Dynamic Class Names in Refactored Components
**What goes wrong:**
The codebase already uses synchronous `filelock` and `open()` inside `async def` handlers (CONCERNS.md item 6). After migration to PostgreSQL + MinIO, if synchronous DB drivers (psycopg2) or synchronous MinIO client calls replace the file I/O without wrapping in `asyncio.to_thread()`, the event loop stalls on every I/O operation. Under concurrent load (multiple users uploading), requests queue behind each other even though the hardware is idle.
**Phase:** Visual redesign (Tailwind component system)
**Why it happens:**
SQLAlchemy sync engine + psycopg2 is the default FastAPI tutorial stack. The MinIO Python SDK (`minio` package) is synchronous. Developers add `await` in front of calls that are not coroutines, get a type error, remove the `await`, and ship blocking code.
**What goes wrong:** Vite + Tailwind purges unused CSS at build time by scanning template strings for class names. Dynamic class names constructed at runtime (e.g. `'text-' + color + '-500'`) are invisible to the purger. In development all classes are present; in production the dynamic classes are stripped and topics/provider badges render without color.
**How to avoid:**
- Use SQLAlchemy async engine with `asyncpg` driver: `create_async_engine("postgresql+asyncpg://...")`.
- Wrap all MinIO SDK calls in `asyncio.to_thread()` since there is no official async MinIO client: `await asyncio.to_thread(minio_client.put_object, ...)`.
- Alternatively use `aioboto3` (async boto3) which works with MinIO's S3-compatible API.
- `aiofiles` is already in `requirements.txt` — use it for any remaining local file operations.
- Run `pytest-asyncio` + `asyncio.get_event_loop().set_debug(True)` in tests; the debug mode logs blocking calls.
**Specific risk:** `formatters.js` functions `providerColor`, `providerBg`, `providerLabel` return class strings. If those strings are not present as complete literals anywhere in the scanned source, they are purged.
**Warning signs:**
- `from sqlalchemy import create_engine` (not `create_async_engine`)
- `import psycopg2` anywhere in application code
- `minio_client.put_object(...)` not wrapped in `asyncio.to_thread`
- Uvicorn logs show high request latency even with low CPU usage
**Phase to address:** DB + MinIO migration phase (Phase 2)
**Prevention:** Add a `safelist` in `tailwind.config.js`:
```javascript
safelist: [
{ pattern: /bg-(blue|green|purple|orange|gray|indigo|red)-(50|100|500|600)/ },
{ pattern: /text-(blue|green|purple|orange|gray|indigo|red)-(500|600|700)/ },
]
```
Alternatively, use a lookup object of complete class strings rather than string concatenation — Tailwind can scan complete strings in object literals.
---
### Pitfall 11: N+1 Queries on Document Listing
**What goes wrong:**
`GET /api/documents` returns a list of documents, each including folder name, topic names, and share count. The handler fetches the document list, then for each document issues separate queries for folder, topics, and shares. 100 documents = 301+ queries per page load. The existing codebase already has an O(N) disk scan equivalent (CONCERNS.md items 8, 9); the PostgreSQL migration preserves this pattern if not corrected.
**Why it happens:**
SQLAlchemy lazy loading is the default. `document.folder.name` triggers a query. Developers don't see it until production load hits.
**How to avoid:**
- Use SQLAlchemy `joinedload` or `selectinload` options on the document list query to eagerly load related entities.
- The list endpoint should be a single SQL query with JOINs, not a loop.
- Add `EXPLAIN ANALYZE` checks as part of the phase acceptance criteria.
- Enable SQLAlchemy `echo=True` in development to log every SQL statement.
- Pagination at the database level: `LIMIT 50 OFFSET $n` — not "fetch all, slice in Python."
**Warning signs:**
- SQLAlchemy models using default relationship loading without explicit `lazy=` options
- `for doc in documents: doc.folder.name` pattern
- No `joinedload` or `selectinload` in list query definitions
- Query count in tests grows linearly with fixture size
**Phase to address:** DB + MinIO migration phase (Phase 2); enforce in document listing feature phase
## Integration Pitfalls (Where Two Changes Interact Badly)
---
### Pitfall 12: MinIO Presigned URL Expiry — Stale Links in the UI
### Integration Pitfall A: Admin Rearchitecture + client.js Splitting — Wrong Order Causes Test Blindness
**What goes wrong:**
The document preview and download UI displays links generated as MinIO presigned URLs with a 1-hour expiry. The Vue frontend fetches the document list on page load, stores the URLs in Pinia state, and renders them as `<img src>` or `<a href>`. If the user leaves the tab open for 2 hours and then clicks a link, they get a 403 (presigned URL expired). No error is shown; the image just fails to load or the download silently fails.
**Phases:** Admin panel rearchitecture + frontend refactoring
**Why it happens:**
Presigned URLs feel like permanent links. Teams generate them at list time for convenience and cache them in frontend state without expiry awareness.
**What goes wrong:** If `client.js` is split into domain sub-files before the admin routes are moved to `/admin/*`, the new `api/admin.js` sub-file gets created. Components are then updated to point at the new route structure. But if the split happened first and components were simultaneously moved to import from `api/admin.js` directly (bypassing the barrel), the existing tests (which mock `client.js` imports) no longer cover the new import path. Tests pass, bugs hide.
**How to avoid:**
- Do not embed presigned URLs in list responses. Return only document metadata.
- Generate presigned URLs **on demand**: a separate `GET /api/documents/{id}/download-url` endpoint generates a short-lived URL (515 minutes) at the moment of user intent.
- Alternatively, proxy document bytes through the FastAPI backend (`GET /api/documents/{id}/file` streams from MinIO) — eliminates presigned URL complexity at the cost of bandwidth.
- If presigned URLs are cached in frontend state, include the expiry timestamp and regenerate before expiry.
**Warning signs:**
- Presigned URLs included in document list response JSON
- Frontend stores presigned URLs in Pinia state without TTL tracking
- No `GET /api/documents/{id}/download-url` endpoint (or equivalent)
- `presigned_url_expiry = 3600` with no frontend refresh logic
**Phase to address:** DB + MinIO migration phase (Phase 2) and document access phase
**Prevention:** Split `client.js` using the re-export barrel pattern first. Verify all existing tests still pass against the barrel. Then rearchitect admin routes. The barrel acts as a compatibility shim throughout both phases. Only remove the barrel after all tests are updated to import from sub-files.
---
### Pitfall 13: OAuth Token for Cloud Storage — Revocation Not Handled
### Integration Pitfall B: Virtual Scrolling + Drag-and-Drop Cannot Be Added Simultaneously
**What goes wrong:**
A user connects Google Drive via OAuth. The app stores the encrypted access token + refresh token. Six months later, the user revokes the app's access in their Google account settings. The next time DocuVault tries to access their Drive, it gets a 401. The refresh token exchange also fails with `invalid_grant`. The app has no handler for this — it retries, logs an error, and the user sees a generic "storage error" with no path to reconnect.
**Phases:** Frontend performance + UX improvements
**Why it happens:**
OAuth happy-path is well-documented. Revocation and `invalid_grant` handling are in footnotes. Teams handle 401 → refresh → retry but don't handle refresh failure → user notification.
**What goes wrong:** Virtual scroll and HTML5 drag-and-drop between rows both depend on the same DOM elements. Virtual scroll keeps only visible rows in the DOM. A user dragging a file toward a folder that is currently scrolled out of the virtual window will never trigger `dragover` on that folder row — it is not in the DOM. `StorageBrowser.vue`'s existing drag-into-folder implementation (lines 88-90) relies on `@dragover.prevent` on folder rows. In a virtual list, off-screen folder rows do not receive this event.
**How to avoid:**
- Wrap all cloud storage adapter calls in a two-level error handler:
- Level 1: 401 → attempt refresh → retry.
- Level 2: refresh fails with `invalid_grant` or `token_revoked` → mark connection as `REQUIRES_REAUTH` in DB, **do not retry**, surface a "reconnect required" notice to the user.
- The `cloud_connections` table must have a `status` column: `ACTIVE | REQUIRES_REAUTH | ERROR`.
- The UI must poll or react to `REQUIRES_REAUTH` state and prompt the user to re-authorize, not just show an error toast.
- Never silently swallow a revoked-token error or retry indefinitely.
**Warning signs:**
- Cloud storage adapter raises generic `StorageError` for all OAuth errors without distinguishing revocation
- No `status` or `needs_reauth` column on `cloud_connections`
- Reconnect UI not planned in feature scope
**Phase to address:** Cloud storage integration phase
**Prevention:** Decide the list interaction model before implementing either feature. If virtual scrolling is added, redesign drag-to-folder: the sidebar folder tree (always fully rendered, outside the virtual list) becomes the drop target. This is a UX design decision that must be made before either feature is implemented, not a detail to resolve mid-phase.
---
### Pitfall 14: Cloud Storage Rate Limits — No Backoff, No Per-User Throttling
### Integration Pitfall C: Responsive Sidebar + Admin Guard Race on Mobile Page Load
**What goes wrong:**
Multiple users with Google Drive connected trigger simultaneous document uploads. The app hits Google's per-app rate limit (Drive API: 20,000 requests/100 seconds/user, but also global per-project limits). Google returns 429. The app has no retry-with-backoff; uploads fail and the errors are attributed to the individual user, not the platform-level limit.
**Phases:** Responsive layout + admin panel rearchitecture
**Why it happens:**
Rate limits are per-API-key, not per-user. A single misbehaving user or burst of activity for all users shares the same quota. Teams only test with a single user.
**What goes wrong:** The current `beforeEach` guard correctly awaits `authStore.refresh()` before checking `authStore.user?.role` (router lines 82-88). This is correct. The risk emerges when adding nested admin child routes: a developer adding a component-level `created()` or `onMounted()` auth check in an admin page component, trying to guard against unauthorized access "just in case." If that component check does not await the refresh (because the token is memory-only and gone on mobile page reload), `authStore.user` is null at that point, the component check redirects the user to `/login`, and the correct guard never gets to run. The result is that admins are bounced to login on every mobile page reload.
**How to avoid:**
- Implement exponential backoff with jitter for all cloud storage adapter calls (start 1s, max 32s, 3 retries).
- Use a task queue (Celery or FastAPI BackgroundTasks with a semaphore) for cloud-destined uploads rather than processing inline in the request handler.
- Log 429 responses separately from other errors — they indicate platform-level throttling, not user errors.
- Per-provider rate limit documentation should be captured in adapter docstrings.
**Warning signs:**
- Cloud upload performed synchronously inside the HTTP request handler with no retry logic
- No `tenacity` or equivalent retry decorator on provider calls
- 429 responses from cloud APIs cause 500 responses to the end user
**Phase to address:** Cloud storage integration phase
---
### Pitfall 15: GDPR Right to Erasure — Cloud Copies Not Deleted
**What goes wrong:**
A user requests account deletion. The app deletes the PostgreSQL rows, the MinIO objects, and the user record. But if the user's documents were stored in Google Drive or OneDrive via the cloud storage adapter, those files remain on the cloud provider. The user's data has not actually been erased from all systems.
**Why it happens:**
Account deletion is implemented against the systems the team controls (PostgreSQL, MinIO). Cloud storage is treated as "the user's own storage" so deletion is skipped. Under GDPR, if the platform wrote files to the user's cloud storage on their behalf as a data processor, erasure obligations may extend there.
**How to avoid:**
- Account deletion must enumerate all `cloud_connections` for the user and call `adapter.delete_all_user_files()` (or equivalent) on each active connection.
- If the cloud connection is already in `REQUIRES_REAUTH` state, the deletion cannot proceed automatically — log a compliance alert and require manual follow-up or notify the user to delete manually.
- Document the data flow in a data map: "files stored in X go to Y" — this is a GDPR Article 30 requirement.
- Right to erasure flow must be tested as part of acceptance criteria, not assumed to work.
**Warning signs:**
- Account deletion handler only operates on PostgreSQL and MinIO
- No `delete_user_files` method in the `StorageBackend` interface
- No GDPR data map in project documentation
**Phase to address:** Cloud storage integration phase and account management phase
---
### Pitfall 16: Encryption Key Management — Single Key for All Users
**What goes wrong:**
All cloud credentials are encrypted with one Fernet key stored in `CLOUD_ENCRYPTION_KEY` env var. If this key is exposed (leaked `.env`, compromised deployment config, insider threat), every user's cloud credentials decrypt at once. The privacy-first model collapses entirely.
**Why it happens:**
One key is simpler than per-user keys. Fernet key derivation per user is slightly more complex to implement.
**How to avoid:**
- Derive a per-user encryption key from the master key + a user-specific salt: `HKDF(master_key, salt=user_id_bytes, info=b"cloud-credentials")`. The salt is stored in the users table.
- Even if the master key leaks, an attacker still needs each user's salt to decrypt their credentials.
- Rotate the master key on a schedule: re-encrypt all stored credentials with the new key in a background job.
- Audit log any code path that accesses the encryption key — this should only ever happen in the storage adapter, never in API handlers.
- Never log the encryption key or derived key material, even at DEBUG level.
**Warning signs:**
- Single `ENCRYPTION_KEY` env var with no per-user derivation
- Encryption/decryption called directly in API endpoint handlers (not in the adapter layer)
- No key rotation plan documented
**Phase to address:** Cloud storage integration phase (design the key derivation before writing the first line of credential storage code)
---
### Pitfall 17: Shared Document — Quota Not Charged to Sharer Correctly After Revoke
**What goes wrong:**
User A shares a document with User B. The document is not duplicated — it stays in User A's storage and User A's quota. User A revokes the share. If the quota update on revoke has a bug (or is missing), User A's used bytes may drift from reality over time. At scale, quota inaccuracies accumulate and users can either be locked out prematurely or exceed limits invisibly.
**Why it happens:**
Sharing is implemented as a metadata-only operation (a `shares` table row), but quota accounting is only re-examined on upload and delete. Edge cases (revoke, owner deletion while share is active, cloud storage document quota) are skipped.
**How to avoid:**
- Quota is a property of documents, not shares. Ensure the quota model is: "sum of `file_size` for all documents where `owner_id = user_id`." Shares do not affect quota calculation.
- Recalculate quota from source-of-truth (a `SUM` query) as a periodic background job and reconcile against the cached `used_bytes` value. Alert if drift exceeds 1%.
- The `DELETE /documents/{id}` handler must atomically decrement quota and delete the DB row and MinIO object in a single transaction (DB) + best-effort cleanup (MinIO).
**Warning signs:**
- `used_bytes` stored as a counter updated by application code, never reconciled against a database SUM
- Share revocation handler doesn't touch quota (correct if quota is owner-only, but must be verified)
- Document deletion does not atomically update quota
**Phase to address:** Quotas and sharing phases
---
## Technical Debt Patterns
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Single global encryption key for cloud credentials | Simple env var config | Single point of failure; one leak decrypts all users | Never for a SaaS product |
| localStorage JWT storage | Easy Axios integration | XSS → full account takeover, TOTP bypass | Never; httpOnly cookies have identical DX once set up |
| Synchronous DB driver (psycopg2) with FastAPI | Familiar, simple | Event loop blocking under any load | Never for new greenfield code; acceptable only during an explicitly time-boxed spike |
| Presigned URL in list response | One fewer endpoint | Stale URLs, user-facing failures after expiry | Only for short-lived signed URLs (<5 min) with TTL tracking in frontend |
| Skip per-user key derivation | Fewer moving parts | Catastrophic blast radius on key leak | Never for cloud credential encryption |
| Inline quota check (read-check-write) without atomic update | Simple code | Silent quota bypass under concurrent load | Never for enforced limits |
| Soft-coding 8-char UUID prefix as document IDs (existing) | Shorter IDs | Collision risk at scale; insecure for auth tokens | Replace with full UUID before multi-user goes live |
---
## Integration Gotchas
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| Google Drive OAuth | Storing `access_token` only; ignoring `refresh_token` | Store both; refresh_token is issued only on first authorization — store immediately or it's lost |
| OneDrive / MSAL | Treating MSAL token cache as permanent | MSAL token cache can be invalidated by Microsoft; always handle `invalid_grant` and re-auth prompt |
| Nextcloud | Using Basic Auth with plain credentials | Use App Passwords (Nextcloud-specific token), never store the user's Nextcloud master password |
| MinIO | Using `minio.get_object()` for large files without streaming | Stream the response: `response.stream(32768)` or use boto3 streaming; loading full file to memory crashes on large PDFs |
| MinIO | Treating bucket names as security boundaries | Bucket names are not credentials; isolation is enforced by object key prefix + IAM policy. Set MinIO IAM so the app's service account can only access the designated bucket |
| PostgreSQL | Using `autocommit=True` for all connections | Quota updates and document record creation must be transactional; autocommit makes rollback impossible |
| PyOTP (TOTP) | Using default `valid_window=0` then switching to `valid_window=2` for "user convenience" | Window=1 (±30s) tolerates clock skew. Window=2+ increases brute-force surface disproportionately |
---
## Performance Traps
| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| O(N) metadata scan on every document list (existing pattern) | List endpoint slows linearly with document count | Paginated SQL query with index on `(user_id, created_at)` | ~500 documents per user |
| Topic count scan on every topic fetch (existing pattern) | `GET /topics` latency spikes as documents grow | Materialized counter column updated on insert/delete, or cached with 30s TTL | ~1,000 documents |
| Synchronous MinIO upload inline in HTTP handler | Requests time out on large files; one upload blocks all other requests | Background task queue; return 202 Accepted with job ID | First user uploading a file >10 MB |
| Eager loading all cloud connections on user login | Login latency grows with number of connected providers | Lazy-load cloud connection status; only check on storage page | >3 cloud providers per user |
| Full-text search via `ILIKE '%term%'` on document content | Full table scan on every search | PostgreSQL `tsvector` full-text index on extracted text column | ~10,000 documents platform-wide |
| JWT validation on every request without caching public key | Repeated public key fetch (if using asymmetric JWT) | Cache the signing key in memory; never refetch on every request | High request volume |
---
## Security Mistakes
| Mistake | Risk | Prevention |
|---------|------|------------|
| CORS `allow_origins=["*"]` retained from existing codebase | Cross-origin requests from attacker-controlled pages | Set exact origin (frontend URL) in production; never wildcard with credentials |
| File type validation defined but not enforced (existing) | Executable or malicious file uploads | Enforce MIME type check at upload boundary; also validate magic bytes (not just Content-Type header) |
| No file size limit at HTTP boundary (existing) | Memory exhaustion, DoS via large upload | Add `max_upload_bytes` limit in FastAPI middleware before reading body |
| API keys in plaintext JSON (existing) | Credential leakage via Docker volume mount | Env vars for all secrets; never serialize keys to disk in application data directory |
| Password reset without invalidating existing sessions | Attacker keeps session after victim resets password | Invalidate all sessions for user on successful password reset |
| Audit log includes document metadata (filenames) | Filename can reveal document content | Audit log stores only document ID, event type, timestamp, IP — no filenames |
| Admin can read `cloud_connections.encrypted_token` column | Ciphertext exposure; retroactive decryption if key leaks | Exclude credential columns from all admin serializers by policy |
| No breach check on registration password | Users reuse passwords from breached services | Integrate HIBP k-anonymity API on registration |
---
## UX Pitfalls
| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| Silent presigned URL expiry (image fails to load, download does nothing) | User thinks the app is broken; no recovery path | On-demand URL generation; show explicit "link expired, regenerating..." state |
| TOTP enrollment without backup codes | User loses phone → permanently locked out of account | Issue 810 single-use backup codes at TOTP enrollment; require user to acknowledge |
| Quota error shown only as "Upload failed" | User doesn't know why; may try repeatedly | Return structured quota error: `{"error": "quota_exceeded", "used": 98MB, "limit": 100MB}` |
| Cloud storage reconnect required, but no in-app prompt | User's uploads silently fail until they notice the settings page | Banner notification or upload-time prompt: "Your Google Drive connection needs re-authorization" |
| Folder delete with documents inside silently deletes documents | Data loss without confirmation | Require explicit confirmation listing document count; offer "move to root" alternative |
| Share revoke with no notification to recipient | Recipient sees documents disappear without explanation | Audit-trail entry visible to recipient: "Access to [folder] was revoked by [owner]" |
---
## "Looks Done But Isn't" Checklist
- [ ] **TOTP enrollment:** Backup codes issued, stored hashed, and acknowledged by user before TOTP is marked active
- [ ] **Password reset:** Does NOT create a full authenticated session — routes through login with TOTP check
- [ ] **Document delete:** Atomically decrements quota AND removes MinIO object AND removes DB row in a single transaction
- [ ] **Account delete:** Deletes from MinIO, PostgreSQL, AND calls `adapter.delete_user_files()` for each cloud connection
- [ ] **Cloud disconnect:** Revokes OAuth token at provider (not just deletes local record) — provider refresh tokens remain valid until explicitly revoked
- [ ] **Quota display:** Shows real-time usage, not cached value from registration — must query SUM from DB
- [ ] **Sharing:** Shared documents do NOT copy to recipient's quota — verify with a `SELECT SUM(file_size) WHERE owner_id = recipient_id` before and after share
- [ ] **Migration:** Document count in PostgreSQL equals document count in old JSON store before cutover flag is flipped
- [ ] **Presigned URLs:** Never in list responses — verified by API contract test asserting no `url` field in list endpoint response body
- [ ] **Admin isolation:** Admin token cannot retrieve document content — verified by dedicated negative-access test
---
## Recovery Strategies
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| JWT in localStorage discovered post-launch | HIGH | Rotate all tokens (force logout all users), redeploy with httpOnly cookies, communicate to users |
| Quota race condition caused 10% of users to exceed quota | MEDIUM | Run `UPDATE quotas SET used_bytes = (SELECT SUM(file_size) FROM documents WHERE owner_id = user_id)` reconciliation; enforce atomic updates going forward |
| Flat-file migration data loss (documents missing post-cutover) | HIGH | Restore from pre-migration backup of JSON data directory; re-run migration with dual-write; reconcile counts |
| Encryption key leaked (single-key model) | CRITICAL | Immediately rotate key; re-encrypt all credentials with new key; notify affected users; assume all cloud credentials compromised — prompt all users to revoke and reconnect |
| Admin escalation (admin accessed user documents) | HIGH | Audit log review to determine scope; GDPR breach notification if EU users affected (72h window); patch authorization layer; force session invalidation |
| Cloud connection revoked but app retried indefinitely | LOW | Mark all stuck connections as `REQUIRES_REAUTH`; send user notification; add `invalid_grant` handler to adapter |
---
## Pitfall-to-Phase Mapping
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| JWT in localStorage | Phase 1 — Auth | Confirm no `localStorage.setItem('token', ...)` in frontend; auth cookie is httpOnly in browser DevTools |
| TOTP bypass via password reset | Phase 1 — Auth | Integration test: reset password → assert no full session created → assert login still requires TOTP |
| Refresh token reuse detection | Phase 1 — Auth | Test: use a rotated (old) refresh token → assert 401 and full family revocation |
| TOTP timing attack / brute force | Phase 1 — Auth | Rate limit test: 6th TOTP attempt within window → assert 429 |
| Path traversal in MinIO keys | Phase 2 — DB + MinIO migration | Unit test: filename with `../` → assert stored key is UUID-based, not user-provided |
| Quota race condition | Phase 2 — DB + MinIO migration | Concurrent upload test: 2 threads uploading to full quota → assert only one succeeds |
| Admin privilege escalation | Phase 1 + every phase adding resource endpoints | Negative access test: admin JWT → `GET /documents/{other_user_doc_id}` → assert 403 |
| Cloud credential leakage via admin query | Phase 3 — Cloud storage | Test: admin list user cloud connections → assert response contains no `token` or `encrypted_` fields |
| Flat-file to PostgreSQL data loss | Phase 2 — DB + MinIO migration | Count reconciliation: `assert json_doc_count == db_doc_count` in migration script |
| Blocking I/O in async handlers | Phase 2 — DB + MinIO migration | Load test with 10 concurrent requests; assert no event loop warnings; check SQLAlchemy driver is asyncpg |
| N+1 queries on document list | Phase 2 — DB + MinIO migration | Enable SQLAlchemy echo; assert document list query count = 1 regardless of result size |
| Presigned URL expiry | Phase 2 — DB + MinIO migration | API contract test: list endpoint response contains no presigned URL fields |
| OAuth revocation not handled | Phase 3 — Cloud storage | Mock `invalid_grant` from OAuth provider → assert connection status set to `REQUIRES_REAUTH`, not retried |
| Rate limits without backoff | Phase 3 — Cloud storage | Mock 429 from provider → assert exponential backoff, not immediate 500 to user |
| GDPR right to erasure incomplete | Phase 3 — Cloud storage + account mgmt | Account deletion test: assert cloud adapter `delete_user_files` called for each active connection |
| Single encryption key | Phase 3 — Cloud storage | Code review gate: assert per-user HKDF derivation; no single-key decryption in any code path |
| Quota drift from sharing/revoke | Sharing phase | Reconciliation query: `SUM(file_size WHERE owner_id = user)` vs `used_bytes` — assert < 1% drift |
---
## Sources
- Project context: `/Users/nik/Documents/Progamming/document_scanner/.planning/PROJECT.md`
- Codebase audit: `/Users/nik/Documents/Progamming/document_scanner/.planning/codebase/CONCERNS.md`
- Auth pitfalls: OWASP JWT Security Cheat Sheet, OWASP Authentication Cheat Sheet (well-established, HIGH confidence)
- OAuth token lifecycle: RFC 6749 Section 10.4 (refresh token revocation), Google OAuth error codes documentation (HIGH confidence from training data)
- MinIO/S3 path traversal: AWS S3 object key documentation; known class of vulnerability in multi-tenant S3-prefix isolation (HIGH confidence)
- Quota race conditions: Classic check-then-act concurrency pattern; PostgreSQL SELECT FOR UPDATE documentation (HIGH confidence)
- GDPR Article 17 (right to erasure) and Article 30 (records of processing): well-established regulatory requirements (HIGH confidence)
- N+1 query pattern: SQLAlchemy documentation on relationship loading strategies (HIGH confidence)
- TOTP RFC 6238; PyOTP library behavior from training data (MEDIUM — verify PyOTP `valid_window` defaults against current docs before implementation)
---
*Pitfalls research for: DocuVault — multi-user SaaS document management (FastAPI + Vue 3 + PostgreSQL + MinIO)*
*Researched: 2026-05-21*
**Prevention:** The single `beforeEach` guard is the only place that should call `refresh()` or check roles. Page components must not independently re-check auth. If a component needs the current user, it reads from `authStore.user` — which is guaranteed to be populated by the time the component mounts, because the guard has already awaited the refresh.
+239 -470
View File
@@ -1,553 +1,322 @@
# Stack Research — DocuVault: Multi-User Auth, Storage & Cloud Integrations
# Technology Stack — DocuVault v0.2: UI Overhaul and Optimization
**Domain:** SaaS document management — adding multi-user auth, PostgreSQL, MinIO, cloud storage integrations to existing FastAPI + Vue 3 app
**Researched:** 2026-05-21
**Overall Confidence:** MEDIUM-HIGH (most core library choices verified against official FastAPI docs and release notes; cloud SDK versions partially from training data, flagged where unverified)
**Milestone:** v0.2 — UI Overhaul and Optimization
**Researched:** 2026-06-07
**Scope:** New additions only. The v0.1 stack (FastAPI, Vue 3, Pinia, Vue Router 4, Vite, Tailwind CSS, PostgreSQL, MinIO) is unchanged and not re-researched here.
---
## Existing Stack (Do Not Replace)
## Existing Stack Baseline (Confirmed, Do Not Change)
| Component | Current | Notes |
|-----------|---------|-------|
| Backend framework | FastAPI 0.136.1 | Latest confirmed from official release notes |
| Frontend framework | Vue 3 | Keep as-is |
| Runtime | Python 3.11+ | FastAPI supports 3.14t as of 0.136.0 |
| Deployment | Docker Compose | Remains primary target |
| ASGI server | Uvicorn (via `fastapi run`) | Starlette 1.0.0 now bundled |
| Component | Current Version (package.json / requirements.txt) | Notes |
|-----------|--------------------------------------------------|-------|
| Vue | `^3.4.0` | Must bump to `^3.5.0` (see VueUse note below) |
| Vite | `^5.2.0` | Must bump to `^6.0.0` — Vite 8 is latest; skip 8 (too new) |
| Tailwind CSS | `^3.4.0` | Stay on v3; v4 is out but requires config rewrite |
| Pinia | `^2.1.0` | No change needed |
| Vue Router | `^4.3.0` | No change needed |
---
## Area 1: Authentication
## New Frontend Dependencies
### JWT — PyJWT 2.12.1
### 1. VueUse — `@vueuse/core` + `@vueuse/integrations`
**Confidence: HIGH** (verified from FastAPI release notes: `pyjwt` bumped to `2.12.1` in FastAPI 0.136.1; FastAPI tutorial now uses `import jwt` not `python-jose`)
**Recommended:** `@vueuse/core@^14.3.0`, `@vueuse/integrations@^14.3.0`
**Confidence:** HIGH (verified from Context7 docs and npm registry)
```
pip install "pyjwt[crypto]>=2.12.1"
**Why:** VueUse provides three composables this milestone needs, each saving 30-100 lines of custom code that would otherwise require careful browser-compatibility testing:
- `useDropZone` — drop-target zones for drag-and-drop file upload (handles `dragenter`/`dragleave`/`drop`, exposes `isOverDropZone` reactive ref, correct `dataTypes` filtering, Safari limitations documented)
- `onKeyStroke` — keyboard shortcut registration without manual `addEventListener`/`removeEventListener` lifecycle management
- `useSortable` (from `@vueuse/integrations`) — thin wrapper over SortableJS for list/folder reordering, auto-syncs to reactive array
**Bundle impact:** `@vueuse/core` unpacked size is ~870 KB but it is fully tree-shakeable — only imported composables are included in the bundle. Each composable is typically 1-3 KB gzipped. This is the correct way to import:
```js
import { useDropZone, onKeyStroke } from '@vueuse/core'
import { useSortable } from '@vueuse/integrations/useSortable'
```
Use `pyjwt[crypto]` to enable RS256/ES256 if asymmetric keys are ever needed. For this project HS256 with a strong secret is sufficient (single-issuer, stateless).
**Critical version constraint:** VueUse v14.0+ requires `vue@^3.5.0`. The current `package.json` pins `^3.4.0`. Vue 3.5 has no breaking changes for Options API components (confirmed from official Vue blog — purely additive release: reactivity improvements, -56% memory usage). Bump Vue to `^3.5.0` as part of this milestone's first phase.
**Do not use `python-jose`** — the FastAPI tutorial no longer references it, it has had unmaintained periods, and the official docs have migrated entirely to PyJWT.
**`@vueuse/integrations` peer deps:** `useSortable` requires `sortablejs@^1` as a peer dependency. Install `sortablejs` alongside.
### Password Hashing — pwdlib 0.2.x with Argon2
**Confidence: HIGH** (verified from current FastAPI security tutorial — `pwdlib[argon2]` is the documented recommendation, replacing the old `passlib[bcrypt]` guidance)
```
pip install "pwdlib[argon2]>=0.2.0"
```bash
npm install @vueuse/core@^14.3.0 @vueuse/integrations@^14.3.0 sortablejs@^1.15.7
npm install -D @types/sortablejs
```
**Why Argon2 over bcrypt:** Argon2id won the Password Hashing Competition, is memory-hard (resistant to GPU/ASIC attacks), and is the default recommendation in OWASP 2025 guidelines. `pwdlib` is a thin, modern wrapper; it does not carry `passlib`'s legacy baggage.
**Exception:** If the existing codebase already stores any bcrypt hashes, keep `passlib[bcrypt]` for the migration phase to verify and re-hash on login, then remove it.
### TOTP 2FA — pyotp 2.9.x
**Confidence: MEDIUM** (standard library for RFC 6238 TOTP in Python; no competing library of comparable adoption exists; version from training data — verify on PyPI before pinning)
```
pip install "pyotp>=2.9.0"
```
`pyotp` implements RFC 6238 TOTP and RFC 4226 HOTP. It generates provisioning URIs compatible with Google Authenticator, Authy, and any standard TOTP app. Generates QR code URIs via `pyotp.totp.TOTP.provisioning_uri()`. Pair with `qrcode[pil]` or `segno` to render a QR code PNG for the setup screen.
For TOTP enrollment flow:
1. Generate secret: `pyotp.random_base32()`
2. Store secret encrypted at rest (Fernet — see credential encryption below)
3. Return provisioning URI + QR code to user
4. Verify one TOTP code before marking 2FA active
5. On login: verify password first, then verify TOTP code with a 1-period window (`valid_window=1`)
### Session / Token Strategy
**Confidence: HIGH** (pattern; no external library needed beyond PyJWT)
Use a **dual-token pattern** for stateless horizontal scaling:
- **Access token**: Short-lived JWT (15 min), HS256, payload includes `user_id`, `email`, `roles`, `jti`
- **Refresh token**: Long-lived JWT (730 days), stored as `httpOnly` + `Secure` cookie, rotated on use
- **Revocation**: Store `jti` of revoked refresh tokens in PostgreSQL `token_blacklist` table with TTL. Clean up expired entries via a periodic task.
No additional session library is needed. Do not use Redis for token storage — the PROJECT.md requires stateless backends; a PostgreSQL blacklist table is sufficient for this scale and avoids another infrastructure dependency.
FastAPI's `fastapi.security.OAuth2PasswordBearer` handles the Bearer extraction from headers. Implement `get_current_user` as a dependency.
### Credential Encryption (Cloud OAuth Tokens, TOTP Secrets) — cryptography 44.x Fernet
**Confidence: HIGH** (cryptography is a stable, core Python library; Fernet is its symmetric authenticated encryption primitive)
```
pip install "cryptography>=44.0.0"
```
`cryptography.fernet.Fernet` provides AES-128-CBC + HMAC-SHA256 in a single call. Key lives in an env var (`FERNET_KEY`), never in the database. Encrypt per-user cloud OAuth tokens and TOTP secrets before writing to PostgreSQL. This satisfies the PROJECT.md privacy constraint: admin queries never see plaintext credentials.
**Key derivation pattern:** Generate one `Fernet.generate_key()` at deploy time, store in `CREDENTIAL_ENCRYPTION_KEY` env var, inject via Docker Compose secrets. Do not store the key in the database or expose it through any admin endpoint.
**Do NOT use VueUse as a global plugin** (`app.use(VueUse)`) — it doesn't have one. Import composables individually, always.
---
## Area 2: Database
### 2. vue-virtual-scroller — `vue-virtual-scroller`
### ORM — SQLAlchemy 2.0 (async) + psycopg (v3)
**Recommended:** `vue-virtual-scroller@^3.0.4`
**Confidence:** HIGH (verified from Context7 docs and npm registry; published 2026-05-20, actively maintained)
**Confidence: HIGH for SQLAlchemy 2.0 async; MEDIUM for psycopg v3 vs asyncpg** (SQLAlchemy 2.0 async confirmed stable; driver choice between asyncpg and psycopg 3 is functionally equivalent — see note below)
**Why:** Document lists can reach hundreds or thousands of entries per user. Without virtual scrolling, rendering all `DocumentCard` components simultaneously degrades scroll performance significantly on mobile. `vue-virtual-scroller` is the canonical Vue 3 virtual list library by Guillaume Chau (Akryum, Vue core team contributor). It ships ESM-only, is Vite-native, and requires Vue 3.3+ (compatible with our 3.5 bump).
**Components used:**
- `<RecycleScroller>` — fixed item height, highest performance. Use for the main document grid where item height is deterministic.
- `<DynamicScroller>` — variable item height. Use only if needed (e.g. list view with text previews of different lengths).
**Bundle size:** Unpacked ~461 KB; ESM-only means Rollup tree-shakes unused components. Requires importing its CSS: `import 'vue-virtual-scroller/index.css'`.
**Integration note:** Because `RecycleScroller` pools and reuses DOM nodes, every item component must be fully reactive from props alone — no internal `mounted()` side effects that assume a fresh component per item. Options API components that follow the props-down / events-up pattern (as required by the existing architecture) are fully compatible.
```bash
npm install vue-virtual-scroller@^3.0.4
```
pip install "sqlalchemy[asyncio]>=2.0.36" "psycopg[asyncio,binary]>=3.2.0"
```
**Why SQLAlchemy 2.0 over SQLModel for this project:**
SQLModel 0.0.38 (current version per FastAPI release notes) is the official recommendation for greenfield apps, but for this brownfield migration it introduces risk:
1. SQLModel does not yet have first-class async session documentation. Its `AsyncSession` support works but is inherited from SQLAlchemy and not well-documented in SQLModel's own tutorials.
2. The existing codebase already has Pydantic models for all API schemas. Adding SQLModel means maintaining a second model hierarchy (table models vs response models) which increases complexity mid-migration.
3. SQLAlchemy 2.0 `AsyncSession` with `asyncpg` or `psycopg[asyncio]` is battle-tested and the pattern used by the FastAPI full-stack template.
4. Alembic (see below) integrates directly with SQLAlchemy — the migration toolchain is native.
**Recommended pattern:**
```python
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
engine = create_async_engine(
"postgresql+psycopg://user:pass@db:5432/docuvault",
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
```
**asyncpg vs psycopg 3:** Both work with SQLAlchemy 2.0 async. Prefer `psycopg[asyncio,binary]` for this project because:
- psycopg 3 is the PostgreSQL-sanctioned successor to psycopg2, meaning the same package covers both sync (Alembic) and async (FastAPI) paths
- asyncpg is async-only and requires a separate sync driver for Alembic migrations
- psycopg 3 binary wheel has comparable performance to asyncpg in benchmarks
**Conflict note:** psycopg2 is incompatible with psycopg 3 (different import names: `psycopg2` vs `psycopg`). If any existing dependency pins `psycopg2`, update it. Do not install both.
### Migrations — Alembic 1.14.x
**Confidence: HIGH** (Alembic is the only migration tool for SQLAlchemy; no viable alternative)
```
pip install "alembic>=1.14.0"
```
**Async migration pattern** — Alembic's `env.py` needs special handling for async engines. Use the `run_sync` pattern:
```python
# alembic/env.py
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
def run_migrations_online():
connectable = create_async_engine(settings.DATABASE_URL)
async def run():
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
asyncio.run(run())
```
**Migration strategy for brownfield migration:**
1. Create initial migration that builds schema from scratch (new install path)
2. Create a separate data migration script that reads flat-file JSON and inserts rows
3. Run both in sequence during the deploy that replaces the existing data
---
## Area 3: Object Storage (MinIO)
### 3. Drag-and-Drop for File Upload — Native HTML5 + VueUse `useDropZone`
### MinIO Python SDK 7.x
**Recommended:** No additional library. Use `useDropZone` from `@vueuse/core` (already added above).
**Confidence:** HIGH
**Confidence: MEDIUM** (MinIO SDK is well-known; exact version from training data — verify on PyPI before pinning)
**Why not a dedicated library:** The drag-and-drop requirement for this milestone is drop-zone file upload (files dragged from OS to a browser area). `useDropZone` covers this completely. Dedicated upload libraries like `dropzone-vue` or `vue-file-uploader` are either poorly maintained, opinionated about UI, or duplicate what the existing upload flow already does. The existing backend upload endpoint and progress tracking are already implemented — adding a library that wraps both drop detection and upload would require replacing working code.
```
pip install "minio>=7.2.0"
```
**For folder/list reordering** (drag document into a different folder), use `useSortable` from `@vueuse/integrations` (already added above via SortableJS). SortableJS handles this precisely: it is a low-level, pure-DOM drag-sort library with no Vue-specific assumptions.
The MinIO Python SDK (`minio`) wraps the S3 API. It is synchronous. Use it inside FastAPI via `asyncio.to_thread()` for large streaming operations, or call it directly for short metadata operations.
**Important:** Do NOT use the MinIO SDK for high-throughput streaming (uploads/downloads of large documents). Instead, use **pre-signed URLs**:
```python
from minio import Minio
from datetime import timedelta
client = Minio(
"minio:9000",
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=False, # True in production with TLS
)
# Generate upload URL (client uploads directly to MinIO, bypassing FastAPI)
url = client.presigned_put_object(
bucket_name="user-documents",
object_name=f"{user_id}/{document_id}",
expires=timedelta(minutes=15),
)
# Generate download URL
url = client.presigned_get_object(
bucket_name="user-documents",
object_name=f"{user_id}/{document_id}",
expires=timedelta(minutes=60),
)
```
Pre-signed URLs mean FastAPI never proxies document bytes — only metadata flows through the backend. This is critical for horizontal scaling (no file pinning to a specific backend instance) and for quota enforcement (track bytes at upload-record creation time, not at streaming time).
**Quota enforcement pattern:**
1. Client requests an upload token from FastAPI
2. FastAPI checks current usage against `user.quota_used_bytes` + `user.quota_limit_bytes`
3. If within quota, record tentative size, issue pre-signed PUT URL
4. After successful upload, confirm actual size (via MinIO event or HEAD request) and commit to quota
**boto3 alternative:** `boto3` works against MinIO via `endpoint_url` override. Only use it if you anticipate migrating to AWS S3 — for a MinIO-only deployment the native SDK is simpler and avoids the large boto3 dependency tree.
### aiobotocore / aiominio — Do Not Use
The async MinIO/S3 client libraries (`aiobotocore`, `aiominio`) add significant complexity with uncertain maintenance status. The pre-signed URL pattern renders them unnecessary — the sync SDK is only called in the FastAPI path for URL generation (microseconds), not for streaming.
**Do NOT use `vue-draggable-plus` (0.6.1, last published 2026-01-11):** It wraps SortableJS but adds a Vue component abstraction layer that conflicts with the project's data-provider view pattern. It also adds bundle overhead that `useSortable` avoids. The last publish date of January 2026 is acceptable for a stable library, but there is no reason to prefer it over the VueUse integration that is already in the bundle.
---
## Area 4: Cloud Storage SDKs
### 4. Keyboard Shortcuts — VueUse `onKeyStroke`
### OneDrive — msgraph-sdk 1.x + azure-identity 1.x
**Recommended:** `onKeyStroke` from `@vueuse/core` (already added). No additional library.
**Confidence:** HIGH
**Confidence: MEDIUM** (Microsoft Graph Python SDK is GA per official Microsoft docs; exact version from training data — verify on PyPI)
**Why no dedicated library:** Libraries like `vue-shortkey` or `hotkeys-js` exist but are unnecessary overhead. `onKeyStroke` from VueUse handles:
- Single key listening: `onKeyStroke('/', handler)` (focus search)
- Modifier combos: check `event.ctrlKey` / `event.metaKey` inside the handler
- Automatic cleanup when the component is unmounted (it wraps `addEventListener`/`removeEventListener` with `onUnmounted`)
```
pip install "msgraph-sdk>=1.0.0" "azure-identity>=1.19.0"
```
For a document manager, typical shortcuts are: `/` (search focus), `Escape` (close modal/panel), `Delete` (delete selected), `Enter` (open/confirm). All fit `onKeyStroke` with no wrapper library.
Microsoft Graph Python SDK (`msgraph-sdk`) is the official Microsoft library for OneDrive access. It covers:
- Drive item CRUD (`/me/drive/items/{id}`)
- Upload sessions for large files
- Delta sync for listing changes
**Implementation pattern:**
```js
// In setup() or a composable
import { onKeyStroke } from '@vueuse/core'
For server-side (backend-behalf-of-user) flows use the **OAuth 2.0 Authorization Code** flow with `azure-identity`'s `OnBehalfOfCredential` or a custom token provider wrapping stored refresh tokens.
**Important:** Microsoft's OneDrive tokens (access + refresh) must be stored encrypted at rest using the Fernet approach described in Area 1. Refresh tokens are long-lived and grant significant access.
**Package note:** The older `O365` package and `office365-REST-python-client` both wrap Graph API but are community-maintained. Prefer the official `msgraph-sdk` which Microsoft now actively develops and tests against Graph v1.0.
### Google Drive — google-api-python-client 2.x + google-auth-oauthlib 1.x
**Confidence: MEDIUM** (package names confirmed from Google Cloud docs; exact minor versions from training data)
```
pip install "google-api-python-client>=2.150.0" "google-auth-oauthlib>=1.2.0" "google-auth-httplib2>=0.2.0"
```
Use the Drive API v3 (not v2 — v2 is deprecated). For server-side OAuth flows:
- Use `google_auth_oauthlib.flow.Flow` for the authorization redirect
- Store OAuth2 credentials (`Credentials` object JSON) encrypted in PostgreSQL
- Rebuild credentials from stored JSON on each API call: `google.oauth2.credentials.Credentials.from_authorized_user_info(json_data, scopes)`
Required scopes for this project: `https://www.googleapis.com/auth/drive.file` (access only files created by the app — minimum privilege).
### Nextcloud — webdav4 0.x
**Confidence: MEDIUM** (webdav4 is the most actively maintained Python WebDAV client as of 2024; version from training data)
```
pip install "webdav4[fsspec]>=0.9.8"
```
Nextcloud exposes two APIs: WebDAV (for file operations) and OCS (for sharing, users, and metadata). For document upload/download, WebDAV is sufficient. `webdav4` wraps the WebDAV protocol with a clean interface and optional `fsspec` integration.
**Nextcloud-specific paths:**
- WebDAV root: `https://{host}/remote.php/dav/files/{username}/`
- Authentication: Basic auth (username + app password) or Bearer token
For Nextcloud, recommend storing an **app password** (user-generated in Nextcloud settings) rather than OAuth tokens — it's simpler to implement and doesn't require an OAuth app registration.
**webdavclient3 alternative:** An older library with less active maintenance. `webdav4` is preferred.
### Generic WebDAV — webdav4 (same package)
`webdav4` handles generic RFC 4918 WebDAV, so any WebDAV-compatible server (ownCloud, Seafile WebDAV bridge, etc.) is covered by the same adapter.
---
## Area 5: Storage Abstraction
### Pattern — Protocol-based Adapter (no third-party library needed)
**Confidence: HIGH** (this is the architecture mandated by PROJECT.md and mirrors the existing AI provider pattern)
Define a `StorageBackend` Protocol that all adapters implement:
```python
from typing import Protocol, AsyncIterator
class StorageBackend(Protocol):
async def put_object(
self,
path: str,
data: AsyncIterator[bytes],
size: int,
content_type: str,
) -> None: ...
async def get_object(self, path: str) -> AsyncIterator[bytes]: ...
async def delete_object(self, path: str) -> None: ...
async def list_objects(self, prefix: str) -> list[str]: ...
async def get_presigned_url(self, path: str, expires_seconds: int) -> str | None: ...
```
Concrete implementations:
- `MinIOBackend` — uses the MinIO SDK + pre-signed URLs
- `OneDriveBackend` — uses `msgraph-sdk`
- `GoogleDriveBackend` — uses `google-api-python-client`
- `NextcloudBackend` — uses `webdav4`
The `get_presigned_url` method returns `None` for backends that don't support it (Google Drive, Nextcloud). FastAPI then falls back to proxying the stream through the backend for those cases.
**No FSSpec dependency at the protocol layer** — FSSpec (`fsspec`) can be used internally by `webdav4` but should not leak into the storage abstraction interface. The interface must be async-native.
**Per-user backend resolution:** Store `user.storage_backend_type` (enum: `minio`, `onedrive`, `gdrive`, `nextcloud`) and `user.storage_backend_credential_id` (FK to encrypted credentials table) in PostgreSQL. A `StorageBackendFactory` resolves the correct adapter on each request.
---
## Area 6: Vue 3 Auth Patterns
### State Management — Pinia 2.x
**Confidence: HIGH** (Pinia is the official Vue 3 state management library per vuejs.org; Vuex is deprecated for Vue 3)
```
npm install pinia@^2.0.0
```
Store auth state in a Pinia store:
```typescript
// stores/auth.ts
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
accessToken: null as string | null,
user: null as User | null,
}),
getters: {
isAuthenticated: (state) => !!state.accessToken,
},
actions: {
setTokens(accessToken: string) {
this.accessToken = accessToken
// Refresh token is httpOnly cookie — not stored in JS
},
logout() {
this.accessToken = null
this.user = null
},
},
onKeyStroke('/', (e) => {
e.preventDefault()
searchInput.value?.focus()
})
```
### Token Storage Strategy
---
**Confidence: HIGH** (security best practice, not library-specific)
### 5. Loading Skeletons — Pure Tailwind CSS
- **Access token:** Store in Pinia memory state only (not `localStorage`, not `sessionStorage`). Survives tab navigation but is cleared on page refresh — intentional for security.
- **Refresh token:** Store as `httpOnly; Secure; SameSite=Strict` cookie set by FastAPI. Never readable by JavaScript. Refresh is done by hitting a `/auth/refresh` endpoint which reads the cookie server-side.
- **Do not use `localStorage` for tokens** — XSS vulnerability. In a document management app users upload arbitrary files; stored XSS risk is not theoretical.
**Recommended:** No library. Use `animate-pulse` utility with placeholder shapes.
**Confidence:** HIGH
On page load/refresh, immediately call `/auth/me` (which uses the httpOnly refresh cookie automatically). If it returns 200, restore access token from the response. If 401, redirect to login.
**Why no library:** Skeleton loaders in this app are simple rectangular placeholders — cards, list rows, and a few text lines. Tailwind's `animate-pulse` combined with `bg-gray-200 dark:bg-gray-700` rounded shapes is sufficient and produces zero additional bundle weight. A skeleton library (`vue3-skeleton`, etc.) would add a dependency for something that is 5 lines of Tailwind HTML per component.
### Protected Routes — Vue Router 4.x Navigation Guards
**Confidence: HIGH** (Vue Router 4 is the Vue 3 router; this is a standard pattern)
```
npm install vue-router@^4.0.0
**Recommended pattern for a document card skeleton:**
```html
<div class="animate-pulse rounded-lg border border-gray-200 p-4">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
```
```typescript
// router/index.ts
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
// Attempt silent refresh before redirecting
try {
await auth.silentRefresh() // hits /auth/refresh endpoint
} catch {
return { name: 'login', query: { redirect: to.fullPath } }
}
}
For a shimmer effect (more polished than pulse), use a CSS `@keyframes` background-position animation — still zero JS dependency. Only add a library if shimmer is required across 10+ distinct skeleton shapes that need consistent management.
---
### 6. Tailwind CSS Plugins
**Recommended:** `@tailwindcss/forms@^0.5.11`
**Confidence:** HIGH (official Tailwind Labs plugin, 4,500+ GitHub stars, supports both v3 and v4)
**Why `@tailwindcss/forms`:** DocuVault has several form-heavy screens (login, registration, upload dialog, admin user management, cloud credential entry). Browser defaults for `<input>`, `<select>`, `<textarea>`, `<checkbox>`, and `<radio>` are inconsistent across platforms and resist Tailwind utility styling. `@tailwindcss/forms` provides a minimal, accessible reset that makes all form elements style-consistent and fully overridable with utilities. Unpacked size is 55 KB; it is a PostCSS plugin with zero runtime JS.
```bash
npm install -D @tailwindcss/forms@^0.5.11
```
```js
// tailwind.config.js
plugins: [require('@tailwindcss/forms')]
```
**Container queries (`@tailwindcss/container-queries`):** Do NOT add. In Tailwind v3, this would be the `@tailwindcss/container-queries` plugin. However, the v0.2 responsive layout target is standard viewport-based breakpoints (`sm:`, `md:`, `lg:`). Container queries are useful when the same component is rendered in contexts of different widths (sidebar vs. main content), but the DocuVault component architecture uses the data-provider view pattern where layout is controlled by the view, not by individual components. This eliminates the primary use case for container queries in this codebase. If a future phase introduces a component genuinely embedded in variable-width containers, add the plugin then.
**Typography plugin (`@tailwindcss/typography`):** Do NOT add. There is no long-form rich text content in this app — document content is displayed in a preview iframe, not rendered as styled HTML.
---
### 7. Bundle Analysis — `rollup-plugin-visualizer`
**Recommended:** `rollup-plugin-visualizer@^7.0.1` (dev dependency, build-time only)
**Confidence:** MEDIUM-HIGH (standard tool, actively maintained, Vite-native)
**Why:** The bundle size reduction goal requires knowing what is currently in the bundle. `rollup-plugin-visualizer` generates an interactive treemap that shows each module's contribution. Run once at the start of v0.2 to establish baseline, and again after refactoring to verify improvement. It does not affect the production build.
```bash
npm install -D rollup-plugin-visualizer@^7.0.1
```
```js
// vite.config.js — enable only when analyzing, not in every build
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
process.env.ANALYZE ? visualizer({ open: true, filename: 'dist/stats.html' }) : null,
].filter(Boolean),
})
```
Mark routes with `meta: { requiresAuth: true }`. The guard attempts a silent refresh before redirecting — this handles the page-refresh case where the access token is gone but the refresh cookie is still valid.
### Refresh Token Handling — Axios Interceptors
**Confidence: HIGH** (standard pattern for token refresh in SPA + REST API; Axios is already common in Vue 3 projects)
```
npm install axios@^1.0.0
```
```typescript
// api/client.ts
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true
await authStore.silentRefresh()
error.config.headers['Authorization'] = `Bearer ${authStore.accessToken}`
return axiosInstance(error.config)
}
return Promise.reject(error)
}
)
```
### TOTP UI — No dedicated library needed
The TOTP enrollment flow only requires:
1. Display a QR code image (returned as base64 PNG from FastAPI, rendered via `<img :src="qrDataUrl">`)
2. An OTP input field (6-digit numeric input, `type="text" inputmode="numeric" maxlength="6"`)
No Vue TOTP component library is needed. Avoid heavy auth UI libraries (Auth0 components, etc.) — they assume SSO flows incompatible with this design.
Run with: `ANALYZE=1 npm run build`
---
## Full Dependency Summary
## Vite Upgrade
### Python (backend)
**Recommended:** Bump from `^5.2.0` to `^6.4.3` (the stable Vite 6 release; avoid Vite 7/8 beta)
**Confidence:** HIGH (Vite 6 released 2024-11; Vite 7 and 8 are in beta as of 2026-06)
```
# requirements.txt additions for this milestone
**Why Vite 6 over staying on Vite 5:** Vite 6 is a stable, minimal-breaking-change major release. Breaking changes for this project are:
1. **Node.js ≥ 20.0.0 required** — Docker Compose service should already use Node 20+ (verify `FROM node:20-alpine` or equivalent in any Dockerfile that runs the frontend build).
2. **No other breaking changes apply** to this project (CSS library output file rename only affects library authors, not app builds; SSR CSS change is not applicable).
# Auth
pyjwt[crypto]>=2.12.1
pwdlib[argon2]>=0.2.0
pyotp>=2.9.0
cryptography>=44.0.0
qrcode[pil]>=8.0.0 # TOTP QR code generation
Vite 6 delivers improved dependency pre-bundling and module runner improvements relevant to the performance goals. The built-in code splitting (`import()` dynamic imports for lazy routes) and tree-shaking require no config changes — they are on by default. The manual chunking config for vendor splitting (Pinia, Vue Router) in `build.rollupOptions.output.manualChunks` can be used if the baseline analysis shows large vendor chunks being repeated across async routes.
# Database
sqlalchemy[asyncio]>=2.0.36
psycopg[asyncio,binary]>=3.2.0
alembic>=1.14.0
**Do NOT upgrade to Vite 7 or 8** — both are in beta as of this research date and are not appropriate for a production milestone.
# Object storage
minio>=7.2.0
# Cloud storage
msgraph-sdk>=1.0.0
azure-identity>=1.19.0
google-api-python-client>=2.150.0
google-auth-oauthlib>=1.2.0
google-auth-httplib2>=0.2.0
webdav4>=0.9.8
```
### JavaScript (frontend)
```json
{
"dependencies": {
"pinia": "^2.0.0",
"vue-router": "^4.0.0",
"axios": "^1.0.0"
}
}
```bash
npm install -D vite@^6.4.3 @vitejs/plugin-vue@^5.0.0
```
---
## Alternatives Considered
## Vue Version Bump
| Category | Recommended | Alternative | Why Not |
|----------|-------------|-------------|---------|
| JWT | PyJWT 2.12.1 | python-jose | FastAPI docs migrated away; python-jose had unmaintained periods; PyJWT is the Python JWT spec reference implementation |
| Password hashing | pwdlib + Argon2 | passlib + bcrypt | passlib is in maintenance mode; bcrypt is weaker than Argon2 (not memory-hard); pwdlib is the current FastAPI recommendation |
| ORM | SQLAlchemy 2.0 async | SQLModel 0.0.38 | SQLModel is great for greenfield but brownfield migration risk is higher; async SQLModel docs are thin; direct SQLAlchemy gives full control |
| ORM | SQLAlchemy 2.0 async | Tortoise ORM 0.21.x | Tortoise has its own metaclass system that conflicts with Pydantic models; integration with FastAPI requires aerich for migrations (separate toolchain); less ecosystem momentum than SQLAlchemy |
| PostgreSQL driver | psycopg 3 | asyncpg | asyncpg is async-only (needs separate sync driver for Alembic); psycopg 3 covers both paths; psycopg 3 is the official PostgreSQL Python driver successor |
| OneDrive | msgraph-sdk | O365 / office365-REST | Community-maintained; Graph API coverage incomplete; Microsoft has deprecated these in favor of the official SDK |
| S3 integration | minio native SDK | boto3 | boto3 pulls in botocore (large dep tree); minio SDK is purpose-built and simpler for MinIO-only use; boto3 makes sense only if AWS S3 migration is planned |
| Frontend state | Pinia | Vuex | Vuex is the Vue 2 store; Vue 3 official recommendation is Pinia |
| Token storage | Memory (Pinia) | localStorage | localStorage is vulnerable to XSS; document management apps with file upload have non-trivial XSS surface |
**Required:** `^3.4.0``^3.5.0`
**Confidence:** HIGH (no breaking changes for Options API; confirmed from official Vue 3.5 blog post)
This is required by `@vueuse/core@^14.0`. Vue 3.5 is a drop-in upgrade for Options API components — the release is purely additive (reactivity performance improvements, `useTemplateRef()` for Composition API). No component changes are needed.
```bash
npm install vue@^3.5.0
```
---
## What NOT to Use
## Backend — No New Python Packages Needed
| Avoid | Why | Use Instead |
|-------|-----|-------------|
| `python-jose` | No longer referenced by FastAPI docs; had maintenance gaps; `python-multipart` dependency overlap caused version conflicts | `pyjwt[crypto]` |
| `passlib[bcrypt]` for new hashes | In maintenance mode; bcrypt is not memory-hard; weaker than Argon2 against modern GPU attacks | `pwdlib[argon2]` (keep passlib only for migrating existing bcrypt hashes) |
| `Tortoise ORM` | Incompatible metaclass system creates friction with Pydantic v2; aerich migration toolchain is less mature; smaller ecosystem | SQLAlchemy 2.0 async |
| `tiangolo/uvicorn-gunicorn-fastapi` Docker image | **Deprecated** by FastAPI author as of 2024. Official FastAPI docs now recommend building from `python:3.x` base directly | Plain `python:3.12-slim` base image |
| `databases` (encode/databases) | Was an early async DB wrapper; SQLAlchemy 2.0 async has superseded its use case; the project is effectively in maintenance mode | SQLAlchemy 2.0 `AsyncSession` |
| `localStorage` for auth tokens | XSS-accessible; a document management app is an attractive XSS target | httpOnly cookies for refresh tokens; Pinia memory for access tokens |
| Multiple per-user Fernet keys | Overly complex key management; one platform-level Fernet key is sufficient — user data isolation is enforced at the PostgreSQL row level, not at the encryption key level | Single `CREDENTIAL_ENCRYPTION_KEY` env var |
**Confidence:** HIGH
FastAPI's `APIRouter` (already used) is the correct and sufficient tool for router decomposition. Splitting large router files into smaller modules uses only `APIRouter(prefix=..., tags=[...])` and `app.include_router()` — both already in the project. No new library is warranted.
**Pattern for large router decomposition:**
```
backend/routers/
documents/
__init__.py # from .upload import router as upload_router; etc.
upload.py # APIRouter for POST /documents
search.py # APIRouter for GET /documents
share.py # APIRouter for share endpoints
admin/
__init__.py
users.py
audit.py
settings.py
```
Include in `main.py`:
```python
from routers.documents import router as documents_router
from routers.admin import router as admin_router
app.include_router(documents_router, prefix="/documents", tags=["documents"])
app.include_router(admin_router, prefix="/admin", tags=["admin"])
```
This is a pure refactor — no new dependencies, no behavior change, no test changes except updating import paths.
---
## Stack Compatibility Notes
## Full v0.2 Dependency Diff
| Concern | Detail |
|---------|--------|
| Pydantic v2 required | FastAPI 0.136.x requires `pydantic>=2.9.0`. SQLAlchemy 2.0 is Pydantic v2-compatible. The existing app must already be on Pydantic v2 to run FastAPI 0.136. |
| psycopg 3 vs psycopg 2 | If the existing codebase (or any dependency) imports `psycopg2`, there will be a name conflict. `psycopg` (v3) imports as `import psycopg`, so they can technically coexist in the same environment, but avoid having both. |
| Starlette 1.0.0 | Bumped in FastAPI 0.136.1 — this is a major version. If the existing app uses any Starlette internals directly (middleware, routing), audit for breaking changes before upgrading FastAPI. |
| PyJWT 2.x vs 1.x API | PyJWT 2.x changed `jwt.encode()` to return `str` (not `bytes`). If the existing codebase has any JWT code using the 1.x API, update the call sites. |
| Vue Router 4 + Pinia SSR | Not applicable (no SSR in this project), but worth noting: Pinia's state is per-request in SSR contexts. For this SPA deployment, no issues. |
| Argon2 system dependency | `pwdlib[argon2]` requires `argon2-cffi` which needs a C compiler or binary wheel. The official Python Docker image (`python:3.12-slim`) provides wheels for common platforms — no `build-essential` needed. |
### `npm install` (production dependencies)
```bash
npm install \
vue@^3.5.0 \
@vueuse/core@^14.3.0 \
@vueuse/integrations@^14.3.0 \
sortablejs@^1.15.7 \
vue-virtual-scroller@^3.0.4
```
### `npm install -D` (dev dependencies)
```bash
npm install -D \
vite@^6.4.3 \
@vitejs/plugin-vue@^5.0.0 \
@tailwindcss/forms@^0.5.11 \
rollup-plugin-visualizer@^7.0.1 \
@types/sortablejs@^1.15.0
```
### Python backend additions
**None.** All backend work (router decomposition, service extraction) uses FastAPI primitives already present.
---
## Version Compatibility Matrix
## Alternatives Considered and Rejected
| Package | Version | Python | Pydantic | FastAPI |
|---------|---------|--------|---------|--------|
| pyjwt | 2.12.1 | 3.8+ | any | 0.100+ |
| pwdlib | 0.2.x | 3.9+ | v2 | 0.100+ |
| sqlalchemy | 2.0.36+ | 3.8+ | v2 (via fastapi) | 0.100+ |
| psycopg (v3) | 3.2.x | 3.8+ | — | — |
| alembic | 1.14.x | 3.8+ | — | — |
| minio | 7.2.x | 3.7+ | — | — |
| msgraph-sdk | 1.x | 3.8+ | — | — |
| azure-identity | 1.19.x | 3.8+ | — | — |
| pinia | 2.x | — | — | — |
| vue-router | 4.x | — | — | — |
| Category | Rejected | Reason |
|----------|----------|--------|
| Drag-and-drop file upload | `dropzone-vue`, `vue-file-uploader` | Opinionated UI, poor maintenance, duplicates existing backend upload flow |
| Drag-and-drop sorting | `vue-draggable-plus@0.6.1` | Adds Vue component abstraction over SortableJS that conflicts with data-provider view pattern; VueUse `useSortable` covers the same need with less surface area |
| Virtual scrolling | `@tanstack/vue-virtual` | Lower-level API requiring more integration code than `vue-virtual-scroller`; no meaningful bundle advantage; vue-virtual-scroller is Vue-native |
| Keyboard shortcuts | `hotkeys-js`, `vue-shortkey` | Unnecessary wrapper over `addEventListener`; VueUse `onKeyStroke` is sufficient and already in the bundle |
| Loading skeletons | `vue3-skeleton` | 5-line Tailwind pattern produces identical results; no justification for an additional dependency |
| Tailwind container queries | `@tailwindcss/container-queries` | Viewport breakpoints are sufficient for this app's layout; container queries solve a problem this codebase does not have |
| Tailwind typography | `@tailwindcss/typography` | No prose HTML content in this app |
| Vite 7 / 8 | Latest beta | Both in beta as of research date; not appropriate for a production milestone |
---
## Compatibility Matrix (v0.2 additions)
| Package | Version | Vue Requirement | Vite Compatibility | Tree-Shakeable |
|---------|---------|-----------------|-------------------|----------------|
| `@vueuse/core` | ^14.3.0 | ^3.5.0 | Vite 5+ | Yes — import individually |
| `@vueuse/integrations` | ^14.3.0 | ^3.5.0 | Vite 5+ | Yes |
| `sortablejs` | ^1.15.7 | — (plain JS) | Any | Yes |
| `vue-virtual-scroller` | ^3.0.4 | ^3.3.0 | Vite (ESM) | Partial (register components individually) |
| `@tailwindcss/forms` | ^0.5.11 | — (PostCSS) | Any | N/A (CSS plugin) |
| `rollup-plugin-visualizer` | ^7.0.1 | — (build tool) | Vite 5+ | N/A (dev only) |
| `vue` | ^3.5.0 | — | — | — |
| `vite` | ^6.4.3 | — | — | — |
---
## Sources
- FastAPI official release notes (verified 2026-05-21): https://fastapi.tiangolo.com/release-notes/ — PyJWT 2.12.1, SQLModel 0.0.38, Starlette 1.0.0, pydantic>=2.9.0 confirmed
- FastAPI security tutorial (verified 2026-05-21): https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/ — PyJWT recommended, python-jose absent, pwdlib[argon2] recommended
- FastAPI SQL databases tutorial (verified 2026-05-21): https://fastapi.tiangolo.com/tutorial/sql-databases/ — SQLModel documented as recommended ORM
- FastAPI Docker guide (verified 2026-05-21): https://fastapi.tiangolo.com/deployment/docker/ — tiangolo/uvicorn-gunicorn-fastapi deprecated confirmed
- Microsoft Graph SDK overview (verified 2026-05-21): https://learn.microsoft.com/en-us/graph/sdks/sdks-overview — Python SDK confirmed GA
- pwdlib argon2 version: MEDIUM confidence — training data, verify on PyPI
- pyotp version: MEDIUM confidence — training data, verify on PyPI
- minio Python SDK version: MEDIUM confidence — training data, verify on PyPI
- webdav4 version: MEDIUM confidence — training data, verify on PyPI
- google-api-python-client version: MEDIUM confidence — training data, verify on PyPI
- azure-identity / msgraph-sdk minor versions: MEDIUM confidence — training data, verify on PyPI
- VueUse v14.3.0 docs and peer dependencies: Context7 `/vueuse/vueuse`, verified via npm registry (2026-06-07)
- vue-virtual-scroller v3.0.4: Context7 `/akryum/vue-virtual-scroller`, npm registry (last published 2026-05-20)
- vue-draggable-plus v0.6.1: npm registry (last published 2026-01-11) — rejected
- sortablejs v1.15.7: npm registry (2026-06-07)
- Tailwind CSS container queries: official Tailwind CSS announcement — built into v4 core; plugin for v3 would be `@tailwindcss/container-queries` — rejected as out of scope
- @tailwindcss/forms: official Tailwind Labs GitHub (tailwindlabs/tailwindcss-forms), 4,500+ stars, supports v3 and v4
- rollup-plugin-visualizer: npm registry, v7.0.1 (2026-06-07)
- Vue 3.5 no-breaking-changes confirmation: https://blog.vuejs.org/posts/vue-3-5
- Vite 6 migration guide: https://v6.vite.dev/guide/migration
- FastAPI router decomposition: https://fastapi.tiangolo.com/tutorial/bigger-applications/ (built-in, no new deps)
---
*Stack research for: DocuVault multi-user auth, PostgreSQL, MinIO, cloud integrations*
*Researched: 2026-05-21*
*Stack research for: DocuVault v0.2 — UI Overhaul and Optimization*
*Researched: 2026-06-07*