diff --git a/docs/superpowers/plans/2026-07-20-quantumancy-01-foundation-auth.md b/docs/superpowers/plans/2026-07-20-quantumancy-01-foundation-auth.md new file mode 100644 index 0000000..916c0ac --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-quantumancy-01-foundation-auth.md @@ -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?**