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:
@@ -40,6 +40,18 @@ async def get_current_user(request: Request, session: SessionDep) -> User:
|
|||||||
CurrentUser = Annotated[User, Depends(get_current_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:
|
def get_mailer() -> Mailer:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.mailer == "smtp" and settings.smtp_host:
|
if settings.mailer == "smtp" and settings.smtp_host:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.api.v1 import (
|
|||||||
media,
|
media,
|
||||||
names,
|
names,
|
||||||
persons,
|
persons,
|
||||||
|
public,
|
||||||
relationships,
|
relationships,
|
||||||
sources,
|
sources,
|
||||||
trees,
|
trees,
|
||||||
@@ -30,3 +31,4 @@ api_router.include_router(citations.router)
|
|||||||
api_router.include_router(media.router)
|
api_router.include_router(media.router)
|
||||||
api_router.include_router(gedcom.router)
|
api_router.include_router(gedcom.router)
|
||||||
api_router.include_router(cleanup.router)
|
api_router.include_router(cleanup.router)
|
||||||
|
api_router.include_router(public.router)
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -29,3 +29,16 @@ class TreeRead(BaseModel):
|
|||||||
owner_id: uuid.UUID
|
owner_id: uuid.UUID
|
||||||
home_person_id: uuid.UUID | None = None
|
home_person_id: uuid.UUID | None = None
|
||||||
created_at: datetime
|
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
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
Vendored
+408
@@ -769,6 +769,142 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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 type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -1329,6 +1465,25 @@ export interface components {
|
|||||||
/** Notes */
|
/** Notes */
|
||||||
notes?: string | null;
|
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 */
|
||||||
RegisterRequest: {
|
RegisterRequest: {
|
||||||
/** Email */
|
/** 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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"components": {
|
||||||
@@ -4900,6 +5324,54 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "PersonUpdate"
|
"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": {
|
"RegisterRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"email": {
|
"email": {
|
||||||
|
|||||||
Reference in New Issue
Block a user