porchlight/docs/plans/2026-02-11-scaffolding-plan.md

860 lines
22 KiB
Markdown

# Project Scaffolding Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Set up the project foundation: tooling (ruff, ty), package structure, configuration, Pydantic models, repository protocols, app factory, and initial test infrastructure.
**Architecture:** FastAPI app factory pattern with src layout. Pydantic models for data, Protocol-based repository interfaces, env-based configuration via pydantic-settings. All code checked by ruff (broad ruleset) and ty (strict mode).
**Tech Stack:** Python 3.13, FastAPI, pydantic-settings, ruff, ty, pytest, pytest-asyncio
**Design Document:** `docs/plans/2026-02-11-oidc-op-design.md`
---
### Task 1: Configure pyproject.toml with dependencies and tooling
**Files:**
- Modify: `pyproject.toml`
**Step 1: Update pyproject.toml with all sections**
```toml
[project]
name = "fastapi-oidc-op"
version = "0.1.0"
description = "OIDC OpenID Provider with user management"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
"idpyoidc>=5.0",
"pydantic-settings>=2.7",
"jinja2>=3.1",
"fido2>=2.1",
"argon2-cffi>=25.1",
"motor>=3.7",
"aiosqlite>=0.21",
"proquint>=0.2",
"python-multipart>=0.0.20",
"httpx>=0.28",
]
[project.scripts]
fastapi-oidc-op = "fastapi_oidc_op.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/fastapi_oidc_op"]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.25",
"ruff>=0.15",
"ty>=0.0.16",
]
[tool.ruff]
line-length = 120
target-version = "py311"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "UP", "B", "SIM", "I", "C4", "RUF"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true
[tool.ty]
python-version = "3.13"
src = ["src"]
[tool.ty.rules]
possibly-unresolved-reference = "error"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
```
**Step 2: Install dependencies**
Run: `uv sync`
Expected: All dependencies resolve and install successfully.
**Step 3: Verify tools work**
Run: `uv run ruff check --version && uv run ty --version`
Expected: Both print version numbers.
**Step 4: Commit**
```bash
git add pyproject.toml uv.lock
git commit -m "chore: configure project dependencies and tooling (ruff, ty, pytest)"
```
---
### Task 2: Create package structure with __init__.py files
**Files:**
- Create: `src/fastapi_oidc_op/__init__.py`
- Create: `src/fastapi_oidc_op/oidc/__init__.py`
- Create: `src/fastapi_oidc_op/authn/__init__.py`
- Create: `src/fastapi_oidc_op/manage/__init__.py`
- Create: `src/fastapi_oidc_op/store/__init__.py`
- Create: `src/fastapi_oidc_op/store/mongodb/__init__.py`
- Create: `src/fastapi_oidc_op/store/sqlite/__init__.py`
- Create: `src/fastapi_oidc_op/invite/__init__.py`
- Create: `tests/__init__.py`
- Create: `tests/test_store/__init__.py`
- Create: `tests/test_authn/__init__.py`
- Create: `tests/test_oidc/__init__.py`
- Create: `tests/test_manage/__init__.py`
**Step 1: Create all directories and __init__.py files**
All `__init__.py` files are empty. Create the directory tree:
```
src/
└── fastapi_oidc_op/
├── __init__.py
├── oidc/
│ └── __init__.py
├── authn/
│ └── __init__.py
├── manage/
│ └── __init__.py
├── store/
│ ├── __init__.py
│ ├── mongodb/
│ │ └── __init__.py
│ └── sqlite/
│ └── __init__.py
└── invite/
└── __init__.py
tests/
├── __init__.py
├── test_store/
│ └── __init__.py
├── test_authn/
│ └── __init__.py
├── test_oidc/
│ └── __init__.py
└── test_manage/
└── __init__.py
```
**Step 2: Verify package is importable**
Run: `uv run python -c "import fastapi_oidc_op; print('OK')"`
Expected: Prints `OK`
**Step 3: Commit**
```bash
git add src/ tests/
git commit -m "chore: create package structure with src layout"
```
---
### Task 3: Implement configuration module
**Files:**
- Create: `src/fastapi_oidc_op/config.py`
- Create: `tests/test_config.py`
**Step 1: Write the failing test**
```python
# tests/test_config.py
from fastapi_oidc_op.config import Settings, StorageBackend
def test_default_settings() -> None:
settings = Settings(
issuer="http://localhost:8000",
)
assert settings.issuer == "http://localhost:8000"
assert settings.storage_backend == StorageBackend.SQLITE
assert settings.sqlite_path == "data/oidc_op.db"
assert settings.manage_client_id == "manage-app"
assert settings.invite_ttl == 86400
assert settings.theme == "default"
def test_mongodb_settings() -> None:
settings = Settings(
issuer="http://localhost:8000",
storage_backend=StorageBackend.MONGODB,
mongodb_uri="mongodb://mongo:27017",
mongodb_database="test_db",
)
assert settings.storage_backend == StorageBackend.MONGODB
assert settings.mongodb_uri == "mongodb://mongo:27017"
assert settings.mongodb_database == "test_db"
def test_settings_from_env(monkeypatch: "pytest.MonkeyPatch") -> None:
import pytest
monkeypatch.setenv("OIDC_OP_ISSUER", "https://op.example.org")
monkeypatch.setenv("OIDC_OP_STORAGE_BACKEND", "mongodb")
monkeypatch.setenv("OIDC_OP_MONGODB_URI", "mongodb://remote:27017")
settings = Settings() # type: ignore[call-arg]
assert settings.issuer == "https://op.example.org"
assert settings.storage_backend == StorageBackend.MONGODB
```
**Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_config.py -v`
Expected: FAIL - ImportError
**Step 3: Write the implementation**
```python
# src/fastapi_oidc_op/config.py
from enum import StrEnum
from pydantic_settings import BaseSettings
class StorageBackend(StrEnum):
SQLITE = "sqlite"
MONGODB = "mongodb"
class Settings(BaseSettings):
model_config = {"env_prefix": "OIDC_OP_"}
# Core
issuer: str
debug: bool = False
# Storage
storage_backend: StorageBackend = StorageBackend.SQLITE
# SQLite
sqlite_path: str = "data/oidc_op.db"
# MongoDB
mongodb_uri: str = "mongodb://localhost:27017"
mongodb_database: str = "oidc_op"
# Management RP
manage_client_id: str = "manage-app"
# Magic links
invite_ttl: int = 86400 # seconds
# Theme
theme: str = "default"
```
**Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_config.py -v`
Expected: All 3 tests PASS
**Step 5: Run ruff and ty**
Run: `uv run ruff check src/fastapi_oidc_op/config.py tests/test_config.py && uv run ruff format --check src/fastapi_oidc_op/config.py tests/test_config.py && uv run ty check src/fastapi_oidc_op/config.py`
Expected: No errors
**Step 6: Commit**
```bash
git add src/fastapi_oidc_op/config.py tests/test_config.py
git commit -m "feat: add configuration module with env-based settings"
```
---
### Task 4: Implement Pydantic models
**Files:**
- Create: `src/fastapi_oidc_op/models.py`
- Create: `tests/test_models.py`
**Step 1: Write the failing tests**
```python
# tests/test_models.py
from datetime import datetime, timezone
from fastapi_oidc_op.models import (
CredentialType,
MagicLink,
PasswordCredential,
User,
WebAuthnCredential,
)
def test_user_creation() -> None:
user = User(
userid="lusab-bansen",
username="alice",
)
assert user.userid == "lusab-bansen"
assert user.username == "alice"
assert user.preferred_username is None
assert user.email is None
assert user.active is True
assert user.groups == []
assert user.created_at is not None
assert user.updated_at is not None
def test_user_with_all_fields() -> None:
user = User(
userid="lusab-bansen",
username="alice",
preferred_username="Alice W.",
given_name="Alice",
family_name="Wonderland",
nickname="ally",
email="alice@example.com",
email_verified=True,
phone_number="+1234567890",
phone_number_verified=False,
picture="https://example.com/alice.jpg",
locale="en-US",
active=True,
groups=["admin", "users"],
)
assert user.given_name == "Alice"
assert user.groups == ["admin", "users"]
def test_webauthn_credential() -> None:
cred = WebAuthnCredential(
user_id="lusab-bansen",
credential_id=b"\x01\x02\x03",
public_key=b"\x04\x05\x06",
sign_count=0,
device_name="YubiKey 5",
)
assert cred.type == CredentialType.WEBAUTHN
assert cred.credential_id == b"\x01\x02\x03"
assert cred.device_name == "YubiKey 5"
def test_password_credential() -> None:
cred = PasswordCredential(
user_id="lusab-bansen",
password_hash="$argon2id$v=19$m=65536,t=3,p=4$hash",
)
assert cred.type == CredentialType.PASSWORD
assert cred.password_hash.startswith("$argon2")
def test_magic_link() -> None:
link = MagicLink(
token="abc123def456",
username="newuser",
)
assert link.token == "abc123def456"
assert link.username == "newuser"
assert link.used is False
assert link.created_by is None
assert link.expires_at > datetime.now(timezone.utc)
```
**Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_models.py -v`
Expected: FAIL - ImportError
**Step 3: Write the implementation**
```python
# src/fastapi_oidc_op/models.py
from datetime import datetime, timedelta, timezone
from enum import StrEnum
from pydantic import BaseModel, Field
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _default_expiry() -> datetime:
return datetime.now(timezone.utc) + timedelta(hours=24)
class CredentialType(StrEnum):
WEBAUTHN = "webauthn"
PASSWORD = "password"
class User(BaseModel):
userid: str
username: str
preferred_username: str | None = None
given_name: str | None = None
family_name: str | None = None
nickname: str | None = None
email: str | None = None
email_verified: bool = False
phone_number: str | None = None
phone_number_verified: bool = False
picture: str | None = None
locale: str | None = None
active: bool = True
created_at: datetime = Field(default_factory=_utcnow)
updated_at: datetime = Field(default_factory=_utcnow)
groups: list[str] = Field(default_factory=list)
class WebAuthnCredential(BaseModel):
user_id: str
type: CredentialType = CredentialType.WEBAUTHN
credential_id: bytes
public_key: bytes
sign_count: int = 0
device_name: str = ""
created_at: datetime = Field(default_factory=_utcnow)
class PasswordCredential(BaseModel):
user_id: str
type: CredentialType = CredentialType.PASSWORD
password_hash: str
created_at: datetime = Field(default_factory=_utcnow)
class MagicLink(BaseModel):
token: str
username: str
expires_at: datetime = Field(default_factory=_default_expiry)
used: bool = False
created_by: str | None = None
note: str | None = None
```
**Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_models.py -v`
Expected: All 5 tests PASS
**Step 5: Run ruff and ty**
Run: `uv run ruff check src/fastapi_oidc_op/models.py tests/test_models.py && uv run ruff format --check src/fastapi_oidc_op/models.py tests/test_models.py && uv run ty check src/fastapi_oidc_op/models.py`
Expected: No errors
**Step 6: Commit**
```bash
git add src/fastapi_oidc_op/models.py tests/test_models.py
git commit -m "feat: add Pydantic models for User, Credential, and MagicLink"
```
---
### Task 5: Implement repository protocols
**Files:**
- Create: `src/fastapi_oidc_op/store/protocols.py`
- Create: `tests/test_store/test_protocols.py`
**Step 1: Write the failing test**
```python
# tests/test_store/test_protocols.py
from typing import runtime_checkable
from fastapi_oidc_op.store.protocols import (
CredentialRepository,
MagicLinkRepository,
UserRepository,
)
def test_protocols_are_runtime_checkable() -> None:
assert runtime_checkable(UserRepository) # type: ignore[arg-type]
assert runtime_checkable(CredentialRepository) # type: ignore[arg-type]
assert runtime_checkable(MagicLinkRepository) # type: ignore[arg-type]
```
Note: This test just verifies the protocols are importable and runtime-checkable. The actual conformance tests come when we implement the SQLite/MongoDB repositories.
**Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_store/test_protocols.py -v`
Expected: FAIL - ImportError
**Step 3: Write the implementation**
```python
# src/fastapi_oidc_op/store/protocols.py
from typing import Protocol, runtime_checkable
from fastapi_oidc_op.models import (
MagicLink,
PasswordCredential,
User,
WebAuthnCredential,
)
@runtime_checkable
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: ...
@runtime_checkable
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: ...
@runtime_checkable
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: ...
```
**Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_store/test_protocols.py -v`
Expected: PASS
**Step 5: Run ruff and ty**
Run: `uv run ruff check src/fastapi_oidc_op/store/protocols.py && uv run ruff format --check src/fastapi_oidc_op/store/protocols.py && uv run ty check src/fastapi_oidc_op/store/protocols.py`
Expected: No errors
**Step 6: Commit**
```bash
git add src/fastapi_oidc_op/store/protocols.py tests/test_store/test_protocols.py
git commit -m "feat: add repository Protocol interfaces for User, Credential, MagicLink"
```
---
### Task 6: Implement userid generation utility
**Files:**
- Create: `src/fastapi_oidc_op/userid.py`
- Create: `tests/test_userid.py`
**Step 1: Write the failing tests**
```python
# tests/test_userid.py
import re
from fastapi_oidc_op.userid import generate_userid
def test_generate_userid_format() -> None:
userid = generate_userid()
# 32-bit proquint format: xxxxx-xxxxx
parts = userid.split("-")
assert len(parts) == 2
for part in parts:
assert len(part) == 5
def test_generate_userid_uniqueness() -> None:
ids = {generate_userid() for _ in range(100)}
assert len(ids) == 100 # All unique
def test_generate_userid_is_lowercase() -> None:
userid = generate_userid()
assert userid == userid.lower()
```
**Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_userid.py -v`
Expected: FAIL - ImportError
**Step 3: Write the implementation**
```python
# src/fastapi_oidc_op/userid.py
import secrets
from proquint import uint2quint
def generate_userid() -> str:
"""Generate a unique user identifier in proquint format.
Returns a 32-bit proquint string like 'lusab-bansen'.
"""
return uint2quint(secrets.randbits(32))
```
**Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_userid.py -v`
Expected: All 3 tests PASS
**Step 5: Run ruff and ty**
Run: `uv run ruff check src/fastapi_oidc_op/userid.py tests/test_userid.py && uv run ruff format --check src/fastapi_oidc_op/userid.py tests/test_userid.py && uv run ty check src/fastapi_oidc_op/userid.py`
Expected: No errors (ty may warn about proquint missing type stubs - suppress with `# type: ignore[import-untyped]` if needed)
**Step 6: Commit**
```bash
git add src/fastapi_oidc_op/userid.py tests/test_userid.py
git commit -m "feat: add proquint-based userid generation"
```
---
### Task 7: Implement app factory and health endpoint
**Files:**
- Create: `src/fastapi_oidc_op/app.py`
- Create: `tests/conftest.py`
- Create: `tests/test_app.py`
- Delete: `main.py` (replaced by app.py)
**Step 1: Write the failing tests**
```python
# tests/conftest.py
from collections.abc import AsyncIterator
import pytest
from httpx import ASGITransport, AsyncClient
from fastapi_oidc_op.app import create_app
from fastapi_oidc_op.config import Settings
@pytest.fixture
def settings() -> Settings:
return Settings(issuer="http://localhost:8000")
@pytest.fixture
async def client(settings: Settings) -> AsyncIterator[AsyncClient]:
app = create_app(settings)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url=settings.issuer) as ac:
yield ac
```
```python
# tests/test_app.py
from httpx import AsyncClient
async def test_health_endpoint(client: AsyncClient) -> None:
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
async def test_app_has_title(client: AsyncClient) -> None:
response = await client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert data["info"]["title"] == "FastAPI OIDC OP"
```
**Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_app.py -v`
Expected: FAIL - ImportError
**Step 3: Write the implementation**
```python
# src/fastapi_oidc_op/app.py
from fastapi import FastAPI
from fastapi_oidc_op.config import Settings
def create_app(settings: Settings | None = None) -> FastAPI:
if settings is None:
settings = Settings() # type: ignore[call-arg]
app = FastAPI(
title="FastAPI OIDC OP",
version="0.1.0",
docs_url="/docs" if settings.debug else None,
redoc_url=None,
)
app.state.settings = settings
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
return app
```
**Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_app.py -v`
Expected: All 2 tests PASS
**Step 5: Delete the old main.py**
Run: `rm main.py`
**Step 6: Run ruff and ty on all source files**
Run: `uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run ty check src/`
Expected: No errors
**Step 7: Commit**
```bash
git rm main.py
git add src/fastapi_oidc_op/app.py tests/conftest.py tests/test_app.py
git commit -m "feat: add app factory with health endpoint and test infrastructure"
```
---
### Task 8: Run full quality checks and format
**Files:**
- All files in `src/` and `tests/`
**Step 1: Format all code with ruff**
Run: `uv run ruff format src/ tests/`
Expected: Files formatted (or already formatted)
**Step 2: Run ruff lint with auto-fix**
Run: `uv run ruff check src/ tests/ --fix`
Expected: No errors (or safe fixes applied)
**Step 3: Run ty type check**
Run: `uv run ty check src/`
Expected: No errors
**Step 4: Run full test suite**
Run: `uv run pytest -v`
Expected: All tests pass
**Step 5: Commit any formatting changes**
```bash
git add -A
git diff --cached --quiet || git commit -m "style: apply ruff formatting"
```
---
### Task 9: Add pre-commit quality gate script
**Files:**
- Create: `scripts/check.sh`
**Step 1: Create the check script**
```bash
#!/usr/bin/env bash
# Run all quality checks
set -euo pipefail
echo "==> Formatting..."
uv run ruff format src/ tests/
echo "==> Linting..."
uv run ruff check src/ tests/ --fix
echo "==> Type checking..."
uv run ty check src/
echo "==> Testing..."
uv run pytest -v
echo "==> All checks passed!"
```
**Step 2: Make it executable**
Run: `chmod +x scripts/check.sh`
**Step 3: Run the script to verify it works**
Run: `./scripts/check.sh`
Expected: All checks pass
**Step 4: Commit**
```bash
git add scripts/check.sh
git commit -m "chore: add quality check script (ruff, ty, pytest)"
```
---
## Summary
After completing these 9 tasks, the project will have:
1. **pyproject.toml** - Dependencies, ruff config (broad ruleset), ty config (strict), pytest config
2. **Package structure** - Full src layout with all subpackages
3. **Configuration** - Pydantic Settings with env vars, storage backend selection
4. **Models** - User, WebAuthnCredential, PasswordCredential, MagicLink
5. **Repository protocols** - Type-safe interfaces for all data access
6. **Userid generation** - Proquint-based unique identifiers
7. **App factory** - FastAPI app with health endpoint
8. **Test infrastructure** - pytest + httpx async client fixtures
9. **Quality tooling** - ruff (lint + format), ty (type check), check script
The next plan will cover the SQLite repository implementation, followed by authentication, OIDC provider integration, and the management UI.