porchlight/docs/plans/2026-02-12-sqlite-repositories-design.md

235 lines
8.3 KiB
Markdown

# SQLite Repository Implementation — Design & Session State
> **For Claude:** This document captures the full project state and in-progress design decisions
> for the SQLite repository implementation. Resume from "Next Steps" section.
## Project State (as of 2026-02-12)
### Git
- **Branch:** `main` (10 commits, no feature branches)
- **HEAD:** `9d7a67b fix: add collision retry for userid generation`
- **Working tree:** Clean (untracked: `.idea/`, `README.md`, `docs/`)
- **All 17 tests passing**, all quality checks green
### What Exists
| File | Purpose |
|------|---------|
| `src/fastapi_oidc_op/config.py` | `Settings(BaseSettings)` with `env_prefix="OIDC_OP_"`, `StorageBackend` enum (sqlite/mongodb), `sqlite_path` default `data/oidc_op.db` |
| `src/fastapi_oidc_op/models.py` | `User`, `WebAuthnCredential`, `PasswordCredential`, `MagicLink`, `CredentialType` enum. Datetimes use `datetime.UTC`. `MagicLink.expires_at` is required (no default). |
| `src/fastapi_oidc_op/store/protocols.py` | `@runtime_checkable` Protocol interfaces: `UserRepository`, `CredentialRepository`, `MagicLinkRepository` |
| `src/fastapi_oidc_op/userid.py` | `generate_userid()` (pure, proquint 32-bit), `generate_unique_userid(user_repo, max_retries=10)` (async, collision retry) |
| `src/fastapi_oidc_op/app.py` | `create_app(settings)` factory. `/health` endpoint. Settings on `app.state`. Docs only in debug mode. |
| `tests/conftest.py` | `settings` fixture, async `client` fixture via httpx `ASGITransport` |
| `scripts/check.sh` | Quality gate: ruff format, ruff check, ty check, pytest |
| `pyproject.toml` | Python 3.13, hatchling build, src layout. Deps: fastapi, idpyoidc, pydantic-settings, fido2, argon2-cffi, motor, aiosqlite, proquint, httpx. Dev: ruff, ty, pytest, pytest-asyncio. |
### Package Structure
```
src/fastapi_oidc_op/
├── __init__.py
├── app.py
├── config.py
├── models.py
├── userid.py
├── oidc/__init__.py
├── authn/__init__.py
├── manage/__init__.py
├── invite/__init__.py
└── store/
├── __init__.py
├── protocols.py
├── mongodb/__init__.py
└── sqlite/__init__.py
```
### Existing Protocol Signatures (must implement exactly)
```python
class UserRepository(Protocol):
async def create(self, user: User) -> User
async def get_by_userid(self, userid: str) -> User | None
async def get_by_username(self, username: str) -> User | None
async def update(self, user: User) -> User
async def list_users(self, offset: int = 0, limit: int = 100) -> list[User]
async def delete(self, userid: str) -> bool
class CredentialRepository(Protocol):
async def create_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential
async def create_password(self, credential: PasswordCredential) -> PasswordCredential
async def get_webauthn_by_user(self, user_id: str) -> list[WebAuthnCredential]
async def get_webauthn_by_credential_id(self, credential_id: bytes) -> WebAuthnCredential | None
async def get_password_by_user(self, user_id: str) -> PasswordCredential | None
async def update_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential
async def delete_webauthn(self, user_id: str, credential_id: bytes) -> bool
async def delete_password(self, user_id: str) -> bool
class MagicLinkRepository(Protocol):
async def create(self, link: MagicLink) -> MagicLink
async def get_by_token(self, token: str) -> MagicLink | None
async def mark_used(self, token: str) -> bool
async def delete_expired(self) -> int
```
### Design Document
Full design doc at `docs/plans/2026-02-11-oidc-op-design.md`. Key points:
- SQLite uses `aiosqlite` with raw SQL
- Storage backend selected at startup via config
- SQLite is the default test backend (no external deps)
### Known Quirks
- **ty 0.0.16:** `python-version` goes under `[tool.ty.environment]`, no `src` field at top level
- **ruff 0.15:** version command is `ruff version` not `ruff --version`
- **User.userid** (no underscore) vs **credential.user_id** (underscore) — intentional, matches design doc
---
## Design Decisions (confirmed by user)
### 1. Schema Initialization: SQL Migration Files
- Directory: `src/fastapi_oidc_op/store/sqlite/migrations/`
- Numbered SQL files: `001_initial.sql`, `002_...`, etc.
- Tracker table `_migrations` records applied files
- Migration runner at startup: read files, skip applied, execute in order, record
### 2. Connection Strategy: Single Shared Connection
- One `aiosqlite` connection created at startup, shared by all repositories
- WAL mode enabled for concurrent reads
- Foreign keys enabled (`PRAGMA foreign_keys = ON`)
- Connection closed on shutdown
### 3. Dependency Injection: Lifespan + app.state
- App lifespan (`@asynccontextmanager`) creates DB connection and repositories
- Stored on `app.state` (e.g. `app.state.user_repo`)
- FastAPI `Depends()` retrieves from `request.app.state`
### 4. Groups Storage: Separate Join Table
- `user_groups(userid TEXT, group_name TEXT)` with composite PK
- `ON DELETE CASCADE` from users table
- Repository handles join/split when reading/writing User models
### 5. Bytes Columns: BLOB
- `credential_id` and `public_key` stored as BLOB
- aiosqlite handles bytes natively
### 6. Datetimes: ISO 8601 TEXT
- All datetime columns stored as ISO 8601 text strings
- Converted to/from `datetime` objects in the repository layer
### 7. Booleans: INTEGER 0/1
- SQLite has no native bool; use INTEGER with 0/1
---
## Schema (approved — Section 1)
```sql
-- _migrations tracker
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 001_initial.sql
CREATE TABLE users (
userid TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
preferred_username TEXT,
given_name TEXT,
family_name TEXT,
nickname TEXT,
email TEXT,
email_verified INTEGER NOT NULL DEFAULT 0,
phone_number TEXT,
phone_number_verified INTEGER NOT NULL DEFAULT 0,
picture TEXT,
locale TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE user_groups (
userid TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE,
group_name TEXT NOT NULL,
PRIMARY KEY (userid, group_name)
);
CREATE TABLE webauthn_credentials (
user_id TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE,
credential_id BLOB NOT NULL,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
device_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, credential_id)
);
CREATE TABLE password_credentials (
user_id TEXT PRIMARY KEY REFERENCES users(userid) ON DELETE CASCADE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE magic_links (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_by TEXT,
note TEXT
);
```
**Status:** Schema approved. Implementation complete.
---
## Implementation Status
All SQLite repository tasks are **complete**. See `docs/plans/2026-02-13-sqlite-repositories-plan.md` for the full implementation plan.
### Completed (commits `bfa5b2e` through `a45604f`)
1. SQL migration file (`001_initial.sql`)
2. Migration runner (`run_migrations()` with `_migrations` tracker table)
3. `DuplicateError` domain exception
4. `SQLiteUserRepository` with tests
5. `SQLiteCredentialRepository` with tests
6. `SQLiteMagicLinkRepository` with tests
7. Lifespan integration and dependency injection (`dependencies.py`)
### Quality Gate
All 64 tests passing. `./scripts/check.sh` all green (ruff format, ruff check, ty check, pytest).
### Roadmap
Per the design doc, the roadmap is:
1. ~~Scaffolding~~ (done)
2. ~~SQLite repositories~~ (done)
3. **Authentication** (WebAuthn + password) — next phase
4. OIDC provider integration (idpyoidc)
5. Management UI (OIDC RP that dogfoods the OP)
---
## Process Notes
- User prefers to be asked design questions before implementation begins
- Use **brainstorming** skill: one question at a time, present design in 200-300 word sections, validate each
- Use **subagent-driven development** for implementation
- Plans live in `docs/plans/`
- Quality gate: `./scripts/check.sh` (ruff format, ruff check, ty check, pytest)