From a6179037c2e0a229f1382dc969e0cd27fc3e530a Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Wed, 10 Jun 2026 22:38:59 -0400 Subject: [PATCH] Close citation/source living-person leak; add on-demand tree purge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes. 1. Privacy fix (NN#2/NN#3) — the citation and source list endpoints gated only on can_view_tree, so a non-member on a public/unlisted/site_members tree could enumerate citations and sources tied to a redacted living person, leaking that the person exists and has sourced facts (and possibly their name via a source title). #46 closed this for events/media/names/relationships but not citations/sources. Now citation_service.list_citations and source_service.{list_sources,get_source} delegate non-member reads to public_view_service, mirroring the #46 pattern: - citations: shown only when the cited fact resolves to FULL-visibility person(s) — covers the person_id, name_id, event_id (person or both-partner), and relationship_id (both-partner) target paths. - sources: shown only when they back at least one visible citation; a withheld source 404s (don't reveal it exists). Tests cover all four citation target types + source withholding + member-sees-all. 2. On-demand tree purge — owners can permanently delete a soft-deleted tree now instead of waiting out the 30-day auto-purge window. POST /trees/{id}/purge (owner-only): the tree must already be in the trash, and the caller retypes its name to confirm. Media objects are deleted from storage, then a single DELETE on trees cascades all tree-owned rows via the tree_id ON DELETE CASCADE; the audit entry survives (tree_id SET NULL). Frontend adds a "Delete forever" button to the Recently-deleted list. No migration. Suite: 102 passing. Signed-off-by: Justin Paul --- backend/app/api/v1/trees.py | 19 ++- backend/app/schemas/tree.py | 5 + backend/app/services/citation_service.py | 9 ++ backend/app/services/public_view_service.py | 92 ++++++++++++ backend/app/services/source_service.py | 14 ++ backend/app/services/tree_service.py | 50 ++++++- .../tests/test_authed_nonmember_redaction.py | 136 ++++++++++++++++++ backend/tests/test_tree_purge.py | 78 ++++++++++ docs/BACKLOG.md | 11 +- frontend/app/trees/page.tsx | 37 ++++- frontend/lib/api/schema.d.ts | 59 ++++++++ frontend/openapi.json | 60 ++++++++ 12 files changed, 558 insertions(+), 12 deletions(-) create mode 100644 backend/tests/test_tree_purge.py diff --git a/backend/app/api/v1/trees.py b/backend/app/api/v1/trees.py index b8f5439..fd73ad8 100644 --- a/backend/app/api/v1/trees.py +++ b/backend/app/api/v1/trees.py @@ -2,8 +2,8 @@ import uuid from fastapi import APIRouter, status -from app.api.deps import CurrentUser, SessionDep -from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate +from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep +from app.schemas.tree import TreeCreate, TreePurge, TreeRead, TreeUpdate from app.services import tree_service router = APIRouter(prefix="/trees", tags=["trees"]) @@ -57,3 +57,18 @@ async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentU async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id) return TreeRead.model_validate(tree) + + +@router.post("/{tree_id}/purge", status_code=status.HTTP_204_NO_CONTENT) +async def purge_tree( + tree_id: uuid.UUID, + data: TreePurge, + session: SessionDep, + current: CurrentUser, + store: ObjectStoreDep, +) -> None: + """Permanently delete a soft-deleted tree and all its data — irreversible. + Owner-only; the tree must be in the trash and `confirm_name` must match.""" + await tree_service.purge_tree( + session, store, actor=current, tree_id=tree_id, confirm_name=data.confirm_name + ) diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index 1eacbf1..40f1fb6 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -19,6 +19,11 @@ class TreeUpdate(BaseModel): home_person_id: uuid.UUID | None = None +class TreePurge(BaseModel): + # Retype the tree's name to confirm a permanent, irreversible delete. + confirm_name: str + + class TreeRead(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/citation_service.py b/backend/app/services/citation_service.py index ccb4e20..fba8afb 100644 --- a/backend/app/services/citation_service.py +++ b/backend/app/services/citation_service.py @@ -105,6 +105,15 @@ async def list_citations( indicators in a single round-trip.""" if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): raise Forbidden("not permitted to view this tree") + # Non-members get only citations whose cited fact resolves to a full- + # visibility person — a citation on a redacted living person's fact would + # otherwise leak that the person has that sourced fact. + if await privacy.get_membership_role(session, viewer_id, tree.id) is None: + from app.services import public_view_service + + return await public_view_service.list_public_citations( + session, viewer_id=viewer_id, tree=tree + ) stmt = ( select(Citation) .where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None)) diff --git a/backend/app/services/public_view_service.py b/backend/app/services/public_view_service.py index 166104b..e3d2c17 100644 --- a/backend/app/services/public_view_service.py +++ b/backend/app/services/public_view_service.py @@ -12,6 +12,8 @@ person's real name, dates, alternate names, or media. The rules: living partner's timeline otherwise). - names : only for FULL-visibility persons. - media : NOT exposed yet (deferred — see docs/design/tree-visibility.md). + - citations : only when the cited fact resolves to FULL person(s). + - sources : only when they back at least one visible citation. 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. @@ -27,6 +29,7 @@ from app.models.event import Event from app.models.media import Media from app.models.person import Name, Person from app.models.relationship import Relationship +from app.models.source import Citation, Source from app.models.tree import Tree from app.services import privacy from app.services.exceptions import NotFound @@ -296,6 +299,95 @@ async def can_view_media( return vis == Visibility.full +async def _full_person_ids( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree +) -> set[uuid.UUID]: + persons = await _persons(session, tree) + vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons) + return {pid for pid, v in vis.items() if v == Visibility.full} + + +async def list_public_citations( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree +) -> list[Citation]: + """Only citations whose cited fact resolves to FULL-visibility person(s). A + citation on a redacted/hidden person's fact (or a partnership where either + partner isn't full) is dropped — its existence plus page/detail would leak + that the person has that sourced fact. Mirrors the events/names rule (FULL + only).""" + full = await _full_person_ids(session, viewer_id=viewer_id, tree=tree) + + async def _by_id(model): + rows = ( + await session.execute( + select(model).where(model.tree_id == tree.id, model.deleted_at.is_(None)) + ) + ).scalars().all() + return {r.id: r for r in rows} + + names = await _by_id(Name) + rels = await _by_id(Relationship) + events = await _by_id(Event) + + def target_is_full(c: Citation) -> bool: + if c.person_id is not None: + return c.person_id in full + if c.name_id is not None: + n = names.get(c.name_id) + return n is not None and n.person_id in full + if c.event_id is not None: + e = events.get(c.event_id) + if e is None: + return False + if e.person_id is not None: + return e.person_id in full + if e.relationship_id is not None: + r = rels.get(e.relationship_id) + return r is not None and r.person_from_id in full and r.person_to_id in full + return False + if c.relationship_id is not None: + r = rels.get(c.relationship_id) + return r is not None and r.person_from_id in full and r.person_to_id in full + return False + + citations = ( + await session.execute( + select(Citation) + .where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None)) + .order_by(Citation.created_at) + ) + ).scalars().all() + return [c for c in citations if target_is_full(c)] + + +async def list_public_sources( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree +) -> list[Source]: + """Only sources backing at least one visible citation. A source used solely + for a redacted/hidden person's facts is withheld — its title or notes could + name that living person.""" + visible = await list_public_citations(session, viewer_id=viewer_id, tree=tree) + cited = {c.source_id for c in visible} + sources = ( + await session.execute( + select(Source) + .where(Source.tree_id == tree.id, Source.deleted_at.is_(None)) + .order_by(Source.title) + ) + ).scalars().all() + return [s for s in sources if s.id in cited] + + +async def get_public_source( + session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, source_id: uuid.UUID +) -> Source: + for s in await list_public_sources(session, viewer_id=viewer_id, tree=tree): + if s.id == source_id: + return s + # 404 (not 403): don't reveal that a withheld source exists. + raise NotFound("source not found") + + async def list_public_trees( session: AsyncSession, *, diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py index d942903..df671b5 100644 --- a/backend/app/services/source_service.py +++ b/backend/app/services/source_service.py @@ -61,6 +61,14 @@ async def create_source( async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Source]: if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): raise Forbidden("not permitted to view this tree") + # Non-members see only sources backing a visible citation (see citation + # redaction) — a source used solely for a redacted person could name them. + if await privacy.get_membership_role(session, viewer_id, tree.id) is None: + from app.services import public_view_service + + return await public_view_service.list_public_sources( + session, viewer_id=viewer_id, tree=tree + ) stmt = ( select(Source) .where(Source.tree_id == tree.id, Source.deleted_at.is_(None)) @@ -74,6 +82,12 @@ async def get_source( ) -> Source: if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): raise Forbidden("not permitted to view this tree") + if await privacy.get_membership_role(session, viewer_id, tree.id) is None: + from app.services import public_view_service + + return await public_view_service.get_public_source( + session, viewer_id=viewer_id, tree=tree, source_id=source_id + ) source = ( await session.execute( select(Source).where( diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py index 2a80323..81c328e 100644 --- a/backend/app/services/tree_service.py +++ b/backend/app/services/tree_service.py @@ -5,16 +5,18 @@ authorization basis) and an audit entry. Reads go through the privacy engine. import uuid from datetime import UTC, datetime -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession +from app.integrations.objectstore.base import ObjectStore from app.models.enums import MembershipRole, TreeVisibility +from app.models.media import Media from app.models.tree import Tree, TreeMembership from app.models.user import User from app.repositories.base import BaseRepository from app.services import privacy from app.services.audit import record_audit -from app.services.exceptions import Forbidden, NotFound +from app.services.exceptions import Conflict, Forbidden, NotFound async def create_tree( @@ -128,6 +130,50 @@ async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID return tree +async def purge_tree( + session: AsyncSession, + store: ObjectStore, + *, + actor: User, + tree_id: uuid.UUID, + confirm_name: str, +) -> None: + """Permanently delete a soft-deleted tree and ALL its data — irreversible. + Owner-only. The tree must already be in the trash (soft-deleted) and the + caller must retype its name. Tree-owned rows are removed by the `tree_id` + ON DELETE CASCADE; we delete the media objects from storage first (the DB + cascade drops the rows but not the bytes). Audit entries survive with their + `tree_id` nulled (ON DELETE SET NULL), so the purge stays in the log.""" + tree = await _owned_tree(session, actor=actor, tree_id=tree_id) + if tree.deleted_at is None: + raise Conflict("delete the tree first, then purge it from the trash") + if confirm_name.strip() != (tree.name or "").strip(): + raise Forbidden("tree name confirmation does not match") + + keys = list( + ( + await session.execute(select(Media.storage_key).where(Media.tree_id == tree.id)) + ).scalars().all() + ) + for key in keys: + try: + await store.delete_object(key=key) + except Exception: # noqa: BLE001 — best-effort; a missing object must not block the purge + pass + + record_audit( + session, + action="purge", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=actor.id, + before={"name": tree.name}, + ) + await session.execute(delete(Tree).where(Tree.id == tree.id)) + await session.commit() + + async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]: stmt = ( select(Tree) diff --git a/backend/tests/test_authed_nonmember_redaction.py b/backend/tests/test_authed_nonmember_redaction.py index 844459a..bdbc5ff 100644 --- a/backend/tests/test_authed_nonmember_redaction.py +++ b/backend/tests/test_authed_nonmember_redaction.py @@ -106,6 +106,142 @@ async def test_authed_nonmember_does_not_see_living_pii(client): ).status_code == 200 +async def _setup_sources(client): + owner = auth(await register(client, "anmcs-owner@ex.com")) + tid = ( + await client.post( + "/api/v1/trees", json={"name": "PubCS", "visibility": "public"}, headers=owner + ) + ).json()["id"] + old = ( + await client.post( + f"/api/v1/trees/{tid}/persons", + json={"given": "Oldcs", "surname": "Gonecs", "is_living": False}, + headers=owner, + ) + ).json()["id"] + young = ( + await client.post( + f"/api/v1/trees/{tid}/persons", + json={"given": "Youngcs", "surname": "Csleaksurname", "is_living": True}, + headers=owner, + ) + ).json()["id"] + for pid, year in ((old, "1851"), (young, "2004")): + await client.post( + f"/api/v1/trees/{tid}/events", + json={"event_type": "birth", "person_id": pid, "date_value": year}, + headers=owner, + ) + s_old = ( + await client.post( + f"/api/v1/trees/{tid}/sources", json={"title": "Oldsource record"}, headers=owner + ) + ).json()["id"] + s_young = ( + await client.post( + f"/api/v1/trees/{tid}/sources", + json={"title": "Youngsource Csleaktitle"}, # title names the living person + headers=owner, + ) + ).json()["id"] + await client.post( + f"/api/v1/trees/{tid}/citations", + json={"source_id": s_old, "person_id": old, "page": "p.1"}, + headers=owner, + ) + await client.post( + f"/api/v1/trees/{tid}/citations", + json={"source_id": s_young, "person_id": young, "page": "p.2"}, + headers=owner, + ) + return owner, tid, old, young, s_old, s_young + + +async def test_authed_nonmember_citation_source_redaction(client): + """A non-member must not see citations on a redacted living person's facts, + nor sources used only for them.""" + owner, tid, old, young, s_old, s_young = await _setup_sources(client) + stranger = auth(await register(client, "anmcs-stranger@ex.com")) + + cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json() + cited = {c.get("person_id") for c in cites} + assert old in cited + assert young not in cited # living person's citation dropped + + srcs = (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger)) + src_ids = {s["id"] for s in srcs.json()} + assert s_old in src_ids + assert s_young not in src_ids # source used only for the living person withheld + assert "Csleaktitle" not in srcs.text # its title (which names them) must not leak + + # The withheld source 404s — don't reveal it exists; the visible one is fine. + assert ( + await client.get(f"/api/v1/trees/{tid}/sources/{s_young}", headers=stranger) + ).status_code == 404 + assert ( + await client.get(f"/api/v1/trees/{tid}/sources/{s_old}", headers=stranger) + ).status_code == 200 + + # Members still see everything. + mc = {c.get("person_id") for c in (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json()} + assert {old, young} <= mc + ms = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=owner)).json()} + assert {s_old, s_young} <= ms + + +async def test_citation_redaction_via_indirect_targets(client): + """Citations targeting a living person *indirectly* (via their event or name, + not person_id) must also be dropped for non-members.""" + owner = auth(await register(client, "anmind-owner@ex.com")) + tid = ( + await client.post( + "/api/v1/trees", json={"name": "PubInd", "visibility": "public"}, headers=owner + ) + ).json()["id"] + young = ( + await client.post( + f"/api/v1/trees/{tid}/persons", + json={"given": "Youngind", "surname": "Indsurname", "is_living": True}, + headers=owner, + ) + ).json()["id"] + ev = ( + await client.post( + f"/api/v1/trees/{tid}/events", + json={"event_type": "birth", "person_id": young, "date_value": "2005"}, + headers=owner, + ) + ).json()["id"] + nm = ( + await client.post( + f"/api/v1/trees/{tid}/persons/{young}/names", + json={"name_type": "alias", "given": "Indalias"}, + headers=owner, + ) + ).json()["id"] + s_ev = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "EvSrc"}, headers=owner)).json()["id"] + s_nm = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "NmSrc"}, headers=owner)).json()["id"] + await client.post( + f"/api/v1/trees/{tid}/citations", json={"source_id": s_ev, "event_id": ev}, headers=owner + ) + await client.post( + f"/api/v1/trees/{tid}/citations", json={"source_id": s_nm, "name_id": nm}, headers=owner + ) + + stranger = auth(await register(client, "anmind-stranger@ex.com")) + cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json() + # Neither the event-citation nor the name-citation may surface. + assert not any(c.get("event_id") == ev for c in cites) + assert not any(c.get("name_id") == nm for c in cites) + src_ids = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger)).json()} + assert s_ev not in src_ids and s_nm not in src_ids + + # Owner (member) sees both citations and both sources. + mc = (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json() + assert any(c.get("event_id") == ev for c in mc) and any(c.get("name_id") == nm for c in mc) + + async def test_member_still_sees_everything(client): owner, tid, old, young, om, ym = await _setup(client) diff --git a/backend/tests/test_tree_purge.py b/backend/tests/test_tree_purge.py new file mode 100644 index 0000000..4f0bcfa --- /dev/null +++ b/backend/tests/test_tree_purge.py @@ -0,0 +1,78 @@ +"""On-demand purge of a soft-deleted tree: permanent, owner-only, name-confirmed, +and cascades to all tree data.""" + +import uuid + +from sqlalchemy import func, select + +from app.models.person import Person +from app.models.tree import Tree +from tests.conftest import auth, register + + +async def _tree_with_person(client, owner): + tid = (await client.post("/api/v1/trees", json={"name": "Purge Me"}, headers=owner)).json()["id"] + await client.post( + f"/api/v1/trees/{tid}/persons", json={"given": "Doomed", "surname": "Soul"}, headers=owner + ) + return tid + + +async def test_purge_requires_soft_delete_first(client): + owner = auth(await register(client, "purge-a@ex.com")) + tid = await _tree_with_person(client, owner) + # A live tree can't be purged — it must be trashed first. + r = await client.post( + f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner + ) + assert r.status_code == 409 + + +async def test_purge_name_must_match(client): + owner = auth(await register(client, "purge-b@ex.com")) + tid = await _tree_with_person(client, owner) + await client.delete(f"/api/v1/trees/{tid}", headers=owner) # soft-delete + r = await client.post( + f"/api/v1/trees/{tid}/purge", json={"confirm_name": "WRONG"}, headers=owner + ) + assert r.status_code == 403 + # Still in the trash — nothing destroyed. + deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json() + assert any(t["id"] == tid for t in deleted) + + +async def test_purge_owner_only(client): + owner = auth(await register(client, "purge-c@ex.com")) + other = auth(await register(client, "purge-c2@ex.com")) + tid = await _tree_with_person(client, owner) + await client.delete(f"/api/v1/trees/{tid}", headers=owner) + r = await client.post( + f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=other + ) + assert r.status_code in (403, 404) + + +async def test_purge_removes_tree_and_cascades(client, db_session): + owner = auth(await register(client, "purge-d@ex.com")) + tid = await _tree_with_person(client, owner) + await client.delete(f"/api/v1/trees/{tid}", headers=owner) + + r = await client.post( + f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner + ) + assert r.status_code == 204 + + # Gone from the trash... + deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json() + assert not any(t["id"] == tid for t in deleted) + + # ...and cascaded: no tree row, no person rows. + tuuid = uuid.UUID(tid) + assert ( + await db_session.execute(select(func.count()).select_from(Tree).where(Tree.id == tuuid)) + ).scalar() == 0 + assert ( + await db_session.execute( + select(func.count()).select_from(Person).where(Person.tree_id == tuuid) + ) + ).scalar() == 0 diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index e56ab0a..406c308 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -44,10 +44,9 @@ These two doc edits are themselves trivial quick wins (see §3). - **No place as a usable first-class entity** (model exists, created by GEDCOM, but no read/edit/delete — a create-only entity, which is a bug per NN#8). - **No research log, to-do/task planner, kinship calculator, data-quality checker, or i18n/string externalization** (the last is a documented day-one commitment that is currently unmet). -**Security-priority correctness fixes (do these first, regardless of phase).** Most of the original redaction defects shipped this cycle (#46); two items remain — one a narrowed PII gap, one a config switch: +**Security-priority correctness fixes (do these first, regardless of phase).** The redaction defects all shipped — child resources (#46) and now citations/sources too — leaving one config switch: -1. **Citation/source redaction gap (§2.10)** — `list_media`/`get_media`/`media_content`, plus the event/name/relationship endpoints, now apply `person_visibility` for non-members (#46), closing the media leak. The `citation`/`source` list endpoints still gate only on `can_view_tree`, so a non-member on a public/unlisted tree can still enumerate citations/sources tied to redacted living people — the remaining living-person leak. -2. **Self-registration approval-mode switch (§2.10)** — the read-side enforcement now exists: `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (#53). The remaining gap is the env switch to choose open vs admin-approval vs closed self-registration. +1. **Self-registration approval-mode switch (§2.10)** — the read-side enforcement now exists: `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (#53). The remaining gap is the env switch to choose open vs admin-approval vs closed self-registration. *(The citation/source living-person leak is now closed — citation/source list endpoints apply `person_visibility` for non-members via `public_view_service`.)* **Strategic posture.** The differentiators worth pressing — property chain-of-title, the ChangeProposal AI model, the anonymous mutual-consent hint system, and true self-host data ownership — are mostly still ahead on the roadmap. The near-term job is (a) close the **privacy/auth correctness** and **collaboration** gaps that the architecture already implies, (b) ship the **maps + reports + merge** table stakes, and (c) finish the back-half spine — the **connector framework** plus wiring the now-landed **ChangeProposal/ModelProvider** into the assistant — that unlocks the entire back half of the roadmap. @@ -250,7 +249,7 @@ The architecture is correct (single engine, tenant mixin, audit, soft-delete + p | Item | Description | Status | Imp | Eff | Phase | Non-negotiable | |---|---|---|---|---|---|---| -| **Uniform living-person redaction across child resources** | `person_visibility` now runs for non-members on the event, media, name, and relationship endpoints (#46), which delegate to `public_view_service`. Remaining: the `citation`/`source` list endpoints still gate only on `can_view_tree`, so citations tied to a redacted living person are still enumerable. | Partial | High | S | 1–2 | **Mostly resolved (NN#3/NN#2).** Apply `person_visibility` to the citation/source list paths to close the residual leak. | +| **Uniform living-person redaction across child resources** | `person_visibility` now runs for non-members on the event, media, name, relationship endpoints (#46) and the citation/source list endpoints, all delegating to `public_view_service`: citations resolve to FULL-visibility person(s); sources show only when they back a visible citation. | Have | High | S | 1–2 | **Resolved (NN#3/NN#2).** No child-resource path leaks a redacted living person's facts. | | **Email-verification enforcement gate** | Read-side check now ships (#53): `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (`auth_service.py`). Opt-in (default off) so SMTP-less self-hosts still work. | Have | **High** | S | 1–2 | Read-side trust path now enforced (NN#7); the registration-mode switch below is the separate larger piece. | | Self-registration mode gating (approve / open / closed) | No env switch to choose open vs admin-approval vs closed registration. | Partial | High | M | 2/5 | Twelve-factor registration control (NN#7); pairs with the verification gate above. | | Instance owner / operator role | `OWNER_EMAIL`-declared operator (#240): `is_instance_owner` on `/users/me`, owner-only `GET /api/v1/admin/instance`, `/admin` UI. | Have | Med | S | 2/5 | Owner-only operational surface, twelve-factor via env (NN#7); reads stay through the service layer. | @@ -412,7 +411,7 @@ Ordered by leverage. All are S-effort or a thin slice of a larger item, and most 12. **Sort the merged person timeline** (Research workflow, Med/S) — `shownEvents.sort()` on `date_start`; currently appended unsorted. 13. **Doc corrections (docs-vs-code)** (Meta, trivial/S) — edit CLAUDE.md / ARCHITECTURE so the pgvector "used" claim and the i18n "from day one" claim match reality. The repo convention requires docs to travel with code. -> **Mostly shipped this cycle (#46):** the **media privacy leak** (§2.4) and the broad **child-resource redaction gap** (§2.10) are now closed for the person/event/media/name/relationship endpoints. The narrowed remainder — applying `person_visibility` to the **citation/source list endpoints** — is an S-effort follow-up; treat it as a security-priority Phase 1–2 fix regardless of the quick-win list. +> **Shipped this cycle:** the **media privacy leak** (§2.4) and the **child-resource redaction gap** (§2.10) are fully closed — person/event/media/name/relationship (#46) and citation/source endpoints all apply `person_visibility` for non-members. No residual living-person leak on the read surface. --- @@ -426,6 +425,6 @@ Where to invest to make Provenance distinct rather than a webtrees clone. Each l **3. Anonymous, mutual-consent cross-tree hints.** The privacy model already redacts living people for anonymous viewers, so a hint system that reveals *nothing identifying* until both sides opt in is achievable by construction — and is a categorically more trustworthy version of MyHeritage Smart Matches / Ancestry hints. Requires the matching engine (pgvector enablement + candidate generation, Phase 7), the notification/event-dispatch substrate (§2.9), and the messaging channel that opens only post-consent. -**4. True self-hosting + data ownership.** Full account export/import, soft-delete recovery, GEDCOM round-trip, env-driven everything, a one-command operator backup, and (to-build) scheduled off-host backup + ARM support make Provenance the genealogy app you actually own. The two correctness items that gated the promise have **landed**: GEDCOM export now preserves citations (the Provenance→Provenance round-trip keeps the sources graph), and operator backup moved from "documented procedure" to a one-command dump (`deploy/backup.sh`). What remains is scheduled/verified-restore tooling and ARM builds. The Ollama/self-hosted ModelProvider path means even the AI assistant runs without tree data leaving the deployment — a promise no commercial competitor can make. +**4. True self-hosting + data ownership.** Full account export/import, soft-delete recovery (with owner-confirmed on-demand purge to delete a trashed tree immediately rather than waiting out the 30-day window), GEDCOM round-trip, env-driven everything, a one-command operator backup, and (to-build) scheduled off-host backup + ARM support make Provenance the genealogy app you actually own. The two correctness items that gated the promise have **landed**: GEDCOM export now preserves citations (the Provenance→Provenance round-trip keeps the sources graph), and operator backup moved from "documented procedure" to a one-command dump (`deploy/backup.sh`). What remains is scheduled/verified-restore tooling and ARM builds. The Ollama/self-hosted ModelProvider path means even the AI assistant runs without tree data leaving the deployment — a promise no commercial competitor can make. **5. Sources-first as a felt experience.** The two-tier model is built, and citations now **survive GEDCOM export** (#232); the remaining differentiator is making sourcing *visible and low-friction*: a guided Evidence-Explained citation builder, transcription/abstract fields, source-driven data entry (transcribe a document into the tree), and per-fact confidence surfaced in the UI. These turn "every fact links to where it came from" from an architecture note into the product's personality. diff --git a/frontend/app/trees/page.tsx b/frontend/app/trees/page.tsx index fc37318..d1a0732 100644 --- a/frontend/app/trees/page.tsx +++ b/frontend/app/trees/page.tsx @@ -53,6 +53,26 @@ export default function TreesPage() { await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } }); load(); } + async function purge(id: string, treeName: string) { + const typed = window.prompt( + `Permanently delete "${treeName}" and ALL its data (people, sources, media, …)?\n\n` + + "This CANNOT be undone. Type the tree name to confirm:", + ); + if (typed == null) return; // cancelled + const { error, response } = await api.POST("/api/v1/trees/{tree_id}/purge", { + params: { path: { tree_id: id } }, + body: { confirm_name: typed }, + }); + if (error) { + window.alert( + response.status === 403 + ? "The name didn't match — nothing was deleted." + : "Couldn't purge that tree.", + ); + return; + } + load(); + } // Optimistic visibility change so the dropdown reflects the pick immediately. async function setVisibility(id: string, visibility: NonNullable) { setTrees((cur) => cur.map((t) => (t.id === id ? { ...t, visibility } : t))); @@ -139,15 +159,28 @@ export default function TreesPage() {

Recently deleted

+

+ Restorable for 30 days, after which they're purged automatically. Use + Delete forever to purge one now. +

    {deleted.map((tree) => (
  • - - {tree.name} + + {tree.name} +
  • diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index b3c062c..150df90 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -293,6 +293,27 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/trees/{tree_id}/purge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Purge Tree + * @description Permanently delete a soft-deleted tree and all its data — irreversible. + * Owner-only; the tree must be in the trash and `confirm_name` must match. + */ + post: operations["purge_tree_api_v1_trees__tree_id__purge_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/trees/{tree_id}/persons": { parameters: { query?: never; @@ -1977,6 +1998,11 @@ export interface components { /** @default private */ visibility?: components["schemas"]["TreeVisibility"]; }; + /** TreePurge */ + TreePurge: { + /** Confirm Name */ + confirm_name: string; + }; /** TreeRead */ TreeRead: { /** @@ -2655,6 +2681,39 @@ export interface operations { }; }; }; + purge_tree_api_v1_trees__tree_id__purge_post: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TreePurge"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_persons_api_v1_trees__tree_id__persons_get: { parameters: { query?: { diff --git a/frontend/openapi.json b/frontend/openapi.json index 0ee7e1d..f49e0fa 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -710,6 +710,53 @@ } } }, + "/api/v1/trees/{tree_id}/purge": { + "post": { + "tags": [ + "trees" + ], + "summary": "Purge Tree", + "description": "Permanently delete a soft-deleted tree and all its data \u2014 irreversible.\nOwner-only; the tree must be in the trash and `confirm_name` must match.", + "operationId": "purge_tree_api_v1_trees__tree_id__purge_post", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TreePurge" + } + } + } + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/trees/{tree_id}/persons": { "post": { "tags": [ @@ -7101,6 +7148,19 @@ ], "title": "TreeCreate" }, + "TreePurge": { + "properties": { + "confirm_name": { + "type": "string", + "title": "Confirm Name" + } + }, + "type": "object", + "required": [ + "confirm_name" + ], + "title": "TreePurge" + }, "TreeRead": { "properties": { "id": { -- 2.52.0