Close citation/source living-person leak; add on-demand tree purge #245

Merged
justin merged 1 commits from citation-redaction-and-tree-purge into main 2026-06-10 22:39:16 -04:00
12 changed files with 558 additions and 12 deletions
+17 -2
View File
@@ -2,8 +2,8 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate from app.schemas.tree import TreeCreate, TreePurge, TreeRead, TreeUpdate
from app.services import tree_service from app.services import tree_service
router = APIRouter(prefix="/trees", tags=["trees"]) 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: 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) tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
return TreeRead.model_validate(tree) 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
)
+5
View File
@@ -19,6 +19,11 @@ class TreeUpdate(BaseModel):
home_person_id: uuid.UUID | None = None 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): class TreeRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+9
View File
@@ -105,6 +105,15 @@ async def list_citations(
indicators in a single round-trip.""" indicators in a single round-trip."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this 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 = ( stmt = (
select(Citation) select(Citation)
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None)) .where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
@@ -12,6 +12,8 @@ person's real name, dates, alternate names, or media. The rules:
living partner's timeline otherwise). living partner's timeline otherwise).
- names : only for FULL-visibility persons. - names : only for FULL-visibility persons.
- media : NOT exposed yet (deferred — see docs/design/tree-visibility.md). - 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 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. 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.media import Media
from app.models.person import Name, Person from app.models.person import Name, Person
from app.models.relationship import Relationship from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree from app.models.tree import Tree
from app.services import privacy from app.services import privacy
from app.services.exceptions import NotFound from app.services.exceptions import NotFound
@@ -296,6 +299,95 @@ async def can_view_media(
return vis == Visibility.full 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( async def list_public_trees(
session: AsyncSession, session: AsyncSession,
*, *,
+14
View File
@@ -61,6 +61,14 @@ async def create_source(
async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[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): if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this 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 = ( stmt = (
select(Source) select(Source)
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None)) .where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
@@ -74,6 +82,12 @@ async def get_source(
) -> Source: ) -> Source:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this 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 = ( source = (
await session.execute( await session.execute(
select(Source).where( select(Source).where(
+48 -2
View File
@@ -5,16 +5,18 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.objectstore.base import ObjectStore
from app.models.enums import MembershipRole, TreeVisibility from app.models.enums import MembershipRole, TreeVisibility
from app.models.media import Media
from app.models.tree import Tree, TreeMembership from app.models.tree import Tree, TreeMembership
from app.models.user import User from app.models.user import User
from app.repositories.base import BaseRepository from app.repositories.base import BaseRepository
from app.services import privacy from app.services import privacy
from app.services.audit import record_audit 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( async def create_tree(
@@ -128,6 +130,50 @@ async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID
return tree 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]: async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
stmt = ( stmt = (
select(Tree) select(Tree)
@@ -106,6 +106,142 @@ async def test_authed_nonmember_does_not_see_living_pii(client):
).status_code == 200 ).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): async def test_member_still_sees_everything(client):
owner, tid, old, young, om, ym = await _setup(client) owner, tid, old, young, om, ym = await _setup(client)
+78
View File
@@ -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
+5 -6
View File
@@ -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 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). - **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. 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`.)*
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.
**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. **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 | | 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 | 12 | **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 | 12 | **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 | 12 | Read-side trust path now enforced (NN#7); the registration-mode switch below is the separate larger piece. | | **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 | 12 | 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. | | 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. | | 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. 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. 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 12 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. **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. **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.
+35 -2
View File
@@ -53,6 +53,26 @@ export default function TreesPage() {
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } }); await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
load(); 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. // Optimistic visibility change so the dropdown reflects the pick immediately.
async function setVisibility(id: string, visibility: NonNullable<Tree["visibility"]>) { async function setVisibility(id: string, visibility: NonNullable<Tree["visibility"]>) {
setTrees((cur) => cur.map((t) => (t.id === id ? { ...t, visibility } : t))); setTrees((cur) => cur.map((t) => (t.id === id ? { ...t, visibility } : t)));
@@ -139,15 +159,28 @@ export default function TreesPage() {
<h2 className="font-serif text-base font-semibold text-[var(--muted)]"> <h2 className="font-serif text-base font-semibold text-[var(--muted)]">
Recently deleted Recently deleted
</h2> </h2>
<p className="text-xs text-[var(--muted)]">
Restorable for 30 days, after which they&apos;re purged automatically. Use
Delete forever to purge one now.
</p>
<ul className="space-y-2"> <ul className="space-y-2">
{deleted.map((tree) => ( {deleted.map((tree) => (
<li key={tree.id}> <li key={tree.id}>
<Card> <Card>
<CardContent className="flex items-center justify-between p-4"> <CardContent className="flex items-center justify-between gap-2 p-4">
<span className="text-[var(--muted)]">{tree.name}</span> <span className="min-w-0 flex-1 truncate text-[var(--muted)]">{tree.name}</span>
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}> <Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
Restore Restore
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => purge(tree.id, tree.name)}
className="border-bronze/40 text-bronze hover:bg-bronze/10"
title="Permanently delete this tree and all its data"
>
Delete forever
</Button>
</CardContent> </CardContent>
</Card> </Card>
</li> </li>
+59
View File
@@ -293,6 +293,27 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/trees/{tree_id}/persons": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1977,6 +1998,11 @@ export interface components {
/** @default private */ /** @default private */
visibility?: components["schemas"]["TreeVisibility"]; visibility?: components["schemas"]["TreeVisibility"];
}; };
/** TreePurge */
TreePurge: {
/** Confirm Name */
confirm_name: string;
};
/** TreeRead */ /** TreeRead */
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: { list_persons_api_v1_trees__tree_id__persons_get: {
parameters: { parameters: {
query?: { query?: {
+60
View File
@@ -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": { "/api/v1/trees/{tree_id}/persons": {
"post": { "post": {
"tags": [ "tags": [
@@ -7101,6 +7148,19 @@
], ],
"title": "TreeCreate" "title": "TreeCreate"
}, },
"TreePurge": {
"properties": {
"confirm_name": {
"type": "string",
"title": "Confirm Name"
}
},
"type": "object",
"required": [
"confirm_name"
],
"title": "TreePurge"
},
"TreeRead": { "TreeRead": {
"properties": { "properties": {
"id": { "id": {