chore: Added User/InviteCode models, Alembic migration 016, auth utilit…
- "backend/models.py" - "backend/auth.py" - "backend/schemas.py" - "backend/requirements.txt" - "alembic/versions/016_add_users_and_invite_codes.py" GSD-Task: S02/T01
This commit is contained in:
parent
d77b749cfb
commit
a06ea946b1
17 changed files with 1164 additions and 3 deletions
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## Current State
|
||||
|
||||
Eighteen milestones complete. Phase 1 (build) is done. M018 completed the Phase 1→Phase 2 transition with a comprehensive site audit and Forgejo wiki bootstrap. The system is deployed and running on ub01 at `http://ub01:8096`. Forgejo knowledgebase wiki live at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/`.
|
||||
Eighteen milestones complete. Phase 1 (build) is done. M018 completed the Phase 1→Phase 2 transition with a comprehensive site audit and Forgejo wiki bootstrap. M019 is in progress — LightRAG deployed as graph-enhanced retrieval service. The system is deployed and running on ub01 at `http://ub01:8096`. Forgejo knowledgebase wiki live at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/`.
|
||||
|
||||
### What's Built
|
||||
|
||||
|
|
@ -56,6 +56,8 @@ Eighteen milestones complete. Phase 1 (build) is done. M018 completed the Phase
|
|||
- **Pipeline admin UI fixes** — Collapse toggle styling, mobile card layout, stage chevrons, filter right-alignment, creator dropdown visibility.
|
||||
- **Creator profile page** — Hero section (96px avatar, bio, genre pills), social link icons (9 platforms), stats dashboard (technique/video/moment counts), featured technique card with gradient border, enriched technique grid (summary, tags, moment count), inline admin editing (bio + social links), and 480px mobile responsive overrides.
|
||||
|
||||
- **LightRAG graph-enhanced retrieval** — Running as chrysopedia-lightrag service on port 9621. Uses DGX Sparks for LLM (entity extraction, summarization), Ollama nomic-embed-text for embeddings, Qdrant for vector storage, NetworkX for graph storage. 12 music production entity types configured. Exposed via REST API at /documents/text (ingest) and /query (retrieval with local/global/mix/hybrid modes).
|
||||
|
||||
- **Site Audit Report** — 467-line comprehensive reference document mapping all 12 routes, 41 API endpoints, 13 data models, CSS architecture (77 custom properties), and 8 Phase 2 integration risks. Lives at `.gsd/milestones/M018/slices/S01/SITE-AUDIT-REPORT.md`.
|
||||
- **Forgejo knowledgebase wiki** — 10-page architecture documentation at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/` covering Architecture, Data Model, API Surface, Frontend, Pipeline, Deployment, Development Guide, and Decisions.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Stand up the two foundational systems for Phase 2: creator authentication with c
|
|||
## Slice Overview
|
||||
| ID | Slice | Risk | Depends | Done | After this |
|
||||
|----|-------|------|---------|------|------------|
|
||||
| S01 | [B] LightRAG Deployment + Docker Integration | high | — | ⬜ | LightRAG service running in Docker, connected to Qdrant, entity extraction prompts producing music production entities from test input |
|
||||
| S01 | [B] LightRAG Deployment + Docker Integration | high | — | ✅ | LightRAG service running in Docker, connected to Qdrant, entity extraction prompts producing music production entities from test input |
|
||||
| S02 | [A] Creator Authentication + Dashboard Shell | high | — | ⬜ | Creator registers with invite code, logs in, sees dashboard shell with nav and profile settings |
|
||||
| S03 | [A] Consent Data Model + API Endpoints | medium | — | ⬜ | API accepts per-video consent toggles with versioned audit trail |
|
||||
| S04 | [B] Reindex Existing Corpus Through LightRAG | medium | S01 | ⬜ | All existing content indexed in LightRAG with entity/relationship graph alongside current search |
|
||||
|
|
|
|||
94
.gsd/milestones/M019/slices/S01/S01-SUMMARY.md
Normal file
94
.gsd/milestones/M019/slices/S01/S01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
id: S01
|
||||
parent: M019
|
||||
milestone: M019
|
||||
provides:
|
||||
- LightRAG service running on ub01 port 9621
|
||||
- Entity extraction API at POST /documents/text
|
||||
- Query API at POST /query with mix/local/global/hybrid modes
|
||||
- Music production entity types configured (Creator, Technique, Plugin, Synthesizer, Effect, Genre, DAW, SamplePack, SignalChain, Concept, Frequency, SoundDesignElement)
|
||||
requires:
|
||||
[]
|
||||
affects:
|
||||
- S04
|
||||
key_files:
|
||||
- docker-compose.yml
|
||||
- .env.lightrag
|
||||
- .gsd/KNOWLEDGE.md
|
||||
key_decisions:
|
||||
- Used Python urllib for container healthcheck since LightRAG image lacks curl
|
||||
- Used 127.0.0.1 instead of localhost in container healthcheck for reliable resolution
|
||||
- Set env_file required:true since LightRAG cannot start without config
|
||||
- Used NetworkX graph storage + Qdrant vector storage for initial deployment
|
||||
- Used DGX Sparks as LLM backend with Ollama for embeddings only
|
||||
patterns_established:
|
||||
- Python urllib healthcheck pattern for Docker images lacking curl
|
||||
- LightRAG service integration pattern: depends_on Qdrant + Ollama healthy, env_file for config, bind mount for persistent data
|
||||
observability_surfaces:
|
||||
- GET /health on port 9621 returns full config and status JSON
|
||||
- Docker healthcheck via Python urllib reports healthy/unhealthy
|
||||
- Container logs show entity extraction details and LLM/embedding request outcomes
|
||||
drill_down_paths:
|
||||
- .gsd/milestones/M019/slices/S01/tasks/T01-SUMMARY.md
|
||||
- .gsd/milestones/M019/slices/S01/tasks/T02-SUMMARY.md
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-03T21:36:50.606Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# S01: [B] LightRAG Deployment + Docker Integration
|
||||
|
||||
**LightRAG service deployed in Docker Compose on ub01 with Qdrant vector storage, Ollama embeddings, DGX Sparks LLM, and verified entity extraction producing music production entities.**
|
||||
|
||||
## What Happened
|
||||
|
||||
Two tasks: T01 added the chrysopedia-lightrag service definition to docker-compose.yml and created .env.lightrag with all configuration. T02 deployed on ub01, discovered the LightRAG Docker image lacks curl (healthcheck switched to Python urllib with 127.0.0.1), set the real API key, and verified end-to-end: entity extraction from music production text (10 entities, 9 relations including Creator, Plugin, Technique, Effect types) and query returning accurate results mentioning Serum, OTT, and Valhalla Room.
|
||||
|
||||
The service uses ghcr.io/hkuds/lightrag:latest on port 9621, depends on healthy Qdrant and Ollama services, stores data at /vmPool/r/services/chrysopedia_lightrag, and uses NetworkX for graph storage with QdrantVectorDBStorage for vectors. LLM requests go through DGX Sparks (fyn-llm-agent-chat model via OpenAI-compatible API), embeddings through the existing Ollama nomic-embed-text model.
|
||||
|
||||
## Verification
|
||||
|
||||
All slice-level checks pass:
|
||||
- `ssh ub01 'curl -sf http://localhost:9621/health'` → 200 with full config JSON
|
||||
- `ssh ub01 'docker ps --filter name=chrysopedia-lightrag'` → "Up N minutes (healthy)"
|
||||
- `docker compose config | grep chrysopedia-lightrag` → PASS
|
||||
- `docker compose config` exits 0
|
||||
- `git check-ignore .env.lightrag` → IGNORED (via .env.* wildcard)
|
||||
- All 10 existing Chrysopedia services remain healthy
|
||||
- Entity extraction POST returns success with 10 entities + 9 relations
|
||||
- Query POST returns response with correct music production entities (Serum, OTT, Valhalla Room)
|
||||
|
||||
## Requirements Advanced
|
||||
|
||||
- R010 — LightRAG service added to Docker Compose stack, all existing services remain healthy
|
||||
|
||||
## Requirements Validated
|
||||
|
||||
None.
|
||||
|
||||
## New Requirements Surfaced
|
||||
|
||||
None.
|
||||
|
||||
## Requirements Invalidated or Re-scoped
|
||||
|
||||
None.
|
||||
|
||||
## Deviations
|
||||
|
||||
Healthcheck changed from curl to Python urllib because LightRAG image lacks curl. Additional .env.lightrag settings added beyond plan (SUMMARY_LANGUAGE, MAX_ASYNC, RERANK_BINDING, OLLAMA_EMBEDDING_NUM_CTX) based on upstream env.example review. Files deployed via scp rather than git pull.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
Qdrant client v1.15.1 in LightRAG image warns about server v1.13.2 mismatch — functions correctly but should be monitored. ub01 repo has uncommitted changes (files deployed via scp). NetworkX graph storage is file-based — adequate for initial deployment but may need upgrade for large corpus.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
S04 (Reindex Existing Corpus Through LightRAG) depends on this slice. Consider upgrading Qdrant server to match client version. Consider switching to Neo4j graph storage if NetworkX becomes a bottleneck at scale.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `docker-compose.yml` — Added chrysopedia-lightrag service with healthcheck, depends_on, volumes, port mapping
|
||||
- `.env.lightrag` — Created with LLM, embedding, vector storage, graph storage, and entity type configuration
|
||||
- `.gsd/KNOWLEDGE.md` — Added LightRAG healthcheck knowledge entry
|
||||
66
.gsd/milestones/M019/slices/S01/S01-UAT.md
Normal file
66
.gsd/milestones/M019/slices/S01/S01-UAT.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# S01: [B] LightRAG Deployment + Docker Integration — UAT
|
||||
|
||||
**Milestone:** M019
|
||||
**Written:** 2026-04-03T21:36:50.606Z
|
||||
|
||||
# S01 UAT: LightRAG Deployment + Docker Integration
|
||||
|
||||
## Preconditions
|
||||
- SSH access to ub01
|
||||
- Docker Compose stack running on ub01
|
||||
|
||||
## Test Cases
|
||||
|
||||
### TC1: LightRAG Health Endpoint
|
||||
1. Run `ssh ub01 'curl -sf http://localhost:9621/health'`
|
||||
2. **Expected:** JSON response with `"status":"healthy"`, `"llm_binding":"openai"`, `"embedding_binding":"ollama"`, `"vector_storage":"QdrantVectorDBStorage"`
|
||||
|
||||
### TC2: Container Health Status
|
||||
1. Run `ssh ub01 'docker ps --filter name=chrysopedia-lightrag --format "{{.Status}}"'`
|
||||
2. **Expected:** Output contains "(healthy)"
|
||||
|
||||
### TC3: Docker Compose Config Valid
|
||||
1. Run `docker compose config` from repo root
|
||||
2. **Expected:** Exits 0, output contains `chrysopedia-lightrag` service definition
|
||||
|
||||
### TC4: Env File Git Ignored
|
||||
1. Run `git check-ignore .env.lightrag`
|
||||
2. **Expected:** Exits 0, prints `.env.lightrag`
|
||||
|
||||
### TC5: Entity Extraction
|
||||
1. Run:
|
||||
```
|
||||
ssh ub01 'curl -sf -X POST http://localhost:9621/documents/text \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"text\": \"COPYCATT uses Serum for bass sound design with sidechain compression. The signal chain runs through OTT then Valhalla Room reverb.\", \"file_source\": \"uat_test.txt\"}"'
|
||||
```
|
||||
2. **Expected:** HTTP 200 response with status indicating success
|
||||
|
||||
### TC6: Query Returns Entities
|
||||
1. Run:
|
||||
```
|
||||
ssh ub01 'curl -sf -X POST http://localhost:9621/query \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"query\": \"What plugins does COPYCATT use?\", \"mode\": \"mix\"}"'
|
||||
```
|
||||
2. **Expected:** Response contains references to Serum, OTT, and/or Valhalla Room
|
||||
|
||||
### TC7: Existing Services Unaffected
|
||||
1. Run `ssh ub01 'docker ps --filter name=chrysopedia --format "{{.Names}} {{.Status}}"'`
|
||||
2. **Expected:** All 10 existing services (db, redis, qdrant, ollama, api, worker, watcher, web-8096, mcp, lightrag) show "healthy" or "Up"
|
||||
|
||||
### TC8: Service Dependencies
|
||||
1. Run `docker compose config` and inspect chrysopedia-lightrag depends_on
|
||||
2. **Expected:** Depends on chrysopedia-qdrant (service_healthy) and chrysopedia-ollama (service_healthy)
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### EC1: Container Restart Recovery
|
||||
1. Run `ssh ub01 'docker restart chrysopedia-lightrag'`
|
||||
2. Wait 45 seconds for start_period
|
||||
3. Run `ssh ub01 'docker inspect --format="{{.State.Health.Status}}" chrysopedia-lightrag'`
|
||||
4. **Expected:** "healthy" within 60 seconds
|
||||
|
||||
### EC2: Healthcheck Without Curl
|
||||
1. Run `ssh ub01 'docker exec chrysopedia-lightrag which curl'`
|
||||
2. **Expected:** Exits non-zero (curl not found — confirms Python urllib healthcheck is necessary)
|
||||
24
.gsd/milestones/M019/slices/S01/tasks/T02-VERIFY.json
Normal file
24
.gsd/milestones/M019/slices/S01/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T02",
|
||||
"unitId": "M019/S01/T02",
|
||||
"timestamp": 1775252110245,
|
||||
"passed": false,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "ssh ub01 'curl -sf http://localhost:9621/health",
|
||||
"exitCode": 2,
|
||||
"durationMs": 7,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "echo CONTAINER_OK'",
|
||||
"exitCode": 2,
|
||||
"durationMs": 6,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
|
|
@ -1,6 +1,196 @@
|
|||
# S02: [A] Creator Authentication + Dashboard Shell
|
||||
|
||||
**Goal:** Implement invite-only creator auth with JWT sessions, role system (creator/admin/user), and empty dashboard shell
|
||||
**Goal:** Creator registers with invite code, logs in with JWT, sees dashboard shell with nav and profile settings. Existing public routes remain unaffected.
|
||||
**Demo:** After this: Creator registers with invite code, logs in, sees dashboard shell with nav and profile settings
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas** — Create the User and InviteCode SQLAlchemy models in backend/models.py, generate Alembic migration 016, add PyJWT + passlib[bcrypt] to requirements.txt, and implement the auth utility module (password hashing, JWT encode/decode, FastAPI get_current_user dependency).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `PyJWT>=2.8,<3.0` and `passlib[bcrypt]>=1.7,<2.0` to `backend/requirements.txt` and run `pip install -r backend/requirements.txt`.
|
||||
2. In `backend/models.py`, add a `UserRole` enum (creator, admin) and the `User` model: id (UUID PK), email (unique, not null), hashed_password, display_name, role (UserRole, default creator), creator_id (FK to creators.id, nullable), is_active (default true), created_at, updated_at. Add the `InviteCode` model: id (UUID PK), code (unique, not null), uses_remaining (default 1), created_by (FK to users.id, nullable), expires_at (nullable), created_at.
|
||||
3. In `backend/schemas.py`, add Pydantic schemas: `RegisterRequest` (email, password, display_name, invite_code, optional creator_slug), `LoginRequest` (email, password), `TokenResponse` (access_token, token_type), `UserResponse` (id, email, display_name, role, creator_id, is_active, created_at), `UpdateProfileRequest` (optional display_name, optional current_password, optional new_password).
|
||||
4. Create `backend/auth.py` with: `hash_password(plain) -> str` using passlib bcrypt, `verify_password(plain, hashed) -> bool`, `create_access_token(user_id, role) -> str` using PyJWT HS256 with `settings.app_secret_key` and configurable expiry, `decode_access_token(token) -> dict` with expiry validation, `get_current_user` FastAPI dependency using `OAuth2PasswordBearer(tokenUrl='/api/v1/auth/login')` that decodes token and loads User from DB, `require_role(role)` sub-dependency.
|
||||
5. Generate Alembic migration: `cd backend && alembic revision --autogenerate -m 'add users and invite_codes'`. Review the generated migration to ensure it creates both tables with correct constraints.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] User model with email, hashed_password, display_name, role enum, creator_id FK, is_active
|
||||
- [ ] InviteCode model with code, uses_remaining, created_by FK, expires_at
|
||||
- [ ] Alembic migration 016 creates both tables
|
||||
- [ ] auth.py exports hash_password, verify_password, create_access_token, decode_access_token, get_current_user, require_role
|
||||
- [ ] Pydantic request/response schemas for register, login, token, user profile, update profile
|
||||
- [ ] PyJWT and passlib[bcrypt] in requirements.txt
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| PostgreSQL (migration) | Alembic prints traceback — fix SQL/model and retry | N/A | N/A |
|
||||
| passlib bcrypt | ImportError at import time — verify pip install | N/A | N/A |
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- **Malformed inputs**: decode_access_token with expired token raises, with garbage string raises, with missing claims raises
|
||||
- **Boundary conditions**: InviteCode with uses_remaining=0 should not be usable
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd backend && python -c "from models import User, InviteCode, UserRole; print('Models OK')"` exits 0
|
||||
- `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('Auth OK')"` exits 0
|
||||
- `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('Schemas OK')"` exits 0
|
||||
- Alembic migration file exists in `alembic/versions/` with 'users' and 'invite_codes' in the upgrade function
|
||||
- Estimate: 1h
|
||||
- Files: backend/models.py, backend/schemas.py, backend/auth.py, backend/config.py, backend/requirements.txt, alembic/versions/016_add_users_and_invite_codes.py
|
||||
- Verify: cd backend && python -c "from models import User, InviteCode, UserRole; print('OK')" && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('OK')" && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse; print('OK')"
|
||||
- [ ] **T02: Implement auth API router with registration, login, profile endpoints and integration tests** — Create the auth API router with all endpoints (register, login, me, update profile), register it in main.py, seed initial invite codes, and write comprehensive integration tests.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `backend/routers/auth.py` with APIRouter prefix `/api/v1/auth`:
|
||||
- `POST /register`: Validate invite code (exists, uses_remaining > 0, not expired), check email uniqueness, hash password, create User, decrement invite code uses_remaining, optionally link creator_id by slug. Return 201 + UserResponse. Errors: 403 invalid/expired code, 409 duplicate email.
|
||||
- `POST /login`: Find user by email, verify password, return TokenResponse with JWT. Errors: 401 invalid credentials.
|
||||
- `GET /me`: Requires auth (get_current_user dependency). Return UserResponse.
|
||||
- `PUT /me`: Requires auth. Accept UpdateProfileRequest — update display_name if provided, verify current_password and set new_password if provided. Return updated UserResponse.
|
||||
2. In `backend/main.py`, import and include the auth router.
|
||||
3. Create a seed invite code mechanism: add a function `seed_invite_codes()` in `backend/auth.py` that creates a default invite code if none exist (code='CHRYSOPEDIA-ALPHA-2026', uses_remaining=100). Call it from a startup event or CLI command. For the test suite, create codes in fixtures.
|
||||
4. Update `backend/tests/conftest.py`: import User, InviteCode, UserRole into the model imports so they're included in `Base.metadata.create_all`. Add an `invite_code` fixture that creates a test invite code. Add a `registered_user` fixture that creates a user via the register endpoint. Add an `auth_headers` fixture that logs in and returns `{"Authorization": "Bearer <token>"}`.
|
||||
5. Create `backend/tests/test_auth.py` with integration tests:
|
||||
- Register with valid invite code → 201 + user created in DB
|
||||
- Register with invalid invite code → 403
|
||||
- Register with expired invite code → 403
|
||||
- Register duplicate email → 409
|
||||
- Login with correct credentials → 200 + JWT token
|
||||
- Login with wrong password → 401
|
||||
- Login with nonexistent email → 401
|
||||
- GET /me with valid token → 200 + user profile
|
||||
- GET /me with expired/invalid token → 401
|
||||
- GET /me without token → 401
|
||||
- PUT /me updates display_name → 200 + updated profile
|
||||
- PUT /me changes password → 200, can login with new password
|
||||
- Existing public endpoints still work without auth (GET /api/v1/techniques, GET /api/v1/creators)
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] POST /register with invite code validation, email uniqueness, password hashing
|
||||
- [ ] POST /login returning JWT
|
||||
- [ ] GET /me and PUT /me with auth dependency
|
||||
- [ ] Auth router registered in main.py
|
||||
- [ ] All 13 integration tests pass
|
||||
- [ ] Existing public endpoints verified unaffected
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| PostgreSQL (test DB) | Test fails with connection error — check TEST_DATABASE_URL | N/A | N/A |
|
||||
| bcrypt hashing | Slow on first call (key derivation) — acceptable for test suite | N/A | N/A |
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- **Malformed inputs**: Register with missing fields → 422, Register with empty password → 422, Login with empty body → 422
|
||||
- **Error paths**: Invalid invite code → 403, expired invite code → 403, duplicate email → 409, wrong password → 401, invalid JWT → 401, expired JWT → 401
|
||||
- **Boundary conditions**: Invite code with uses_remaining=1 decrements to 0 and becomes unusable, invite code with uses_remaining=0 rejected
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd backend && python -m pytest tests/test_auth.py -v` — all tests pass
|
||||
- `cd backend && python -m pytest tests/test_public_api.py -v` — existing tests still pass (no auth regression)
|
||||
- Estimate: 1.5h
|
||||
- Files: backend/routers/auth.py, backend/main.py, backend/auth.py, backend/tests/conftest.py, backend/tests/test_auth.py
|
||||
- Verify: cd backend && python -m pytest tests/test_auth.py -v && python -m pytest tests/test_public_api.py -v
|
||||
- [ ] **T03: Build frontend AuthContext, API auth functions, login and register pages** — Create the React auth infrastructure: AuthContext provider with JWT persistence, API client auth functions, login page, register page, and wire AuthContext into App.tsx.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `frontend/src/context/` directory.
|
||||
2. Create `frontend/src/context/AuthContext.tsx`: React context providing `user` (UserResponse | null), `token` (string | null), `isAuthenticated` (boolean), `login(email, password)` (calls API, stores token in localStorage, fetches /me to populate user), `register(data)` (calls API, returns success/error), `logout()` (clears token + user from state and localStorage). On mount, check localStorage for existing token and call /me to rehydrate session. Export `AuthProvider` component and `useAuth()` hook.
|
||||
3. Add auth API functions to `frontend/src/api/public-client.ts`:
|
||||
- `authRegister(data: RegisterRequest): Promise<UserResponse>` — POST /api/v1/auth/register
|
||||
- `authLogin(email: string, password: string): Promise<TokenResponse>` — POST /api/v1/auth/login
|
||||
- `authGetMe(token: string): Promise<UserResponse>` — GET /api/v1/auth/me with Authorization header
|
||||
- `authUpdateProfile(token: string, data: UpdateProfileRequest): Promise<UserResponse>` — PUT /api/v1/auth/me
|
||||
- Add TypeScript interfaces: `RegisterRequest`, `LoginRequest`, `TokenResponse`, `UserResponse`, `UpdateProfileRequest`
|
||||
- Modify `request<T>` to automatically inject Authorization header from localStorage when token exists
|
||||
4. Create `frontend/src/pages/Login.tsx` (CSS module: `Login.module.css`):
|
||||
- Email + password form with validation (required fields, email format)
|
||||
- Error display for invalid credentials
|
||||
- Submit calls `useAuth().login()`, redirects to /creator/dashboard on success
|
||||
- Link to register page
|
||||
- Uses `useDocumentTitle('Login')` for consistent page titles
|
||||
5. Create `frontend/src/pages/Register.tsx` (CSS module: `Register.module.css`):
|
||||
- Invite code + email + password + confirm password + display name form
|
||||
- Client-side validation: required fields, email format, password match, min password length (8 chars)
|
||||
- Error display for API errors (invalid code, email taken)
|
||||
- Submit calls `useAuth().register()`, redirects to /login with success message on completion
|
||||
- Uses `useDocumentTitle('Register')`
|
||||
6. Wrap `<App />` content with `<AuthProvider>` in App.tsx. Add Route entries for `/login` and `/register`.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] AuthContext with login, register, logout, session rehydration from localStorage
|
||||
- [ ] API auth functions with TypeScript types in public-client.ts
|
||||
- [ ] request<T> injects Authorization header when token exists
|
||||
- [ ] Login page with email/password form, error handling, redirect on success
|
||||
- [ ] Register page with invite code + full form, validation, error handling
|
||||
- [ ] CSS modules for both pages (not App.css)
|
||||
- [ ] Routes /login and /register added to App.tsx
|
||||
- [ ] `npm run build` passes with zero errors
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd frontend && npm run build` — zero TypeScript errors
|
||||
- `grep -r 'AuthProvider' frontend/src/App.tsx` — context is wired
|
||||
- `test -f frontend/src/context/AuthContext.tsx` — context file exists
|
||||
- `test -f frontend/src/pages/Login.tsx && test -f frontend/src/pages/Register.tsx` — both pages exist
|
||||
- `test -f frontend/src/pages/Login.module.css && test -f frontend/src/pages/Register.module.css` — CSS modules exist
|
||||
- Estimate: 1.5h
|
||||
- Files: frontend/src/context/AuthContext.tsx, frontend/src/api/public-client.ts, frontend/src/pages/Login.tsx, frontend/src/pages/Login.module.css, frontend/src/pages/Register.tsx, frontend/src/pages/Register.module.css, frontend/src/App.tsx
|
||||
- Verify: cd frontend && npm run build && test -f src/context/AuthContext.tsx && test -f src/pages/Login.tsx && test -f src/pages/Register.tsx
|
||||
- [ ] **T04: Build dashboard shell, profile settings, ProtectedRoute, and nav auth state** — Create the creator dashboard shell page with sidebar navigation, profile settings page, ProtectedRoute wrapper component, and update the main nav to show authenticated user state.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `frontend/src/components/ProtectedRoute.tsx`: Wrapper component that checks `useAuth().isAuthenticated`. If not authenticated, redirects to `/login` with a `returnTo` search param preserving the intended destination. If authenticated, renders children.
|
||||
2. Create `frontend/src/pages/CreatorDashboard.tsx` (CSS module: `CreatorDashboard.module.css`):
|
||||
- Two-column layout: left sidebar (240px) + main content area
|
||||
- Sidebar nav items: Dashboard (active), Content (placeholder, disabled), Settings (links to /creator/settings). Use NavLink for active state.
|
||||
- Main content: Welcome message with user's display_name from `useAuth().user`, placeholder cards for future analytics ("Content stats coming in M020")
|
||||
- Uses `useDocumentTitle('Creator Dashboard')`
|
||||
- Mobile: sidebar collapses to top horizontal nav or hamburger
|
||||
3. Create `frontend/src/pages/CreatorSettings.tsx` (CSS module: `CreatorSettings.module.css`):
|
||||
- Same dashboard layout (sidebar + content) for navigation consistency
|
||||
- Profile section: display_name input (pre-filled), email display (read-only)
|
||||
- Password section: current password + new password + confirm new password
|
||||
- Submit calls `authUpdateProfile()` via AuthContext, shows success/error feedback
|
||||
- Uses `useDocumentTitle('Settings')`
|
||||
4. In `frontend/src/App.tsx`, add protected routes:
|
||||
- `<Route path="/creator/dashboard" element={<ProtectedRoute><CreatorDashboard /></ProtectedRoute>} />`
|
||||
- `<Route path="/creator/settings" element={<ProtectedRoute><CreatorSettings /></ProtectedRoute>} />`
|
||||
5. Update the header/nav in `frontend/src/App.tsx`:
|
||||
- When `useAuth().isAuthenticated`: show user display_name, link to /creator/dashboard, and Logout button
|
||||
- When not authenticated: show Login link (subtle, in nav area)
|
||||
- The existing AdminDropdown remains unchanged — it's a separate concern
|
||||
6. Ensure all new CSS uses CSS modules. Follow the existing dark theme (use CSS custom properties from App.css where applicable). Dashboard sidebar should use `var(--color-surface-*) / var(--color-text-*)` tokens.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] ProtectedRoute redirects unauthenticated users to /login with returnTo param
|
||||
- [ ] Dashboard shell with sidebar nav (Dashboard, Content placeholder, Settings)
|
||||
- [ ] Profile settings page with display_name edit and password change
|
||||
- [ ] Protected routes wired in App.tsx
|
||||
- [ ] Nav shows auth state (logged in: name + dashboard + logout; logged out: login link)
|
||||
- [ ] All CSS in CSS modules, using existing custom properties for dark theme consistency
|
||||
- [ ] `npm run build` passes with zero errors
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd frontend && npm run build` — zero TypeScript errors
|
||||
- `test -f frontend/src/components/ProtectedRoute.tsx` — component exists
|
||||
- `test -f frontend/src/pages/CreatorDashboard.tsx && test -f frontend/src/pages/CreatorSettings.tsx` — both pages exist
|
||||
- `grep -q 'ProtectedRoute' frontend/src/App.tsx` — protected routes wired
|
||||
- `grep -q 'useAuth' frontend/src/App.tsx` — nav auth state wired
|
||||
- Estimate: 1.5h
|
||||
- Files: frontend/src/components/ProtectedRoute.tsx, frontend/src/pages/CreatorDashboard.tsx, frontend/src/pages/CreatorDashboard.module.css, frontend/src/pages/CreatorSettings.tsx, frontend/src/pages/CreatorSettings.module.css, frontend/src/App.tsx
|
||||
- Verify: cd frontend && npm run build && test -f src/components/ProtectedRoute.tsx && test -f src/pages/CreatorDashboard.tsx && test -f src/pages/CreatorSettings.tsx && grep -q 'ProtectedRoute' src/App.tsx
|
||||
|
|
|
|||
140
.gsd/milestones/M019/slices/S02/S02-RESEARCH.md
Normal file
140
.gsd/milestones/M019/slices/S02/S02-RESEARCH.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# S02 Research: [A] Creator Authentication + Dashboard Shell
|
||||
|
||||
## Summary
|
||||
|
||||
This is a greenfield auth system for an existing FastAPI + React codebase that has zero authentication today. The slice adds: a User model (separate from Creator), invite-code-gated registration, JWT-based login sessions, role-based access (creator/admin), and a creator dashboard shell page. The existing Creator model has no auth fields — users will be a new entity linked to creators via foreign key.
|
||||
|
||||
**Depth: Targeted.** Auth with PyJWT + bcrypt on FastAPI is well-understood, but this codebase has never had auth, so the integration surface is broad: new models, new migration, new middleware, new API router, new frontend pages, auth context, and protected routes.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Stack:** PyJWT (trust 9.9, benchmark 92.5) for JWT encode/decode, passlib[bcrypt] for password hashing. Both are the standard FastAPI auth libraries — no need for heavier frameworks (FastAPI-Users, etc.) given the simple role model.
|
||||
|
||||
**Architecture:**
|
||||
- New `User` model in `backend/models.py` with email, hashed_password, role enum (creator/admin), and optional `creator_id` FK.
|
||||
- New `InviteCode` model for invite-gated registration (code, uses_remaining, created_by, expires_at).
|
||||
- `backend/routers/auth.py` with POST /register, POST /login, GET /me, PUT /me (profile settings).
|
||||
- `backend/auth.py` utility module: password hashing, JWT creation/verification, FastAPI dependency `get_current_user`.
|
||||
- Frontend: auth context provider, login/register pages, protected route wrapper, dashboard shell page with sidebar nav.
|
||||
- Existing routes remain public. Only new `/creator/*` and `/admin/*` API routes require auth.
|
||||
|
||||
## Implementation Landscape
|
||||
|
||||
### Existing Code (Key Files)
|
||||
|
||||
| File | Lines | Relevance |
|
||||
|------|-------|-----------|
|
||||
| `backend/models.py` | ~520 | All 13 models in one file. Add User + InviteCode here. Site audit warns about splitting but that's S05's job. |
|
||||
| `backend/schemas.py` | ~500 | Pydantic schemas. Add auth request/response schemas here. |
|
||||
| `backend/database.py` | ~30 | Async engine + session factory. No changes needed. |
|
||||
| `backend/config.py` | ~90 | Settings via pydantic-settings. Add JWT_SECRET_KEY, JWT_EXPIRY_MINUTES, INVITE_CODES config. `app_secret_key` already exists but is unused — repurpose for JWT signing. |
|
||||
| `backend/main.py` | ~100 | FastAPI app setup. Register new auth router here. |
|
||||
| `backend/routers/` | 10 files | Existing pattern: APIRouter with prefix, Depends(get_session). Auth router follows same pattern. |
|
||||
| `frontend/src/App.tsx` | ~140 | React Router setup. Add /login, /register, /creator/dashboard routes. |
|
||||
| `frontend/src/api/public-client.ts` | ~860 | All API calls. `request<T>` function is the injection point for Authorization headers. |
|
||||
| `frontend/src/App.css` | 5820 | Monolithic CSS. Site audit recommends CSS modules for new Phase 2 components — use `.p2-` namespace prefix at minimum. |
|
||||
| `alembic/versions/` | 15 migrations | Migration 016 will add users + invite_codes tables. |
|
||||
|
||||
### Data Model Design
|
||||
|
||||
```
|
||||
User
|
||||
id: UUID (PK)
|
||||
email: String(255), unique, not null
|
||||
hashed_password: String(255), not null
|
||||
display_name: String(255), not null
|
||||
role: Enum('creator', 'admin'), not null, default 'creator'
|
||||
creator_id: UUID FK -> creators.id, nullable (linked after registration)
|
||||
is_active: Boolean, default true
|
||||
created_at: DateTime
|
||||
updated_at: DateTime
|
||||
|
||||
InviteCode
|
||||
id: UUID (PK)
|
||||
code: String(50), unique, not null
|
||||
uses_remaining: Integer, default 1
|
||||
created_by: UUID FK -> users.id, nullable (null for seed codes)
|
||||
expires_at: DateTime, nullable
|
||||
created_at: DateTime
|
||||
```
|
||||
|
||||
**Key design decisions for planner:**
|
||||
- User is separate from Creator. A Creator is content data (extracted from videos); a User is an account. The `creator_id` FK links them after registration. This preserves the existing Creator model untouched.
|
||||
- Role enum is minimal: `creator` and `admin`. No `user` role yet — public visitors don't need accounts in Phase 2.
|
||||
- `is_active` enables account deactivation without deletion.
|
||||
- InviteCode tracks remaining uses, not a boolean — supports multi-use codes for batch invites.
|
||||
|
||||
### Auth Flow
|
||||
|
||||
1. **Registration:** POST /api/v1/auth/register — requires valid invite code + email + password + display_name. Creates User, decrements invite code uses. If role is creator, optionally links to existing Creator row by slug or creates new one.
|
||||
2. **Login:** POST /api/v1/auth/login — email + password → JWT access token (HS256, signed with `app_secret_key`). Token payload: `{sub: user_id, role: role, exp: now + JWT_EXPIRY_MINUTES}`.
|
||||
3. **Session:** Frontend stores JWT in localStorage. `request<T>` injects `Authorization: Bearer <token>` for authenticated endpoints. No refresh tokens for MVP — just re-login on expiry.
|
||||
4. **Auth dependency:** `get_current_user(token: str = Depends(oauth2_scheme))` decodes JWT, loads User from DB, returns it. `require_role("admin")` sub-dependency for admin-only routes.
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
- **AuthContext** (`frontend/src/context/AuthContext.tsx`): React context with `user`, `token`, `login()`, `register()`, `logout()`, `isAuthenticated`. Wraps the app in App.tsx.
|
||||
- **Login page** (`/login`): Email + password form. Redirects to dashboard on success.
|
||||
- **Register page** (`/register`): Invite code + email + password + display name form. Redirects to login on success.
|
||||
- **Dashboard shell** (`/creator/dashboard`): Left sidebar nav (Dashboard, Content, Settings), main content area. Initially empty "Welcome" content — M020/S02 fills in real analytics.
|
||||
- **Profile settings** (`/creator/settings`): Display name, email, password change form.
|
||||
- **ProtectedRoute** component: Wraps creator/admin routes, redirects to /login if not authenticated.
|
||||
- **Nav update**: Show user avatar/name in header when logged in, with dropdown for Dashboard/Logout.
|
||||
|
||||
### CSS Strategy
|
||||
|
||||
Per site audit recommendation, new Phase 2 components should use CSS modules or at minimum a `p2-` namespace prefix. The dashboard shell is a good boundary for this. Options:
|
||||
1. **CSS Modules** (`.module.css` files) — requires no Vite config change (Vite supports them natively). Cleanest isolation.
|
||||
2. **Namespaced classes** in App.css — consistent with existing pattern but adds to the 5820-line monolith.
|
||||
|
||||
Recommend CSS Modules for all new dashboard/auth pages. Existing public pages stay in App.css.
|
||||
|
||||
### Migration Plan
|
||||
|
||||
Single Alembic migration `016_add_users_and_invite_codes.py`:
|
||||
- Create `users` table
|
||||
- Create `invite_codes` table
|
||||
- No FK from `creators` → `users` (the link is `users.creator_id → creators.id`, one-directional)
|
||||
|
||||
### Dependencies to Add
|
||||
|
||||
```
|
||||
# backend/requirements.txt additions:
|
||||
PyJWT>=2.8,<3.0
|
||||
passlib[bcrypt]>=1.7,<2.0
|
||||
```
|
||||
|
||||
### Verification Strategy
|
||||
|
||||
1. **Backend:** Integration tests in `backend/tests/test_auth.py`:
|
||||
- Register with valid invite code → 201 + user created
|
||||
- Register with invalid/expired invite code → 403
|
||||
- Register duplicate email → 409
|
||||
- Login with correct credentials → 200 + JWT
|
||||
- Login with wrong password → 401
|
||||
- GET /me with valid token → user profile
|
||||
- GET /me with expired/invalid token → 401
|
||||
- Protected endpoint without token → 401
|
||||
2. **Frontend:** `npm run build` passes with zero TypeScript errors.
|
||||
3. **End-to-end:** Register → login → see dashboard shell → navigate to settings. Verify via browser tools or curl.
|
||||
|
||||
### Natural Task Seams
|
||||
|
||||
1. **Backend models + migration** — User model, InviteCode model, role enum, Alembic migration. Independent, unblocks everything else.
|
||||
2. **Auth utilities + router** — password hashing, JWT functions, auth dependencies, POST /register, POST /login, GET /me, PUT /me. Depends on (1).
|
||||
3. **Seed invite codes** — CLI command or migration data to create initial invite codes for testing.
|
||||
4. **Frontend auth context + login/register pages** — AuthContext, login form, register form, token storage. Depends on (2) for API contract.
|
||||
5. **Dashboard shell + protected routes** — Dashboard page, sidebar nav, profile settings page, ProtectedRoute component, nav auth state. Depends on (4).
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| JWT secret key rotation | Invalidates all sessions | Use `app_secret_key` from config.py (already exists). Document rotation procedure. |
|
||||
| Password hashing performance in async context | bcrypt is CPU-bound, blocks event loop | passlib's bcrypt is fast enough for low-volume registration. Use `run_in_executor` if needed — defer until measured. |
|
||||
| Creator-User linking | Which creator does a new user map to? | Registration accepts optional `creator_slug`. Admin can link/unlink later. |
|
||||
| localStorage XSS risk | Token theft via XSS | Acceptable for self-hosted single-admin tool. httpOnly cookies would be more secure but add CSRF complexity. Revisit if the tool becomes public-facing. |
|
||||
|
||||
### Skill Discovery
|
||||
|
||||
No installed skills are directly relevant to FastAPI auth implementation. Searched `npx skills find "fastapi authentication"` — top results had 27-84 installs with narrow scope (JWT bridge, router generation). Not worth installing — the auth pattern is standard and well-understood.
|
||||
65
.gsd/milestones/M019/slices/S02/tasks/T01-PLAN.md
Normal file
65
.gsd/milestones/M019/slices/S02/tasks/T01-PLAN.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
estimated_steps: 27
|
||||
estimated_files: 6
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T01: Add User + InviteCode models, migration, and auth utilities
|
||||
|
||||
Create the User and InviteCode SQLAlchemy models in backend/models.py, generate Alembic migration 016, add PyJWT + passlib[bcrypt] to requirements.txt, and implement the auth utility module (password hashing, JWT encode/decode, FastAPI get_current_user dependency).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `PyJWT>=2.8,<3.0` and `passlib[bcrypt]>=1.7,<2.0` to `backend/requirements.txt` and run `pip install -r backend/requirements.txt`.
|
||||
2. In `backend/models.py`, add a `UserRole` enum (creator, admin) and the `User` model: id (UUID PK), email (unique, not null), hashed_password, display_name, role (UserRole, default creator), creator_id (FK to creators.id, nullable), is_active (default true), created_at, updated_at. Add the `InviteCode` model: id (UUID PK), code (unique, not null), uses_remaining (default 1), created_by (FK to users.id, nullable), expires_at (nullable), created_at.
|
||||
3. In `backend/schemas.py`, add Pydantic schemas: `RegisterRequest` (email, password, display_name, invite_code, optional creator_slug), `LoginRequest` (email, password), `TokenResponse` (access_token, token_type), `UserResponse` (id, email, display_name, role, creator_id, is_active, created_at), `UpdateProfileRequest` (optional display_name, optional current_password, optional new_password).
|
||||
4. Create `backend/auth.py` with: `hash_password(plain) -> str` using passlib bcrypt, `verify_password(plain, hashed) -> bool`, `create_access_token(user_id, role) -> str` using PyJWT HS256 with `settings.app_secret_key` and configurable expiry, `decode_access_token(token) -> dict` with expiry validation, `get_current_user` FastAPI dependency using `OAuth2PasswordBearer(tokenUrl='/api/v1/auth/login')` that decodes token and loads User from DB, `require_role(role)` sub-dependency.
|
||||
5. Generate Alembic migration: `cd backend && alembic revision --autogenerate -m 'add users and invite_codes'`. Review the generated migration to ensure it creates both tables with correct constraints.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] User model with email, hashed_password, display_name, role enum, creator_id FK, is_active
|
||||
- [ ] InviteCode model with code, uses_remaining, created_by FK, expires_at
|
||||
- [ ] Alembic migration 016 creates both tables
|
||||
- [ ] auth.py exports hash_password, verify_password, create_access_token, decode_access_token, get_current_user, require_role
|
||||
- [ ] Pydantic request/response schemas for register, login, token, user profile, update profile
|
||||
- [ ] PyJWT and passlib[bcrypt] in requirements.txt
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| PostgreSQL (migration) | Alembic prints traceback — fix SQL/model and retry | N/A | N/A |
|
||||
| passlib bcrypt | ImportError at import time — verify pip install | N/A | N/A |
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- **Malformed inputs**: decode_access_token with expired token raises, with garbage string raises, with missing claims raises
|
||||
- **Boundary conditions**: InviteCode with uses_remaining=0 should not be usable
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd backend && python -c "from models import User, InviteCode, UserRole; print('Models OK')"` exits 0
|
||||
- `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('Auth OK')"` exits 0
|
||||
- `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('Schemas OK')"` exits 0
|
||||
- Alembic migration file exists in `alembic/versions/` with 'users' and 'invite_codes' in the upgrade function
|
||||
|
||||
## Inputs
|
||||
|
||||
- `backend/models.py`
|
||||
- `backend/schemas.py`
|
||||
- `backend/config.py`
|
||||
- `backend/requirements.txt`
|
||||
- `backend/database.py`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- `backend/models.py`
|
||||
- `backend/schemas.py`
|
||||
- `backend/auth.py`
|
||||
- `backend/requirements.txt`
|
||||
- `alembic/versions/016_add_users_and_invite_codes.py`
|
||||
|
||||
## Verification
|
||||
|
||||
cd backend && python -c "from models import User, InviteCode, UserRole; print('OK')" && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('OK')" && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse; print('OK')"
|
||||
88
.gsd/milestones/M019/slices/S02/tasks/T01-SUMMARY.md
Normal file
88
.gsd/milestones/M019/slices/S02/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
id: T01
|
||||
parent: S02
|
||||
milestone: M019
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["backend/models.py", "backend/auth.py", "backend/schemas.py", "backend/requirements.txt", "alembic/versions/016_add_users_and_invite_codes.py"]
|
||||
key_decisions: ["24-hour JWT expiry with HS256 using existing app_secret_key", "OAuth2PasswordBearer tokenUrl /api/v1/auth/login for T02 router", "User.creator_id FK uses SET NULL on delete"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "All slice verification checks pass: model imports OK, auth utility imports OK, schema imports OK, migration file contains users and invite_codes. Additional integration test verified password roundtrip, JWT roundtrip, expired token rejection, and garbage token rejection."
|
||||
completed_at: 2026-04-03T21:46:54.281Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas
|
||||
|
||||
> Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T01
|
||||
parent: S02
|
||||
milestone: M019
|
||||
key_files:
|
||||
- backend/models.py
|
||||
- backend/auth.py
|
||||
- backend/schemas.py
|
||||
- backend/requirements.txt
|
||||
- alembic/versions/016_add_users_and_invite_codes.py
|
||||
key_decisions:
|
||||
- 24-hour JWT expiry with HS256 using existing app_secret_key
|
||||
- OAuth2PasswordBearer tokenUrl /api/v1/auth/login for T02 router
|
||||
- User.creator_id FK uses SET NULL on delete
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-03T21:46:54.282Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas
|
||||
|
||||
**Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas**
|
||||
|
||||
## What Happened
|
||||
|
||||
Added PyJWT and passlib[bcrypt] to requirements.txt. Created UserRole enum and two new models: User (email, hashed_password, display_name, role, creator_id FK, is_active) and InviteCode (code, uses_remaining, created_by FK, expires_at). Added five Pydantic schemas for auth flows. Created backend/auth.py with password hashing, JWT encode/decode, get_current_user dependency, and require_role factory. Wrote Alembic migration 016 creating both tables with constraints and user_role enum.
|
||||
|
||||
## Verification
|
||||
|
||||
All slice verification checks pass: model imports OK, auth utility imports OK, schema imports OK, migration file contains users and invite_codes. Additional integration test verified password roundtrip, JWT roundtrip, expired token rejection, and garbage token rejection.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd backend && python -c "from models import User, InviteCode, UserRole; print('Models OK')"` | 0 | ✅ pass | 500ms |
|
||||
| 2 | `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('Auth OK')"` | 0 | ✅ pass | 500ms |
|
||||
| 3 | `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('Schemas OK')"` | 0 | ✅ pass | 500ms |
|
||||
| 4 | `grep -l 'users|invite_codes' alembic/versions/016_add_users_and_invite_codes.py` | 0 | ✅ pass | 100ms |
|
||||
| 5 | `cd backend && python -c "(auth function integration tests)"` | 0 | ✅ pass | 800ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Alembic migration written manually (no live DB for autogenerate). Added Boolean import to models.py.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `backend/models.py`
|
||||
- `backend/auth.py`
|
||||
- `backend/schemas.py`
|
||||
- `backend/requirements.txt`
|
||||
- `alembic/versions/016_add_users_and_invite_codes.py`
|
||||
|
||||
|
||||
## Deviations
|
||||
Alembic migration written manually (no live DB for autogenerate). Added Boolean import to models.py.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
83
.gsd/milestones/M019/slices/S02/tasks/T02-PLAN.md
Normal file
83
.gsd/milestones/M019/slices/S02/tasks/T02-PLAN.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
estimated_steps: 43
|
||||
estimated_files: 5
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T02: Implement auth API router with registration, login, profile endpoints and integration tests
|
||||
|
||||
Create the auth API router with all endpoints (register, login, me, update profile), register it in main.py, seed initial invite codes, and write comprehensive integration tests.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `backend/routers/auth.py` with APIRouter prefix `/api/v1/auth`:
|
||||
- `POST /register`: Validate invite code (exists, uses_remaining > 0, not expired), check email uniqueness, hash password, create User, decrement invite code uses_remaining, optionally link creator_id by slug. Return 201 + UserResponse. Errors: 403 invalid/expired code, 409 duplicate email.
|
||||
- `POST /login`: Find user by email, verify password, return TokenResponse with JWT. Errors: 401 invalid credentials.
|
||||
- `GET /me`: Requires auth (get_current_user dependency). Return UserResponse.
|
||||
- `PUT /me`: Requires auth. Accept UpdateProfileRequest — update display_name if provided, verify current_password and set new_password if provided. Return updated UserResponse.
|
||||
2. In `backend/main.py`, import and include the auth router.
|
||||
3. Create a seed invite code mechanism: add a function `seed_invite_codes()` in `backend/auth.py` that creates a default invite code if none exist (code='CHRYSOPEDIA-ALPHA-2026', uses_remaining=100). Call it from a startup event or CLI command. For the test suite, create codes in fixtures.
|
||||
4. Update `backend/tests/conftest.py`: import User, InviteCode, UserRole into the model imports so they're included in `Base.metadata.create_all`. Add an `invite_code` fixture that creates a test invite code. Add a `registered_user` fixture that creates a user via the register endpoint. Add an `auth_headers` fixture that logs in and returns `{"Authorization": "Bearer <token>"}`.
|
||||
5. Create `backend/tests/test_auth.py` with integration tests:
|
||||
- Register with valid invite code → 201 + user created in DB
|
||||
- Register with invalid invite code → 403
|
||||
- Register with expired invite code → 403
|
||||
- Register duplicate email → 409
|
||||
- Login with correct credentials → 200 + JWT token
|
||||
- Login with wrong password → 401
|
||||
- Login with nonexistent email → 401
|
||||
- GET /me with valid token → 200 + user profile
|
||||
- GET /me with expired/invalid token → 401
|
||||
- GET /me without token → 401
|
||||
- PUT /me updates display_name → 200 + updated profile
|
||||
- PUT /me changes password → 200, can login with new password
|
||||
- Existing public endpoints still work without auth (GET /api/v1/techniques, GET /api/v1/creators)
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] POST /register with invite code validation, email uniqueness, password hashing
|
||||
- [ ] POST /login returning JWT
|
||||
- [ ] GET /me and PUT /me with auth dependency
|
||||
- [ ] Auth router registered in main.py
|
||||
- [ ] All 13 integration tests pass
|
||||
- [ ] Existing public endpoints verified unaffected
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| PostgreSQL (test DB) | Test fails with connection error — check TEST_DATABASE_URL | N/A | N/A |
|
||||
| bcrypt hashing | Slow on first call (key derivation) — acceptable for test suite | N/A | N/A |
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- **Malformed inputs**: Register with missing fields → 422, Register with empty password → 422, Login with empty body → 422
|
||||
- **Error paths**: Invalid invite code → 403, expired invite code → 403, duplicate email → 409, wrong password → 401, invalid JWT → 401, expired JWT → 401
|
||||
- **Boundary conditions**: Invite code with uses_remaining=1 decrements to 0 and becomes unusable, invite code with uses_remaining=0 rejected
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd backend && python -m pytest tests/test_auth.py -v` — all tests pass
|
||||
- `cd backend && python -m pytest tests/test_public_api.py -v` — existing tests still pass (no auth regression)
|
||||
|
||||
## Inputs
|
||||
|
||||
- `backend/models.py`
|
||||
- `backend/schemas.py`
|
||||
- `backend/auth.py`
|
||||
- `backend/config.py`
|
||||
- `backend/database.py`
|
||||
- `backend/main.py`
|
||||
- `backend/routers/__init__.py`
|
||||
- `backend/tests/conftest.py`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- `backend/routers/auth.py`
|
||||
- `backend/main.py`
|
||||
- `backend/tests/conftest.py`
|
||||
- `backend/tests/test_auth.py`
|
||||
|
||||
## Verification
|
||||
|
||||
cd backend && python -m pytest tests/test_auth.py -v && python -m pytest tests/test_public_api.py -v
|
||||
74
.gsd/milestones/M019/slices/S02/tasks/T03-PLAN.md
Normal file
74
.gsd/milestones/M019/slices/S02/tasks/T03-PLAN.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
estimated_steps: 39
|
||||
estimated_files: 7
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T03: Build frontend AuthContext, API auth functions, login and register pages
|
||||
|
||||
Create the React auth infrastructure: AuthContext provider with JWT persistence, API client auth functions, login page, register page, and wire AuthContext into App.tsx.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `frontend/src/context/` directory.
|
||||
2. Create `frontend/src/context/AuthContext.tsx`: React context providing `user` (UserResponse | null), `token` (string | null), `isAuthenticated` (boolean), `login(email, password)` (calls API, stores token in localStorage, fetches /me to populate user), `register(data)` (calls API, returns success/error), `logout()` (clears token + user from state and localStorage). On mount, check localStorage for existing token and call /me to rehydrate session. Export `AuthProvider` component and `useAuth()` hook.
|
||||
3. Add auth API functions to `frontend/src/api/public-client.ts`:
|
||||
- `authRegister(data: RegisterRequest): Promise<UserResponse>` — POST /api/v1/auth/register
|
||||
- `authLogin(email: string, password: string): Promise<TokenResponse>` — POST /api/v1/auth/login
|
||||
- `authGetMe(token: string): Promise<UserResponse>` — GET /api/v1/auth/me with Authorization header
|
||||
- `authUpdateProfile(token: string, data: UpdateProfileRequest): Promise<UserResponse>` — PUT /api/v1/auth/me
|
||||
- Add TypeScript interfaces: `RegisterRequest`, `LoginRequest`, `TokenResponse`, `UserResponse`, `UpdateProfileRequest`
|
||||
- Modify `request<T>` to automatically inject Authorization header from localStorage when token exists
|
||||
4. Create `frontend/src/pages/Login.tsx` (CSS module: `Login.module.css`):
|
||||
- Email + password form with validation (required fields, email format)
|
||||
- Error display for invalid credentials
|
||||
- Submit calls `useAuth().login()`, redirects to /creator/dashboard on success
|
||||
- Link to register page
|
||||
- Uses `useDocumentTitle('Login')` for consistent page titles
|
||||
5. Create `frontend/src/pages/Register.tsx` (CSS module: `Register.module.css`):
|
||||
- Invite code + email + password + confirm password + display name form
|
||||
- Client-side validation: required fields, email format, password match, min password length (8 chars)
|
||||
- Error display for API errors (invalid code, email taken)
|
||||
- Submit calls `useAuth().register()`, redirects to /login with success message on completion
|
||||
- Uses `useDocumentTitle('Register')`
|
||||
6. Wrap `<App />` content with `<AuthProvider>` in App.tsx. Add Route entries for `/login` and `/register`.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] AuthContext with login, register, logout, session rehydration from localStorage
|
||||
- [ ] API auth functions with TypeScript types in public-client.ts
|
||||
- [ ] request<T> injects Authorization header when token exists
|
||||
- [ ] Login page with email/password form, error handling, redirect on success
|
||||
- [ ] Register page with invite code + full form, validation, error handling
|
||||
- [ ] CSS modules for both pages (not App.css)
|
||||
- [ ] Routes /login and /register added to App.tsx
|
||||
- [ ] `npm run build` passes with zero errors
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd frontend && npm run build` — zero TypeScript errors
|
||||
- `grep -r 'AuthProvider' frontend/src/App.tsx` — context is wired
|
||||
- `test -f frontend/src/context/AuthContext.tsx` — context file exists
|
||||
- `test -f frontend/src/pages/Login.tsx && test -f frontend/src/pages/Register.tsx` — both pages exist
|
||||
- `test -f frontend/src/pages/Login.module.css && test -f frontend/src/pages/Register.module.css` — CSS modules exist
|
||||
|
||||
## Inputs
|
||||
|
||||
- `frontend/src/api/public-client.ts`
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/App.css`
|
||||
- `backend/schemas.py`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- `frontend/src/context/AuthContext.tsx`
|
||||
- `frontend/src/api/public-client.ts`
|
||||
- `frontend/src/pages/Login.tsx`
|
||||
- `frontend/src/pages/Login.module.css`
|
||||
- `frontend/src/pages/Register.tsx`
|
||||
- `frontend/src/pages/Register.module.css`
|
||||
- `frontend/src/App.tsx`
|
||||
|
||||
## Verification
|
||||
|
||||
cd frontend && npm run build && test -f src/context/AuthContext.tsx && test -f src/pages/Login.tsx && test -f src/pages/Register.tsx
|
||||
71
.gsd/milestones/M019/slices/S02/tasks/T04-PLAN.md
Normal file
71
.gsd/milestones/M019/slices/S02/tasks/T04-PLAN.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
estimated_steps: 37
|
||||
estimated_files: 6
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T04: Build dashboard shell, profile settings, ProtectedRoute, and nav auth state
|
||||
|
||||
Create the creator dashboard shell page with sidebar navigation, profile settings page, ProtectedRoute wrapper component, and update the main nav to show authenticated user state.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `frontend/src/components/ProtectedRoute.tsx`: Wrapper component that checks `useAuth().isAuthenticated`. If not authenticated, redirects to `/login` with a `returnTo` search param preserving the intended destination. If authenticated, renders children.
|
||||
2. Create `frontend/src/pages/CreatorDashboard.tsx` (CSS module: `CreatorDashboard.module.css`):
|
||||
- Two-column layout: left sidebar (240px) + main content area
|
||||
- Sidebar nav items: Dashboard (active), Content (placeholder, disabled), Settings (links to /creator/settings). Use NavLink for active state.
|
||||
- Main content: Welcome message with user's display_name from `useAuth().user`, placeholder cards for future analytics ("Content stats coming in M020")
|
||||
- Uses `useDocumentTitle('Creator Dashboard')`
|
||||
- Mobile: sidebar collapses to top horizontal nav or hamburger
|
||||
3. Create `frontend/src/pages/CreatorSettings.tsx` (CSS module: `CreatorSettings.module.css`):
|
||||
- Same dashboard layout (sidebar + content) for navigation consistency
|
||||
- Profile section: display_name input (pre-filled), email display (read-only)
|
||||
- Password section: current password + new password + confirm new password
|
||||
- Submit calls `authUpdateProfile()` via AuthContext, shows success/error feedback
|
||||
- Uses `useDocumentTitle('Settings')`
|
||||
4. In `frontend/src/App.tsx`, add protected routes:
|
||||
- `<Route path="/creator/dashboard" element={<ProtectedRoute><CreatorDashboard /></ProtectedRoute>} />`
|
||||
- `<Route path="/creator/settings" element={<ProtectedRoute><CreatorSettings /></ProtectedRoute>} />`
|
||||
5. Update the header/nav in `frontend/src/App.tsx`:
|
||||
- When `useAuth().isAuthenticated`: show user display_name, link to /creator/dashboard, and Logout button
|
||||
- When not authenticated: show Login link (subtle, in nav area)
|
||||
- The existing AdminDropdown remains unchanged — it's a separate concern
|
||||
6. Ensure all new CSS uses CSS modules. Follow the existing dark theme (use CSS custom properties from App.css where applicable). Dashboard sidebar should use `var(--color-surface-*) / var(--color-text-*)` tokens.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] ProtectedRoute redirects unauthenticated users to /login with returnTo param
|
||||
- [ ] Dashboard shell with sidebar nav (Dashboard, Content placeholder, Settings)
|
||||
- [ ] Profile settings page with display_name edit and password change
|
||||
- [ ] Protected routes wired in App.tsx
|
||||
- [ ] Nav shows auth state (logged in: name + dashboard + logout; logged out: login link)
|
||||
- [ ] All CSS in CSS modules, using existing custom properties for dark theme consistency
|
||||
- [ ] `npm run build` passes with zero errors
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd frontend && npm run build` — zero TypeScript errors
|
||||
- `test -f frontend/src/components/ProtectedRoute.tsx` — component exists
|
||||
- `test -f frontend/src/pages/CreatorDashboard.tsx && test -f frontend/src/pages/CreatorSettings.tsx` — both pages exist
|
||||
- `grep -q 'ProtectedRoute' frontend/src/App.tsx` — protected routes wired
|
||||
- `grep -q 'useAuth' frontend/src/App.tsx` — nav auth state wired
|
||||
|
||||
## Inputs
|
||||
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/context/AuthContext.tsx`
|
||||
- `frontend/src/api/public-client.ts`
|
||||
- `frontend/src/App.css`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- `frontend/src/components/ProtectedRoute.tsx`
|
||||
- `frontend/src/pages/CreatorDashboard.tsx`
|
||||
- `frontend/src/pages/CreatorDashboard.module.css`
|
||||
- `frontend/src/pages/CreatorSettings.tsx`
|
||||
- `frontend/src/pages/CreatorSettings.module.css`
|
||||
- `frontend/src/App.tsx`
|
||||
|
||||
## Verification
|
||||
|
||||
cd frontend && npm run build && test -f src/components/ProtectedRoute.tsx && test -f src/pages/CreatorDashboard.tsx && test -f src/pages/CreatorSettings.tsx && grep -q 'ProtectedRoute' src/App.tsx
|
||||
50
alembic/versions/016_add_users_and_invite_codes.py
Normal file
50
alembic/versions/016_add_users_and_invite_codes.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Add users and invite_codes tables for creator authentication.
|
||||
|
||||
Revision ID: 016_add_users_and_invite_codes
|
||||
Revises: 015_add_creator_profile
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "016_add_users_and_invite_codes"
|
||||
down_revision = "015_add_creator_profile"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create user_role enum type
|
||||
user_role_enum = sa.Enum("creator", "admin", name="user_role", create_constraint=True)
|
||||
user_role_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Create users table
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
|
||||
sa.Column("email", sa.String(255), nullable=False, unique=True),
|
||||
sa.Column("hashed_password", sa.String(255), nullable=False),
|
||||
sa.Column("display_name", sa.String(255), nullable=False),
|
||||
sa.Column("role", user_role_enum, nullable=False, server_default="creator"),
|
||||
sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Create invite_codes table
|
||||
op.create_table(
|
||||
"invite_codes",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
|
||||
sa.Column("code", sa.String(100), nullable=False, unique=True),
|
||||
sa.Column("uses_remaining", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("invite_codes")
|
||||
op.drop_table("users")
|
||||
sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True)
|
||||
116
backend/auth.py
Normal file
116
backend/auth.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Authentication utilities — password hashing, JWT, FastAPI dependencies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import get_settings
|
||||
from database import get_session
|
||||
from models import User, UserRole
|
||||
|
||||
# ── Password hashing ─────────────────────────────────────────────────────────
|
||||
|
||||
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash a plaintext password with bcrypt."""
|
||||
return _pwd_ctx.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""Verify a plaintext password against a bcrypt hash."""
|
||||
return _pwd_ctx.verify(plain, hashed)
|
||||
|
||||
|
||||
# ── JWT ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_ALGORITHM = "HS256"
|
||||
_ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: uuid.UUID | str,
|
||||
role: str,
|
||||
*,
|
||||
expires_minutes: int = _ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
) -> str:
|
||||
"""Create a signed JWT with user_id and role claims."""
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=expires_minutes),
|
||||
}
|
||||
return jwt.encode(payload, settings.app_secret_key, algorithm=_ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
"""Decode and validate a JWT. Raises on expiry or malformed tokens."""
|
||||
settings = get_settings()
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.app_secret_key,
|
||||
algorithms=[_ALGORITHM],
|
||||
options={"require": ["sub", "role", "exp"]},
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired",
|
||||
)
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid token: {exc}",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
# ── FastAPI dependencies ─────────────────────────────────────────────────────
|
||||
|
||||
async def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> User:
|
||||
"""Decode JWT, load User from DB, raise 401 if missing or inactive."""
|
||||
payload = decode_access_token(token)
|
||||
user_id = payload.get("sub")
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_role(required_role: UserRole):
|
||||
"""Return a dependency that checks the current user has the given role."""
|
||||
|
||||
async def _check(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
if current_user.role != required_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires {required_role.value} role",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return _check
|
||||
|
|
@ -12,6 +12,7 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
|
|
@ -73,6 +74,12 @@ class RelationshipType(str, enum.Enum):
|
|||
general_cross_reference = "general_cross_reference"
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""Roles for authenticated users."""
|
||||
creator = "creator"
|
||||
admin = "admin"
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _uuid_pk() -> Mapped[uuid.UUID]:
|
||||
|
|
@ -123,6 +130,52 @@ class Creator(Base):
|
|||
technique_pages: Mapped[list[TechniquePage]] = sa_relationship(back_populates="creator")
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Authenticated user account for the creator dashboard."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
Enum(UserRole, name="user_role", create_constraint=True),
|
||||
default=UserRole.creator,
|
||||
server_default="creator",
|
||||
)
|
||||
creator_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("creators.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, server_default="true"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now(), onupdate=_now
|
||||
)
|
||||
|
||||
# relationships
|
||||
creator: Mapped[Creator | None] = sa_relationship()
|
||||
|
||||
|
||||
class InviteCode(Base):
|
||||
"""Single-use or limited-use invite codes for registration gating."""
|
||||
__tablename__ = "invite_codes"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
uses_remaining: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class SourceVideo(Base):
|
||||
__tablename__ = "source_videos"
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ qdrant-client>=1.9,<2.0
|
|||
pyyaml>=6.0,<7.0
|
||||
psycopg2-binary>=2.9,<3.0
|
||||
watchdog>=4.0,<5.0
|
||||
PyJWT>=2.8,<3.0
|
||||
passlib[bcrypt]>=1.7,<2.0
|
||||
# Test dependencies
|
||||
pytest>=8.0,<10.0
|
||||
pytest-asyncio>=0.24,<1.0
|
||||
|
|
|
|||
|
|
@ -502,3 +502,46 @@ class AdminTechniquePageListResponse(BaseModel):
|
|||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
"""Registration payload — requires a valid invite code."""
|
||||
email: str = Field(..., min_length=3, max_length=255)
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
display_name: str = Field(..., min_length=1, max_length=255)
|
||||
invite_code: str = Field(..., min_length=1, max_length=100)
|
||||
creator_slug: str | None = Field(None, max_length=255)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login payload."""
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""JWT token response."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Public user profile response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
creator_id: uuid.UUID | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""Self-service profile update."""
|
||||
display_name: str | None = Field(None, min_length=1, max_length=255)
|
||||
current_password: str | None = None
|
||||
new_password: str | None = Field(None, min_length=8, max_length=128)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue