Add Plan 1/7: Foundation & Auth implementation plan
Repo scaffold, Docker Compose (app + postgres + isolated postgres_test), async SQLAlchemy setup, and username/password auth with argon2 hashing and server-side session cookies. Remaining six plans (LLM/TTS pipeline, Wire Ghost, EVP, Spirit Radio, Codex, i18n) follow once this lands.
This commit is contained in:
@@ -0,0 +1,809 @@
|
||||
# Quantumancy Plan 1/7: Foundation & Auth — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stand up the deployable skeleton of Quantumancy — repo scaffold, Docker Compose, Postgres, and username/password auth with server-side session cookies — so every later plan (LLM pipeline, spirit modes, Codex) has a real, tested backend to build on.
|
||||
|
||||
**Architecture:** Python + FastAPI backend (async SQLAlchemy 2.0 / asyncpg against Postgres), served from a single `app` Docker container alongside a `postgres` container, plus an isolated `postgres_test` container for tests. Tables are created via `Base.metadata.create_all` at startup (no Alembic yet — schema is not stable enough to justify migration tooling at this stage; revisit once the schema stabilizes past Plan 3).
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async, asyncpg), argon2-cffi, httpx, pytest + pytest-asyncio, Docker Compose.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend is Python + FastAPI (spec §2).
|
||||
- App serves plain HTTP on port 7777; TLS is handled entirely outside this repo by a Cloudflare Tunnel (spec §2).
|
||||
- Database is Postgres (spec §2, §5).
|
||||
- Auth is username/password with argon2 hashing, server-side session cookies (not JWT), and **open registration** — no invite gating (spec §5).
|
||||
- Per-account and per-IP rate limiting applies to all LLM-triggering endpoints (spec §5) — this plan builds the reusable limiter; wiring it onto LLM endpoints happens in Plan 2 once those endpoints exist.
|
||||
- Note on naming vs. spec: the spec's `sessions` table (mode used, timing) is implemented here as `contact_sessions` to avoid collision with the login-session concept (`auth_sessions`), which the spec also calls "sessions." Both concepts exist; only the domain table name changed for clarity.
|
||||
|
||||
## Plan Series
|
||||
|
||||
This is 1 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`):
|
||||
1. **Foundation & Auth** (this plan)
|
||||
2. LLM/TTS/Realtime pipeline (Ollama client+queue, Piper TTS+effects, WebSocket session channel, frontend scaffold)
|
||||
3. Wire Ghost mode + Ouija/Planchette UI
|
||||
4. EVP Listening mode
|
||||
5. Spirit Radio mode
|
||||
6. Codex & entity persistence
|
||||
7. Internationalization (EN/ES)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Repo Scaffold, Docker Compose, Health Check
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/requirements.txt`
|
||||
- Create: `backend/app/main.py`
|
||||
- Create: `backend/Dockerfile`
|
||||
- Create: `backend/pytest.ini`
|
||||
- Create: `docker-compose.yml`
|
||||
- Create: `.env.example`
|
||||
- Test: `backend/tests/test_health.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `app` FastAPI instance in `backend/app/main.py`, importable as `app.main:app`.
|
||||
|
||||
- [ ] **Step 1: Create the infra files**
|
||||
|
||||
`backend/requirements.txt`:
|
||||
```
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
pydantic-settings==2.6.1
|
||||
argon2-cffi==23.1.0
|
||||
httpx==0.27.2
|
||||
numpy==2.1.3
|
||||
pytest==8.3.3
|
||||
pytest-asyncio==0.24.0
|
||||
```
|
||||
|
||||
`backend/pytest.ini`:
|
||||
```ini
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
```
|
||||
|
||||
`backend/Dockerfile`:
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
EXPOSE 7777
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7777"]
|
||||
```
|
||||
|
||||
`docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "7777:7777"
|
||||
env_file: .env
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: quantumancy
|
||||
POSTGRES_PASSWORD: quantumancy
|
||||
POSTGRES_DB: quantumancy
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
postgres_test:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: quantumancy
|
||||
POSTGRES_PASSWORD: quantumancy
|
||||
POSTGRES_DB: quantumancy_test
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
`.env.example`:
|
||||
```
|
||||
DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@postgres:5432/quantumancy
|
||||
OLLAMA_BASE_URL=http://10.30.20.107:11434
|
||||
SESSION_SECRET=change-me-to-a-random-64-char-string
|
||||
PORT=7777
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
`backend/tests/test_health.py`:
|
||||
```python
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_healthz_returns_ok():
|
||||
client = TestClient(app)
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_health.py -v`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'app'` (or `ImportError`) since `app/main.py` doesn't exist yet.
|
||||
|
||||
- [ ] **Step 4: Write minimal implementation**
|
||||
|
||||
`backend/app/__init__.py`: (empty file)
|
||||
|
||||
`backend/app/main.py`:
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(title="Quantumancy")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_health.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend docker-compose.yml .env.example
|
||||
git commit -m "chore: scaffold backend, docker compose, health check"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Config Settings & Async DB Engine
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/config.py`
|
||||
- Create: `backend/app/db.py`
|
||||
- Test: `backend/tests/test_config.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from Task 1 directly (parallel infra piece).
|
||||
- Produces: `settings` (Settings instance) in `app.config`; `Base` (DeclarativeBase), `engine`, `async_session_maker`, `get_db()` async generator in `app.db` — every later model/route task depends on these exact names.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
`backend/tests/test_config.py`:
|
||||
```python
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_settings_load_from_env(monkeypatch):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@host/db")
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://10.30.20.107:11434")
|
||||
monkeypatch.setenv("SESSION_SECRET", "test-secret")
|
||||
settings = Settings(_env_file=None)
|
||||
assert settings.database_url == "postgresql+asyncpg://u:p@host/db"
|
||||
assert settings.ollama_base_url == "http://10.30.20.107:11434"
|
||||
assert settings.port == 7777
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_config.py -v`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.config'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`backend/app/config.py`:
|
||||
```python
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
database_url: str
|
||||
ollama_base_url: str
|
||||
session_secret: str
|
||||
port: int = 7777
|
||||
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
`backend/app/db.py`:
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_config.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/config.py backend/app/db.py backend/tests/test_config.py
|
||||
git commit -m "feat: add settings and async db engine"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: User Model & Registration Endpoint
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/__init__.py`
|
||||
- Create: `backend/app/models/user.py`
|
||||
- Create: `backend/app/security.py`
|
||||
- Create: `backend/app/schemas.py`
|
||||
- Create: `backend/app/routes/__init__.py`
|
||||
- Create: `backend/app/routes/auth.py`
|
||||
- Modify: `backend/app/main.py`
|
||||
- Create: `backend/tests/conftest.py`
|
||||
- Test: `backend/tests/test_auth.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Base`, `get_db` from `app.db` (Task 2).
|
||||
- Produces: `User` model (`id: uuid.UUID`, `username: str`, `password_hash: str`, `email: str | None`, `created_at: datetime`) in `app.models.user`; `hash_password(password: str) -> str` and `verify_password(password: str, password_hash: str) -> bool` in `app.security`; `RegisterRequest`, `UserOut` in `app.schemas`; `POST /auth/register` route. `conftest.py`'s `client` fixture and `_reset_db` fixture are reused by every later test file.
|
||||
|
||||
- [ ] **Step 1: Write the test fixtures and failing tests**
|
||||
|
||||
`backend/tests/conftest.py`:
|
||||
```python
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.db import Base, engine, get_db
|
||||
from app.main import app
|
||||
|
||||
TestSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _reset_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
async def _override_get_db():
|
||||
async with TestSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
```
|
||||
|
||||
`backend/tests/test_auth.py`:
|
||||
```python
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_creates_user(client):
|
||||
response = await client.post(
|
||||
"/auth/register",
|
||||
json={"username": "medium1", "password": "spookyspooky"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["username"] == "medium1"
|
||||
assert "id" in body
|
||||
assert "password" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate_username_rejected(client):
|
||||
await client.post("/auth/register", json={"username": "medium1", "password": "spookyspooky"})
|
||||
response = await client.post("/auth/register", json={"username": "medium1", "password": "anotherpass"})
|
||||
assert response.status_code == 409
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `docker compose run --rm -e DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@postgres_test:5432/quantumancy_test app python -m pytest tests/test_auth.py -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: No module named 'app.models'` (or similar import error)
|
||||
|
||||
Note: this `docker compose run` invocation against `postgres_test` (not `postgres`) is the standard way to run the test suite for the rest of this plan and all later plans — it points the app container at the isolated, tmpfs-backed test database so tests never touch real dev/prod data.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`backend/app/models/user.py`:
|
||||
```python
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
```
|
||||
|
||||
`backend/app/models/__init__.py`:
|
||||
```python
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = ["User"]
|
||||
```
|
||||
|
||||
`backend/app/security.py`:
|
||||
```python
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
```
|
||||
|
||||
`backend/app/schemas.py`:
|
||||
```python
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=32)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
```
|
||||
|
||||
`backend/app/routes/__init__.py`: (empty file)
|
||||
|
||||
`backend/app/routes/auth.py`:
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas import RegisterRequest, UserOut
|
||||
from app.security import hash_password
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.scalar(select(User).where(User.username == payload.username))
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="username taken")
|
||||
|
||||
user = User(
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
email=payload.email,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
```
|
||||
|
||||
`backend/app/main.py` (replace entirely):
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
import app.models # noqa: F401 — registers models on Base.metadata before create_all
|
||||
from app.db import Base, engine
|
||||
from app.routes.auth import router as auth_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Quantumancy", lifespan=lifespan)
|
||||
app.include_router(auth_router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `docker compose run --rm -e DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@postgres_test:5432/quantumancy_test app python -m pytest tests/test_auth.py tests/test_health.py -v`
|
||||
Expected: PASS (all tests, including the Task 1 health check, which still needs to pass since `main.py` was rewritten)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models backend/app/security.py backend/app/schemas.py backend/app/routes backend/app/main.py backend/tests/conftest.py backend/tests/test_auth.py
|
||||
git commit -m "feat: add user model and registration endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Login, Session Cookie & get_current_user
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/auth_session.py`
|
||||
- Modify: `backend/app/models/__init__.py`
|
||||
- Create: `backend/app/deps.py`
|
||||
- Modify: `backend/app/schemas.py`
|
||||
- Modify: `backend/app/routes/auth.py`
|
||||
- Test: `backend/tests/test_auth.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `User` (Task 3), `Base`/`get_db` (Task 2).
|
||||
- Produces: `AuthSession` model, `generate_session_token() -> tuple[str, str]` (raw token, sha256 hash), `hash_token(raw: str) -> str` in `app.models.auth_session`; `get_current_user(...) -> User` dependency and `SESSION_COOKIE_NAME` constant in `app.deps` — every later route needing an authenticated user depends on `get_current_user`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `backend/tests/test_auth.py`:
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_sets_cookie_and_me_returns_user(client):
|
||||
await client.post("/auth/register", json={"username": "medium2", "password": "spookyspooky"})
|
||||
login_resp = await client.post("/auth/login", json={"username": "medium2", "password": "spookyspooky"})
|
||||
assert login_resp.status_code == 200
|
||||
assert "qm_session" in login_resp.cookies
|
||||
|
||||
me_resp = await client.get("/auth/me")
|
||||
assert me_resp.status_code == 200
|
||||
assert me_resp.json()["username"] == "medium2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password_rejected(client):
|
||||
await client.post("/auth/register", json={"username": "medium3", "password": "spookyspooky"})
|
||||
response = await client.post("/auth/login", json={"username": "medium3", "password": "wrongpass"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_without_cookie_rejected(client):
|
||||
response = await client.get("/auth/me")
|
||||
assert response.status_code == 401
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `docker compose run --rm -e DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@postgres_test:5432/quantumancy_test app python -m pytest tests/test_auth.py -v`
|
||||
Expected: FAIL — `404 Not Found` for `/auth/login` and `/auth/me` (routes don't exist yet)
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`backend/app/models/auth_session.py`:
|
||||
```python
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
SESSION_TTL = timedelta(days=14)
|
||||
|
||||
|
||||
class AuthSession(Base):
|
||||
__tablename__ = "auth_sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
def generate_session_token() -> tuple[str, str]:
|
||||
raw = secrets.token_urlsafe(32)
|
||||
return raw, hash_token(raw)
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
```
|
||||
|
||||
`backend/app/models/__init__.py` (replace entirely):
|
||||
```python
|
||||
from app.models.auth_session import AuthSession
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = ["User", "AuthSession"]
|
||||
```
|
||||
|
||||
`backend/app/deps.py`:
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Cookie, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.models.auth_session import AuthSession, hash_token
|
||||
from app.models.user import User
|
||||
|
||||
SESSION_COOKIE_NAME = "qm_session"
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
qm_session: str | None = Cookie(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
if qm_session is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
||||
|
||||
token_hash = hash_token(qm_session)
|
||||
result = await db.execute(select(AuthSession).where(AuthSession.token_hash == token_hash))
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None or session.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session expired")
|
||||
|
||||
user = await db.get(User, session.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
|
||||
return user
|
||||
```
|
||||
|
||||
Append to `backend/app/schemas.py`:
|
||||
```python
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
```
|
||||
|
||||
`backend/app/routes/auth.py` (replace entirely):
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.deps import SESSION_COOKIE_NAME, get_current_user
|
||||
from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token
|
||||
from app.models.user import User
|
||||
from app.schemas import LoginRequest, RegisterRequest, UserOut
|
||||
from app.security import hash_password, verify_password
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.scalar(select(User).where(User.username == payload.username))
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="username taken")
|
||||
|
||||
user = User(
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
email=payload.email,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserOut)
|
||||
async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
user = await db.scalar(select(User).where(User.username == payload.username))
|
||||
if user is None or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
|
||||
|
||||
raw_token, token_hash = generate_session_token()
|
||||
session = AuthSession(
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=datetime.now(timezone.utc) + SESSION_TTL,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
raw_token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=int(SESSION_TTL.total_seconds()),
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(response: Response):
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `docker compose run --rm -e DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@postgres_test:5432/quantumancy_test app python -m pytest tests/test_auth.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/auth_session.py backend/app/models/__init__.py backend/app/deps.py backend/app/schemas.py backend/app/routes/auth.py backend/tests/test_auth.py
|
||||
git commit -m "feat: add login, session cookies, and get_current_user"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Rate Limiter Utility
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/rate_limit.py`
|
||||
- Test: `backend/tests/test_rate_limit.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (pure, standalone utility).
|
||||
- Produces: `RateLimiter(max_requests: int, window_seconds: float)` with `.allow(key: str) -> bool` in `app.rate_limit` — Plan 2 wires this onto the LLM-triggering endpoints per the spec's rate-limiting requirement.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
`backend/tests/test_rate_limit.py`:
|
||||
```python
|
||||
from app.rate_limit import RateLimiter
|
||||
|
||||
|
||||
def test_allows_up_to_limit_then_blocks():
|
||||
limiter = RateLimiter(max_requests=3, window_seconds=60)
|
||||
assert limiter.allow("user-1") is True
|
||||
assert limiter.allow("user-1") is True
|
||||
assert limiter.allow("user-1") is True
|
||||
assert limiter.allow("user-1") is False
|
||||
|
||||
|
||||
def test_different_keys_tracked_independently():
|
||||
limiter = RateLimiter(max_requests=1, window_seconds=60)
|
||||
assert limiter.allow("user-1") is True
|
||||
assert limiter.allow("user-2") is True
|
||||
assert limiter.allow("user-1") is False
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `docker compose run --rm -e DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@postgres_test:5432/quantumancy_test app python -m pytest tests/test_rate_limit.py -v`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.rate_limit'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`backend/app/rate_limit.py`:
|
||||
```python
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Fixed-window limiter keyed by an arbitrary string (user id or client IP)."""
|
||||
|
||||
def __init__(self, max_requests: int, window_seconds: float):
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
now = time.monotonic()
|
||||
window_start = now - self.window_seconds
|
||||
hits = self._hits[key]
|
||||
while hits and hits[0] < window_start:
|
||||
hits.pop(0)
|
||||
if len(hits) >= self.max_requests:
|
||||
return False
|
||||
hits.append(now)
|
||||
return True
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `docker compose run --rm -e DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@postgres_test:5432/quantumancy_test app python -m pytest tests/test_rate_limit.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run the full test suite**
|
||||
|
||||
Run: `docker compose run --rm -e DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@postgres_test:5432/quantumancy_test app python -m pytest -v`
|
||||
Expected: PASS (all tests from Tasks 1-5)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/rate_limit.py backend/tests/test_rate_limit.py
|
||||
git commit -m "feat: add rate limiter utility"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:** repo/Docker scaffold (§2, §9) → Task 1. Postgres (§2) → Task 1/2. FastAPI backend (§2) → all tasks. Username/password + argon2 + open registration (§5) → Task 3. Server-side session cookies (§5) → Task 4. Per-account/per-IP rate limiting (§5) → Task 5 (utility only; enforcement on LLM endpoints is explicitly deferred to Plan 2, since those endpoints don't exist until then). Everything else in the spec (the four modes, Codex, TTS/i18n, deployment finalization) is out of scope for Plan 1 by design — covered in Plans 2-7.
|
||||
|
||||
**Placeholder scan:** none found — every step has complete, runnable code.
|
||||
|
||||
**Type consistency:** `User.id: uuid.UUID` matches `UserOut.id: uuid.UUID`. `generate_session_token() -> tuple[str, str]` return order (raw, hash) matches its usage in `routes/auth.py`'s `login` (`raw_token, token_hash = generate_session_token()`). `hash_token(raw: str) -> str` used identically in both `auth_session.py` (internally) and `deps.py`. `get_current_user` return type `User` matches its use as `user: User = Depends(get_current_user)` in `routes/auth.py` and is the exact name/import path (`app.deps.get_current_user`) later plans will depend on.
|
||||
|
||||
---
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-07-20-quantumancy-01-foundation-auth.md`. Two execution options:
|
||||
|
||||
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||||
|
||||
**Which approach?**
|
||||
Reference in New Issue
Block a user