Visibility phase 3: redaction-safe public read API + leak test

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) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-09 09:17:41 -04:00
parent 3ff03b037b
commit 9820a77d25
8 changed files with 1478 additions and 0 deletions
+12
View File
@@ -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:
+2
View File
@@ -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)
+135
View File
@@ -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]
+13
View File
@@ -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
+256
View File
@@ -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())
+180
View File
@@ -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
+408
View File
@@ -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<string, never>;
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"];
};
};
};
};
}
+472
View File
@@ -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": {