From 9820a77d255b0b1614f85f6b94684b0d7a176311 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Tue, 9 Jun 2026 09:17:41 -0400 Subject: [PATCH] Visibility phase 3: redaction-safe public read API + leak test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the anonymous read surface (/api/v1/public) — the privacy-critical core. - CurrentUserOrNone dependency: optional auth that never 401s (anonymous OK). - public_view_service: every projection passes through privacy.person_visibility. persons redacted (living → "Living person", hidden dropped); relationships only when both endpoints non-hidden; events only for FULL-visibility persons (partnership events only when both partners full); names only for FULL persons. Not-viewable trees raise 404 (not 403) so the surface can't probe for private trees. Media deferred (higher-sensitivity; own pass later). - public router: read-only directory + tree + persons/relationships/events + person detail/names/events. Directory lists `public` to all and adds `site_members` for authenticated callers; never lists unlisted/private. - PublicTreeRead omits owner_id. Tests (ran locally — CI does not run pytest): an anonymous end-to-end leak test asserting a living person's real name, alias, and birth year appear in NO public response while the deceased person's data does; plus private=404, unlisted viewable-by-link-but-unlisted, site_members requires login, and directory visibility. Full suite: 70 passed. Regenerated openapi.json + TS client. Note: the AUTHED list endpoints still leak per-person for non-members (pre-existing) — fixed next, separately. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/api/deps.py | 12 + backend/app/api/v1/__init__.py | 2 + backend/app/api/v1/public.py | 135 ++++++ backend/app/schemas/tree.py | 13 + backend/app/services/public_view_service.py | 256 +++++++++++ backend/tests/test_public_view.py | 180 ++++++++ frontend/lib/api/schema.d.ts | 408 +++++++++++++++++ frontend/openapi.json | 472 ++++++++++++++++++++ 8 files changed, 1478 insertions(+) create mode 100644 backend/app/api/v1/public.py create mode 100644 backend/app/services/public_view_service.py create mode 100644 backend/tests/test_public_view.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 087a2de..cf3b28d 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -40,6 +40,18 @@ async def get_current_user(request: Request, session: SessionDep) -> User: CurrentUser = Annotated[User, Depends(get_current_user)] +async def get_current_user_or_none(request: Request, session: SessionDep) -> User | None: + """Optional auth for public read endpoints — never raises. Returns the user + when a valid session is present, else None (anonymous viewer).""" + raw_token = extract_session_token(request) + if raw_token is None: + return None + return await auth_service.resolve_session_user(session, raw_token=raw_token) + + +CurrentUserOrNone = Annotated[User | None, Depends(get_current_user_or_none)] + + def get_mailer() -> Mailer: settings = get_settings() if settings.mailer == "smtp" and settings.smtp_host: diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index a6f5bcf..244f870 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -11,6 +11,7 @@ from app.api.v1 import ( media, names, persons, + public, relationships, sources, trees, @@ -30,3 +31,4 @@ api_router.include_router(citations.router) api_router.include_router(media.router) api_router.include_router(gedcom.router) api_router.include_router(cleanup.router) +api_router.include_router(public.router) diff --git a/backend/app/api/v1/public.py b/backend/app/api/v1/public.py new file mode 100644 index 0000000..60c5279 --- /dev/null +++ b/backend/app/api/v1/public.py @@ -0,0 +1,135 @@ +"""Public, read-only viewing surface. + +Optional auth (anonymous allowed). Every response is built by +``public_view_service``, which routes through the privacy engine and redacts +possibly-living people. No create/update/delete here. +""" + +import uuid + +from fastapi import APIRouter + +from app.api.deps import CurrentUserOrNone, SessionDep +from app.schemas.event import EventRead +from app.schemas.name import NameRead +from app.schemas.person import PersonRead +from app.schemas.relationship import RelationshipRead +from app.schemas.tree import PublicTreeRead +from app.services import public_view_service + +router = APIRouter(prefix="/public", tags=["public"]) + + +def _vid(viewer: CurrentUserOrNone) -> uuid.UUID | None: + return viewer.id if viewer else None + + +@router.get("/trees", response_model=list[PublicTreeRead]) +async def public_directory( + session: SessionDep, + viewer: CurrentUserOrNone, + q: str | None = None, + limit: int = 50, + offset: int = 0, +) -> list[PublicTreeRead]: + trees = await public_view_service.list_public_trees( + session, viewer_id=_vid(viewer), q=q, limit=limit, offset=offset + ) + return [PublicTreeRead.model_validate(t) for t in trees] + + +@router.get("/trees/{tree_id}", response_model=PublicTreeRead) +async def public_tree( + tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone +) -> PublicTreeRead: + tree = await public_view_service.get_public_tree( + session, viewer_id=_vid(viewer), tree_id=tree_id + ) + return PublicTreeRead.model_validate(tree) + + +@router.get("/trees/{tree_id}/persons", response_model=list[PersonRead]) +async def public_persons( + tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone +) -> list[PersonRead]: + tree = await public_view_service.get_public_tree( + session, viewer_id=_vid(viewer), tree_id=tree_id + ) + persons = await public_view_service.list_public_persons( + session, viewer_id=_vid(viewer), tree=tree + ) + return [PersonRead.model_validate(p) for p in persons] + + +@router.get("/trees/{tree_id}/relationships", response_model=list[RelationshipRead]) +async def public_relationships( + tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone +) -> list[RelationshipRead]: + tree = await public_view_service.get_public_tree( + session, viewer_id=_vid(viewer), tree_id=tree_id + ) + rels = await public_view_service.list_public_relationships( + session, viewer_id=_vid(viewer), tree=tree + ) + return [RelationshipRead.model_validate(r) for r in rels] + + +@router.get("/trees/{tree_id}/events", response_model=list[EventRead]) +async def public_events( + tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone +) -> list[EventRead]: + tree = await public_view_service.get_public_tree( + session, viewer_id=_vid(viewer), tree_id=tree_id + ) + events = await public_view_service.list_public_events( + session, viewer_id=_vid(viewer), tree=tree + ) + return [EventRead.model_validate(e) for e in events] + + +@router.get("/trees/{tree_id}/persons/{person_id}", response_model=PersonRead) +async def public_person( + tree_id: uuid.UUID, + person_id: uuid.UUID, + session: SessionDep, + viewer: CurrentUserOrNone, +) -> PersonRead: + tree = await public_view_service.get_public_tree( + session, viewer_id=_vid(viewer), tree_id=tree_id + ) + person = await public_view_service.get_public_person( + session, viewer_id=_vid(viewer), tree=tree, person_id=person_id + ) + return PersonRead.model_validate(person) + + +@router.get("/trees/{tree_id}/persons/{person_id}/names", response_model=list[NameRead]) +async def public_person_names( + tree_id: uuid.UUID, + person_id: uuid.UUID, + session: SessionDep, + viewer: CurrentUserOrNone, +) -> list[NameRead]: + tree = await public_view_service.get_public_tree( + session, viewer_id=_vid(viewer), tree_id=tree_id + ) + names = await public_view_service.list_public_person_names( + session, viewer_id=_vid(viewer), tree=tree, person_id=person_id + ) + return [NameRead.model_validate(n) for n in names] + + +@router.get("/trees/{tree_id}/persons/{person_id}/events", response_model=list[EventRead]) +async def public_person_events( + tree_id: uuid.UUID, + person_id: uuid.UUID, + session: SessionDep, + viewer: CurrentUserOrNone, +) -> list[EventRead]: + tree = await public_view_service.get_public_tree( + session, viewer_id=_vid(viewer), tree_id=tree_id + ) + events = await public_view_service.list_public_person_events( + session, viewer_id=_vid(viewer), tree=tree, person_id=person_id + ) + return [EventRead.model_validate(e) for e in events] diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index 381b160..1eacbf1 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -29,3 +29,16 @@ class TreeRead(BaseModel): owner_id: uuid.UUID home_person_id: uuid.UUID | None = None created_at: datetime + + +class PublicTreeRead(BaseModel): + """Tree projection for the public surface — deliberately omits owner_id so a + public/unlisted tree doesn't reveal which account owns it.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + description: str | None + visibility: TreeVisibility + home_person_id: uuid.UUID | None = None diff --git a/backend/app/services/public_view_service.py b/backend/app/services/public_view_service.py new file mode 100644 index 0000000..859d57b --- /dev/null +++ b/backend/app/services/public_view_service.py @@ -0,0 +1,256 @@ +"""Read-only, redaction-safe projections for the public viewing surface. + +INVARIANT (CLAUDE.md #2): everything returned here has passed through +``privacy.person_visibility``. A non-member must never receive a possibly-living +person's real name, dates, alternate names, or media. The rules: + + - persons : redacted (living → "Living person"); hidden dropped. + - relationships : only when BOTH endpoints are non-hidden (a link to a + redacted person is fine — the name is already hidden). + - events : only for FULL-visibility persons; partnership events only + when BOTH partners are full (a marriage date would leak a + living partner's timeline otherwise). + - names : only for FULL-visibility persons. + - media : NOT exposed yet (deferred — see docs/design/tree-visibility.md). + +A tree that isn't viewable raises NotFound (never Forbidden) so the public +surface can't be used to probe whether a private tree exists. +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import TreeVisibility +from app.models.event import Event +from app.models.person import Name, Person +from app.models.relationship import Relationship +from app.models.tree import Tree +from app.services import privacy +from app.services.exceptions import NotFound +from app.services.person_service import _attach_primary_name, _redact +from app.services.privacy import Visibility + + +async def get_public_tree( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree_id: uuid.UUID +) -> Tree: + tree = ( + await session.execute( + select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None)) + ) + ).scalar_one_or_none() + # 404 (not 403) when not viewable: don't reveal that a private tree exists. + if tree is None or not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise NotFound("tree not found") + return tree + + +async def _persons(session: AsyncSession, tree: Tree) -> list[Person]: + return list( + ( + await session.execute( + select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None)) + ) + ) + .scalars() + .all() + ) + + +async def _visibility_map( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, persons: list[Person] +) -> dict[uuid.UUID, Visibility]: + return { + p.id: await privacy.person_visibility( + session, user_id=viewer_id, tree=tree, person=p + ) + for p in persons + } + + +async def list_public_persons( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree +) -> list[Person]: + out: list[Person] = [] + for p in await _persons(session, tree): + vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=p) + if vis == Visibility.hidden: + continue + if vis == Visibility.redacted: + _redact(p) + else: + await _attach_primary_name(session, p) + out.append(p) + return out + + +async def get_public_person( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID +) -> Person: + person = ( + await session.execute( + select(Person).where( + Person.id == person_id, + Person.tree_id == tree.id, + Person.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + if person is None: + raise NotFound("person not found") + vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person) + if vis == Visibility.hidden: + raise NotFound("person not found") + if vis == Visibility.redacted: + _redact(person) + else: + await _attach_primary_name(session, person) + return person + + +async def _person_visibility( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID +) -> Visibility | None: + person = ( + await session.execute( + select(Person).where( + Person.id == person_id, + Person.tree_id == tree.id, + Person.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + if person is None: + return None + return await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person) + + +async def list_public_relationships( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree +) -> list[Relationship]: + persons = await _persons(session, tree) + vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons) + nonhidden = {pid for pid, v in vis.items() if v != Visibility.hidden} + rels = list( + ( + await session.execute( + select(Relationship).where( + Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None) + ) + ) + ) + .scalars() + .all() + ) + return [ + r for r in rels if r.person_from_id in nonhidden and r.person_to_id in nonhidden + ] + + +async def list_public_events( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree +) -> list[Event]: + persons = await _persons(session, tree) + vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons) + full = {pid for pid, v in vis.items() if v == Visibility.full} + rels = { + r.id: r + for r in ( + await session.execute( + select(Relationship).where( + Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None) + ) + ) + ) + .scalars() + .all() + } + events = list( + ( + await session.execute( + select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None)) + ) + ) + .scalars() + .all() + ) + out: list[Event] = [] + for e in events: + if e.person_id is not None: + if e.person_id in full: + out.append(e) + elif e.relationship_id is not None: + r = rels.get(e.relationship_id) + if r is not None and r.person_from_id in full and r.person_to_id in full: + out.append(e) + return out + + +async def list_public_person_names( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID +) -> list[Name]: + vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id) + if vis is None: + raise NotFound("person not found") + if vis != Visibility.full: + return [] # redacted/hidden → no names (the real name must not leak) + return list( + ( + await session.execute( + select(Name) + .where(Name.person_id == person_id, Name.deleted_at.is_(None)) + .order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at) + ) + ) + .scalars() + .all() + ) + + +async def list_public_person_events( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID +) -> list[Event]: + vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id) + if vis is None: + raise NotFound("person not found") + if vis != Visibility.full: + return [] # redacted/hidden → no dates + return list( + ( + await session.execute( + select(Event) + .where( + Event.person_id == person_id, + Event.tree_id == tree.id, + Event.deleted_at.is_(None), + ) + .order_by(Event.date_start.nulls_last(), Event.created_at) + ) + ) + .scalars() + .all() + ) + + +async def list_public_trees( + session: AsyncSession, + *, + viewer_id: uuid.UUID | None, + q: str | None = None, + limit: int = 50, + offset: int = 0, +) -> list[Tree]: + # Anonymous: only `public`. Authenticated: also `site_members`. Never list + # `unlisted` (reachable by link only) or `private`. + allowed = [TreeVisibility.public] + if viewer_id is not None: + allowed.append(TreeVisibility.site_members) + stmt = select(Tree).where( + Tree.deleted_at.is_(None), Tree.visibility.in_(allowed) + ) + if q and q.strip(): + stmt = stmt.where(Tree.name.ilike(f"%{q.strip()}%")) + stmt = stmt.order_by(Tree.name).limit(min(limit, 100)).offset(max(offset, 0)) + return list((await session.execute(stmt)).scalars().all()) diff --git a/backend/tests/test_public_view.py b/backend/tests/test_public_view.py new file mode 100644 index 0000000..525c616 --- /dev/null +++ b/backend/tests/test_public_view.py @@ -0,0 +1,180 @@ +"""The public viewing surface (/api/v1/public). + +The central guarantee: an ANONYMOUS viewer of a public tree never receives a +possibly-living person's real name, dates, or alternate names — while deceased +people are shown in full. Plus the access matrix for each visibility level. +See docs/design/tree-visibility.md. +""" + +from tests.conftest import auth, register + +# Distinctive strings so we can assert they never leak anywhere anonymously. +LIVING_GIVEN = "Younglivingsecret" +LIVING_SURNAME = "Hiddensurname" +LIVING_ALIAS = "Secretmaidenalias" +LIVING_BIRTH_YEAR = "2002" + + +async def _person(client, tid, headers, given, surname, is_living): + r = await client.post( + f"/api/v1/trees/{tid}/persons", + json={"given": given, "surname": surname, "is_living": is_living}, + headers=headers, + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + +async def _build_public_tree(client): + owner = auth(await register(client, "pv-owner@ex.com")) + tid = ( + await client.post( + "/api/v1/trees", json={"name": "Heritage", "visibility": "public"}, headers=owner + ) + ).json()["id"] + + old = await _person(client, tid, owner, "Olda", "Ancestor", False) + young = await _person(client, tid, owner, LIVING_GIVEN, LIVING_SURNAME, True) + + # Birth events for each. + await client.post( + f"/api/v1/trees/{tid}/events", + json={"event_type": "birth", "person_id": old, "date_value": "1850"}, + headers=owner, + ) + await client.post( + f"/api/v1/trees/{tid}/events", + json={"event_type": "birth", "person_id": young, "date_value": LIVING_BIRTH_YEAR}, + headers=owner, + ) + # Alternate names for each. + await client.post( + f"/api/v1/trees/{tid}/persons/{old}/names", + json={"name_type": "alias", "given": "Oldnickname"}, + headers=owner, + ) + await client.post( + f"/api/v1/trees/{tid}/persons/{young}/names", + json={"name_type": "alias", "given": LIVING_ALIAS}, + headers=owner, + ) + # old --parent--> young + await client.post( + f"/api/v1/trees/{tid}/relationships", + json={ + "type": "parent_child", + "person_from_id": old, + "person_to_id": young, + "qualifier": "biological", + }, + headers=owner, + ) + return tid, old, young + + +async def test_anonymous_public_view_never_leaks_living_pii(client): + tid, old, young = await _build_public_tree(client) + + # --- persons: deceased full, living redacted --- + persons = (await client.get(f"/api/v1/public/trees/{tid}/persons")).json() + by_id = {p["id"]: p for p in persons} + assert by_id[old]["primary_name"] == "Olda Ancestor" + assert by_id[young]["primary_name"] == "Living person" + assert by_id[young]["gender"] is None + + # --- the living person's real name/alias/birth year must appear NOWHERE --- + for path in ( + f"/api/v1/public/trees/{tid}/persons", + f"/api/v1/public/trees/{tid}/events", + f"/api/v1/public/trees/{tid}/relationships", + f"/api/v1/public/trees/{tid}/persons/{young}", + f"/api/v1/public/trees/{tid}/persons/{young}/names", + f"/api/v1/public/trees/{tid}/persons/{young}/events", + ): + body = (await client.get(path)).text + assert LIVING_GIVEN not in body, path + assert LIVING_SURNAME not in body, path + assert LIVING_ALIAS not in body, path + assert LIVING_BIRTH_YEAR not in body, path + + # --- events: deceased's date present, living's dropped entirely --- + events = (await client.get(f"/api/v1/public/trees/{tid}/events")).json() + assert any(e["person_id"] == old for e in events) + assert not any(e["person_id"] == young for e in events) + + # --- per-person endpoints for the living person are emptied/redacted --- + assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/names")).json() == [] + assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/events")).json() == [] + assert ( + await client.get(f"/api/v1/public/trees/{tid}/persons/{young}") + ).json()["primary_name"] == "Living person" + + # --- deceased person's names/events ARE exposed --- + old_names = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/names")).json() + assert any(n.get("given") == "Oldnickname" for n in old_names) + old_events = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/events")).json() + assert any(e["date_value"] == "1850" for e in old_events) + + # --- relationship kept (links to the redacted person by id, no PII) --- + rels = (await client.get(f"/api/v1/public/trees/{tid}/relationships")).json() + assert any(r2["person_from_id"] == old and r2["person_to_id"] == young for r2 in rels) + + +async def test_private_tree_is_404_anonymously(client): + owner = auth(await register(client, "priv-owner@ex.com")) + tid = ( + await client.post( + "/api/v1/trees", json={"name": "Secret", "visibility": "private"}, headers=owner + ) + ).json()["id"] + assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404 + assert (await client.get(f"/api/v1/public/trees/{tid}/persons")).status_code == 404 + + +async def test_unlisted_viewable_by_link_but_not_in_directory(client): + owner = auth(await register(client, "unl-owner@ex.com")) + tid = ( + await client.post( + "/api/v1/trees", json={"name": "ByLinkOnly", "visibility": "unlisted"}, headers=owner + ) + ).json()["id"] + # Direct link works anonymously... + assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 200 + # ...but it is never listed in the directory. + directory = (await client.get("/api/v1/public/trees")).json() + assert all(t["id"] != tid for t in directory) + + +async def test_site_members_requires_login(client): + owner = auth(await register(client, "sm2-owner@ex.com")) + stranger = auth(await register(client, "sm2-stranger@ex.com")) + tid = ( + await client.post( + "/api/v1/trees", json={"name": "MembersOnly", "visibility": "site_members"}, headers=owner + ) + ).json()["id"] + assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404 # anonymous + assert (await client.get(f"/api/v1/public/trees/{tid}", headers=stranger)).status_code == 200 + + +async def test_directory_visibility(client): + owner = auth(await register(client, "dir-owner@ex.com")) + stranger = auth(await register(client, "dir-stranger@ex.com")) + ids = {} + for vis in ("public", "site_members", "unlisted", "private"): + ids[vis] = ( + await client.post( + "/api/v1/trees", json={"name": f"dir-{vis}", "visibility": vis}, headers=owner + ) + ).json()["id"] + + anon = {t["id"] for t in (await client.get("/api/v1/public/trees")).json()} + assert ids["public"] in anon + for vis in ("site_members", "unlisted", "private"): + assert ids[vis] not in anon + + logged_in = {t["id"] for t in (await client.get("/api/v1/public/trees", headers=stranger)).json()} + assert ids["public"] in logged_in + assert ids["site_members"] in logged_in + assert ids["unlisted"] not in logged_in + assert ids["private"] not in logged_in diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index 50b2aff..a126380 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -769,6 +769,142 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/public/trees": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public Directory */ + get: operations["public_directory_api_v1_public_trees_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/public/trees/{tree_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public Tree */ + get: operations["public_tree_api_v1_public_trees__tree_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/public/trees/{tree_id}/persons": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public Persons */ + get: operations["public_persons_api_v1_public_trees__tree_id__persons_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/public/trees/{tree_id}/relationships": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public Relationships */ + get: operations["public_relationships_api_v1_public_trees__tree_id__relationships_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/public/trees/{tree_id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public Events */ + get: operations["public_events_api_v1_public_trees__tree_id__events_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/public/trees/{tree_id}/persons/{person_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public Person */ + get: operations["public_person_api_v1_public_trees__tree_id__persons__person_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/public/trees/{tree_id}/persons/{person_id}/names": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public Person Names */ + get: operations["public_person_names_api_v1_public_trees__tree_id__persons__person_id__names_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/public/trees/{tree_id}/persons/{person_id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public Person Events */ + get: operations["public_person_events_api_v1_public_trees__tree_id__persons__person_id__events_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1329,6 +1465,25 @@ export interface components { /** Notes */ notes?: string | null; }; + /** + * PublicTreeRead + * @description Tree projection for the public surface — deliberately omits owner_id so a + * public/unlisted tree doesn't reveal which account owns it. + */ + PublicTreeRead: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** Description */ + description: string | null; + visibility: components["schemas"]["TreeVisibility"]; + /** Home Person Id */ + home_person_id?: string | null; + }; /** RegisterRequest */ RegisterRequest: { /** Email */ @@ -3633,4 +3788,257 @@ export interface operations { }; }; }; + public_directory_api_v1_public_trees_get: { + parameters: { + query?: { + q?: string | null; + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicTreeRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + public_tree_api_v1_public_trees__tree_id__get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicTreeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + public_persons_api_v1_public_trees__tree_id__persons_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + public_relationships_api_v1_public_trees__tree_id__relationships_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RelationshipRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + public_events_api_v1_public_trees__tree_id__events_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + public_person_api_v1_public_trees__tree_id__persons__person_id__get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + public_person_names_api_v1_public_trees__tree_id__persons__person_id__names_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NameRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + public_person_events_api_v1_public_trees__tree_id__persons__person_id__events_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } diff --git a/frontend/openapi.json b/frontend/openapi.json index 1e4af0f..e6be1a3 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -3065,6 +3065,430 @@ } } } + }, + "/api/v1/public/trees": { + "get": { + "tags": [ + "public" + ], + "summary": "Public Directory", + "operationId": "public_directory_api_v1_public_trees_get", + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Q" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "title": "Limit" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0, + "title": "Offset" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicTreeRead" + }, + "title": "Response Public Directory Api V1 Public Trees Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/public/trees/{tree_id}": { + "get": { + "tags": [ + "public" + ], + "summary": "Public Tree", + "operationId": "public_tree_api_v1_public_trees__tree_id__get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicTreeRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/public/trees/{tree_id}/persons": { + "get": { + "tags": [ + "public" + ], + "summary": "Public Persons", + "operationId": "public_persons_api_v1_public_trees__tree_id__persons_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonRead" + }, + "title": "Response Public Persons Api V1 Public Trees Tree Id Persons Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/public/trees/{tree_id}/relationships": { + "get": { + "tags": [ + "public" + ], + "summary": "Public Relationships", + "operationId": "public_relationships_api_v1_public_trees__tree_id__relationships_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RelationshipRead" + }, + "title": "Response Public Relationships Api V1 Public Trees Tree Id Relationships Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/public/trees/{tree_id}/events": { + "get": { + "tags": [ + "public" + ], + "summary": "Public Events", + "operationId": "public_events_api_v1_public_trees__tree_id__events_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventRead" + }, + "title": "Response Public Events Api V1 Public Trees Tree Id Events Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/public/trees/{tree_id}/persons/{person_id}": { + "get": { + "tags": [ + "public" + ], + "summary": "Public Person", + "operationId": "public_person_api_v1_public_trees__tree_id__persons__person_id__get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "person_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Person Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/public/trees/{tree_id}/persons/{person_id}/names": { + "get": { + "tags": [ + "public" + ], + "summary": "Public Person Names", + "operationId": "public_person_names_api_v1_public_trees__tree_id__persons__person_id__names_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "person_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Person Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRead" + }, + "title": "Response Public Person Names Api V1 Public Trees Tree Id Persons Person Id Names Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/public/trees/{tree_id}/persons/{person_id}/events": { + "get": { + "tags": [ + "public" + ], + "summary": "Public Person Events", + "operationId": "public_person_events_api_v1_public_trees__tree_id__persons__person_id__events_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "person_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Person Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventRead" + }, + "title": "Response Public Person Events Api V1 Public Trees Tree Id Persons Person Id Events Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -4900,6 +5324,54 @@ "type": "object", "title": "PersonUpdate" }, + "PublicTreeRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "visibility": { + "$ref": "#/components/schemas/TreeVisibility" + }, + "home_person_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Home Person Id" + } + }, + "type": "object", + "required": [ + "id", + "name", + "description", + "visibility" + ], + "title": "PublicTreeRead", + "description": "Tree projection for the public surface \u2014 deliberately omits owner_id so a\npublic/unlisted tree doesn't reveal which account owns it." + }, "RegisterRequest": { "properties": { "email": { -- 2.52.0