Phase 0 — Foundation: backend, data model, local auth, frontend, deploy + CI #1

Merged
justin merged 17 commits from phase-0-foundation into main 2026-06-06 11:32:31 -04:00
20 changed files with 640 additions and 13 deletions
Showing only changes of commit dffd05d303 - Show all commits
+33
View File
@@ -0,0 +1,33 @@
"""Shared API dependencies."""
import uuid
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_session
from app.models.user import User
from app.services.user_service import get_user
SessionDep = Annotated[AsyncSession, Depends(get_session)]
async def get_current_user(
session: SessionDep,
x_user_id: Annotated[uuid.UUID | None, Header()] = None,
) -> User:
"""TEMPORARY pre-auth shim: identifies the caller via the ``X-User-Id``
header. Replaced by the AuthProvider (sessions/tokens) in the auth slice.
The assistant principal will also be minted here, scoped to its user."""
if x_user_id is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "X-User-Id header required (pre-auth)"
)
user = await get_user(session, x_user_id)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unknown user")
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
+10
View File
@@ -0,0 +1,10 @@
"""Versioned API surface. Mounts under /api/v1."""
from fastapi import APIRouter
from app.api.v1 import persons, trees, users
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(users.router)
api_router.include_router(trees.router)
api_router.include_router(persons.router)
+43
View File
@@ -0,0 +1,43 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead
from app.services import person_service, tree_service
# Persons are nested under their tree (the tenant boundary).
router = APIRouter(prefix="/trees", tags=["persons"])
@router.post(
"/{tree_id}/persons",
response_model=PersonRead,
status_code=status.HTTP_201_CREATED,
)
async def create_person(
tree_id: uuid.UUID, data: PersonCreate, session: SessionDep, current: CurrentUser
) -> PersonRead:
# get_tree enforces existence + view access; create_person enforces edit rights.
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.create_person(
session,
actor=current,
tree=tree,
given=data.given,
surname=data.surname,
gender=data.gender,
is_living=data.is_living,
privacy_setting=data.privacy,
notes=data.notes,
)
return PersonRead.model_validate(person)
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
async def list_persons(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[PersonRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
return [PersonRead.model_validate(p) for p in persons]
+33
View File
@@ -0,0 +1,33 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.tree import TreeCreate, TreeRead
from app.services import tree_service
router = APIRouter(prefix="/trees", tags=["trees"])
@router.post("", response_model=TreeRead, status_code=status.HTTP_201_CREATED)
async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.create_tree(
session,
owner=current,
name=data.name,
description=data.description,
visibility=data.visibility,
)
return TreeRead.model_validate(tree)
@router.get("", response_model=list[TreeRead])
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]:
trees = await tree_service.list_trees_for_user(session, user=current)
return [TreeRead.model_validate(t) for t in trees]
@router.get("/{tree_id}", response_model=TreeRead)
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
return TreeRead.model_validate(tree)
+21
View File
@@ -0,0 +1,21 @@
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.user import UserCreate, UserRead
from app.services import user_service
router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(data: UserCreate, session: SessionDep) -> UserRead:
# Open dev bootstrap until the auth slice; lets us create tree owners.
user = await user_service.create_user(
session, email=data.email, display_name=data.display_name
)
return UserRead.model_validate(user)
@router.get("/me", response_model=UserRead)
async def read_me(current: CurrentUser) -> UserRead:
return UserRead.model_validate(current)
+30 -9
View File
@@ -1,22 +1,43 @@
"""Async database engine.
"""Async database engine, session factory, and the FastAPI session dependency.
A single lazily-created async engine for the process. The repository layer
(coming with the data model) will build sessions on top of this; for now it
backs the readiness probe.
The repository layer builds on ``get_session``; ``get_engine`` also backs the
readiness probe. Everything is lazy so importing the app never opens a
connection (important for tests and for ``--help``-style invocations).
"""
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.core.config import get_settings
_engine: AsyncEngine | None = None
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
def get_engine() -> AsyncEngine:
global _engine
if _engine is None:
_engine = create_async_engine(
get_settings().database_url,
pool_pre_ping=True,
)
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
return _engine
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
global _sessionmaker
if _sessionmaker is None:
_sessionmaker = async_sessionmaker(
get_engine(), expire_on_commit=False, class_=AsyncSession
)
return _sessionmaker
async def get_session() -> AsyncIterator[AsyncSession]:
"""FastAPI dependency. One session per request; commits are explicit in the
service layer."""
async with get_sessionmaker()() as session:
yield session
+23 -4
View File
@@ -1,14 +1,31 @@
"""FastAPI application entrypoint.
Thin by design: wire settings and routers, expose the OpenAPI contract. All
domain logic lives in the service layer (added with the data model). The
versioned API will mount under ``/api/v1``; health probes stay at the root.
Thin by design: wire settings, routers, and error handling, and expose the
OpenAPI contract. All domain logic lives in the service layer; the privacy
engine is the single enforcement point for reads.
"""
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api.health import router as health_router
from app.api.v1 import api_router
from app.core.config import get_settings
from app.services.exceptions import Conflict, Forbidden, NotFound
def _register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(NotFound)
async def _not_found(request: Request, exc: NotFound) -> JSONResponse:
return JSONResponse(status_code=404, content={"detail": str(exc) or "not found"})
@app.exception_handler(Forbidden)
async def _forbidden(request: Request, exc: Forbidden) -> JSONResponse:
return JSONResponse(status_code=403, content={"detail": str(exc) or "forbidden"})
@app.exception_handler(Conflict)
async def _conflict(request: Request, exc: Conflict) -> JSONResponse:
return JSONResponse(status_code=409, content={"detail": str(exc) or "conflict"})
def create_app() -> FastAPI:
@@ -19,6 +36,8 @@ def create_app() -> FastAPI:
description="Provenance API — family and land provenance.",
)
app.include_router(health_router)
app.include_router(api_router)
_register_error_handlers(app)
return app
+40
View File
@@ -0,0 +1,40 @@
"""Thin data-access layer over SQLAlchemy. No business rules live here — the
service layer owns those (and the privacy engine). The repository only knows how
to fetch and stage rows, transparently excluding soft-deleted ones.
"""
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
class BaseRepository:
def __init__(self, session: AsyncSession, model: type) -> None:
self.session = session
self.model = model
def _exclude_deleted(self, stmt):
if hasattr(self.model, "deleted_at"):
stmt = stmt.where(self.model.deleted_at.is_(None))
return stmt
async def get(self, id_: Any, *, include_deleted: bool = False):
stmt = select(self.model).where(self.model.id == id_)
if not include_deleted:
stmt = self._exclude_deleted(stmt)
return (await self.session.execute(stmt)).scalar_one_or_none()
async def list(self, *conditions, include_deleted: bool = False, order_by=None):
stmt = select(self.model)
for condition in conditions:
stmt = stmt.where(condition)
if not include_deleted:
stmt = self._exclude_deleted(stmt)
if order_by is not None:
stmt = stmt.order_by(order_by)
return list((await self.session.execute(stmt)).scalars().all())
def add(self, obj):
self.session.add(obj)
return obj
View File
+27
View File
@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import PersonPrivacy
class PersonCreate(BaseModel):
given: str | None = None
surname: str | None = None
gender: str | None = None
is_living: bool | None = None
privacy: PersonPrivacy = PersonPrivacy.inherit
notes: str | None = None
class PersonRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
primary_name: str | None = None
gender: str | None
is_living: bool | None
privacy: PersonPrivacy
created_at: datetime
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import TreeVisibility
class TreeCreate(BaseModel):
name: str
description: str | None = None
visibility: TreeVisibility = TreeVisibility.private
class TreeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
visibility: TreeVisibility
owner_id: uuid.UUID
created_at: datetime
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
# Note: email is a plain str for now (no email-validator dependency yet); the
# auth slice can tighten this to EmailStr.
class UserCreate(BaseModel):
email: str
display_name: str | None = None
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
display_name: str | None
email_verified_at: datetime | None
created_at: datetime
View File
+37
View File
@@ -0,0 +1,37 @@
"""Audit logging. Every mutation records an append-only AuditEntry attributing
the change to a User (or the assistant principal acting for a User). Staged on
the session; the caller commits as part of its unit of work.
"""
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditEntry
from app.models.enums import AuditActorType
def record_audit(
session: AsyncSession,
*,
action: str,
entity_type: str,
entity_id: uuid.UUID | None = None,
tree_id: uuid.UUID | None = None,
actor_user_id: uuid.UUID | None = None,
actor_type: AuditActorType = AuditActorType.user,
before: dict | None = None,
after: dict | None = None,
) -> AuditEntry:
entry = AuditEntry(
action=action,
entity_type=entity_type,
entity_id=entity_id,
tree_id=tree_id,
actor_user_id=actor_user_id,
actor_type=actor_type,
before=before,
after=after,
)
session.add(entry)
return entry
+18
View File
@@ -0,0 +1,18 @@
"""Domain errors. The API layer maps these to HTTP status codes so services
stay transport-agnostic."""
class DomainError(Exception):
"""Base for domain-level errors."""
class NotFound(DomainError):
"""Requested entity does not exist (or is soft-deleted / not visible)."""
class Forbidden(DomainError):
"""Caller lacks the required role for this action."""
class Conflict(DomainError):
"""Operation conflicts with current state (e.g. duplicate email)."""
+113
View File
@@ -0,0 +1,113 @@
"""Person service. Writes require editor rights on the tree; reads run every
person through the privacy engine. Each returned Person gets a transient
``primary_name`` for display (not persisted).
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import PersonPrivacy
from app.models.person import Name, Person
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden
from app.services.privacy import Visibility
def _format_name(name: Name) -> str | None:
parts = [name.given, name.surname]
joined = " ".join(p for p in parts if p)
return joined or name.display_name
async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
stmt = (
select(Name)
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
name = (await session.execute(stmt)).scalars().first()
# Transient display attribute consumed by the PersonRead schema.
person.primary_name = _format_name(name) if name is not None else None
async def create_person(
session: AsyncSession,
*,
actor: User,
tree: Tree,
given: str | None = None,
surname: str | None = None,
gender: str | None = None,
is_living: bool | None = None,
privacy_setting: PersonPrivacy = PersonPrivacy.inherit,
notes: str | None = None,
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = Person(
tree_id=tree.id,
gender=gender,
is_living=is_living,
privacy=privacy_setting,
notes=notes,
)
session.add(person)
await session.flush() # assign person.id
if given or surname:
session.add(
Name(
tree_id=tree.id,
person_id=person.id,
name_type="birth",
given=given,
surname=surname,
is_primary=True,
)
)
record_audit(
session,
action="create",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"given": given, "surname": surname},
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def list_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Person)
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
.order_by(Person.created_at)
)
persons = list((await session.execute(stmt)).scalars().all())
visible: list[Person] = []
for person in persons:
if (
await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person
)
== Visibility.hidden
):
continue
await _attach_primary_name(session, person)
visible.append(person)
return visible
+62
View File
@@ -0,0 +1,62 @@
"""The privacy engine — the single enforcement point for visibility.
INVARIANT (CLAUDE.md #2): every read path resolves visibility here. Do not add a
query path that returns rows to a caller without first passing through this
module. Effective visibility is a function of the viewer's role on the tree, the
tree's visibility, the per-person override, and (Phase 2) living-person status.
"""
import enum
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
from app.models.person import Person
from app.models.tree import Tree, TreeMembership
class Visibility(enum.StrEnum):
full = "full"
redacted = "redacted"
hidden = "hidden"
async def get_membership_role(
session: AsyncSession, user_id: uuid.UUID | None, tree_id: uuid.UUID
) -> MembershipRole | None:
if user_id is None:
return None
stmt = select(TreeMembership.role).where(
TreeMembership.tree_id == tree_id,
TreeMembership.user_id == user_id,
)
return (await session.execute(stmt)).scalar_one_or_none()
async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
if tree.deleted_at is not None:
return False
if await get_membership_role(session, user_id, tree.id) is not None:
return True
return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted)
async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
role = await get_membership_role(session, user_id, tree.id)
return role in (MembershipRole.owner, MembershipRole.editor)
async def person_visibility(
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
) -> Visibility:
if not await can_view_tree(session, user_id=user_id, tree=tree):
return Visibility.hidden
if await get_membership_role(session, user_id, tree.id) is not None:
return Visibility.full
# Non-member viewing a public/unlisted tree:
if person.privacy == PersonPrivacy.private:
return Visibility.hidden
# TODO(Phase 2): redact living people for non-members (ARCHITECTURE §6).
return Visibility.full
+61
View File
@@ -0,0 +1,61 @@
"""Tree service. Creating a tree also creates the owner's TreeMembership (the
authorization basis) and an audit entry. Reads go through the privacy engine.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import MembershipRole, TreeVisibility
from app.models.tree import Tree, TreeMembership
from app.models.user import User
from app.repositories.base import BaseRepository
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
async def create_tree(
session: AsyncSession,
*,
owner: User,
name: str,
description: str | None = None,
visibility: TreeVisibility = TreeVisibility.private,
) -> Tree:
tree = Tree(owner_id=owner.id, name=name, description=description, visibility=visibility)
session.add(tree)
await session.flush() # assign tree.id
session.add(TreeMembership(tree_id=tree.id, user_id=owner.id, role=MembershipRole.owner))
record_audit(
session,
action="create",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=owner.id,
after={"name": name, "visibility": visibility.value},
)
await session.commit()
await session.refresh(tree)
return tree
async def list_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
stmt = (
select(Tree)
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_(None))
.order_by(Tree.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid.UUID) -> Tree:
tree = await BaseRepository(session, Tree).get(tree_id)
if tree is None:
raise NotFound("tree not found")
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
return tree
+44
View File
@@ -0,0 +1,44 @@
"""User service. Account creation here is a temporary, open dev bootstrap so we
can create tree owners before the auth slice exists; the auth slice replaces it
with the AuthProvider (password/OIDC/social) and proper verification.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.repositories.base import BaseRepository
from app.services.audit import record_audit
from app.services.exceptions import Conflict
async def create_user(
session: AsyncSession, *, email: str, display_name: str | None = None
) -> User:
email = email.strip().lower()
existing = (
await session.execute(select(User).where(User.email == email))
).scalar_one_or_none()
if existing is not None:
raise Conflict("email already registered")
user = User(email=email, display_name=display_name)
session.add(user)
await session.flush() # assign user.id
record_audit(
session,
action="create",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
after={"email": email},
)
await session.commit()
await session.refresh(user)
return user
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
return await BaseRepository(session, User).get(user_id)