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

8.4 KiB

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)

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)

-- _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 86 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 services (done) — PasswordService, WebAuthnService, MagicLinkService
  4. Authentication routes (login/register endpoints + templates) — next phase
  5. OIDC provider integration (idpyoidc)
  6. 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)