From 4a3fe983fa8fdaf811b9f54730e8da2da2466625 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Tue, 9 Jun 2026 08:54:45 -0400 Subject: [PATCH] Visibility phase 1: add site_members value + 4-option dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the public-viewing feature (design: docs/design/tree-visibility.md). No non-member behavior change yet — this only widens the vocabulary and UI. - TreeVisibility gains `site_members` (any authenticated user of the instance), giving the four-level model: public / site_members / unlisted / private. - Alembic migration adds the enum value via an autocommit block (ALTER TYPE ADD VALUE can't run in a transaction on older Postgres); downgrade is a no-op since PG can't drop an enum value. - Regenerated openapi.json + frontend TS client. - Trees-list dropdown now offers Private / Public – Members / Unlisted / Public with an explanatory tooltip. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/models/enums.py | 7 +- ...a9c1e7b2f3_tree_visibility_site_members.py | 28 ++++ docs/design/tree-visibility.md | 157 ++++++++++++++++++ frontend/app/trees/page.tsx | 9 +- frontend/lib/api/schema.d.ts | 2 +- frontend/openapi.json | 1 + 6 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/versions/d4a9c1e7b2f3_tree_visibility_site_members.py create mode 100644 docs/design/tree-visibility.md diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index 41e5cb8..4069e5a 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -9,9 +9,10 @@ import enum class TreeVisibility(enum.StrEnum): - public = "public" - unlisted = "unlisted" - private = "private" + public = "public" # anyone on the web (anonymous), listed + search-indexable + site_members = "site_members" # any authenticated user of this instance + unlisted = "unlisted" # anyone with the link (anonymous), not listed/indexed + private = "private" # members only (default) class MembershipRole(enum.StrEnum): diff --git a/backend/migrations/versions/d4a9c1e7b2f3_tree_visibility_site_members.py b/backend/migrations/versions/d4a9c1e7b2f3_tree_visibility_site_members.py new file mode 100644 index 0000000..a3c25ab --- /dev/null +++ b/backend/migrations/versions/d4a9c1e7b2f3_tree_visibility_site_members.py @@ -0,0 +1,28 @@ +"""tree_visibility: add 'site_members' value + +Revision ID: d4a9c1e7b2f3 +Revises: c7e1a4f2d3b8 +Create Date: 2026-06-09 + +""" +from collections.abc import Sequence + +from alembic import op + +revision: str = "d4a9c1e7b2f3" +down_revision: str | None = "c7e1a4f2d3b8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older + # Postgres; run it in an autocommit block so it applies regardless of version. + with op.get_context().autocommit_block(): + op.execute("ALTER TYPE tree_visibility ADD VALUE IF NOT EXISTS 'site_members'") + + +def downgrade() -> None: + # Postgres cannot drop an enum value without rebuilding the type; treat the + # added value as irreversible. (Rows using it would block a rebuild anyway.) + pass diff --git a/docs/design/tree-visibility.md b/docs/design/tree-visibility.md new file mode 100644 index 0000000..b8befc5 --- /dev/null +++ b/docs/design/tree-visibility.md @@ -0,0 +1,157 @@ +# Design note: tree visibility & the public viewing surface + +Status: **proposed** (design only — no code yet). Owner: Justin. Created 2026-06-09. + +This is a privacy-critical change (it creates the first anonymous read surface in +Provenance). Per CLAUDE.md, design before code. Implementation should land in +small, individually-reviewable PRs, with tests on the privacy engine and the +public read path before any anonymous endpoint is exposed. + +## 1. The model + +Visibility flattens **two axes** — *who may read* and *how discoverable* — into one +ordered enum for the UI: + +| Level | Anonymous (no login) | Any logged-in user | Tree members | In-app directory | Search-indexed | +|---|---|---|---|---|---| +| `public` — anyone on the web | ✅ view¹ | ✅ view¹ | ✅ full | ✅ listed to everyone | ✅ sitemap + indexable | +| `site_members` — Public, Site Members | ❌ | ✅ view¹ | ✅ full | ✅ listed to logged-in users | ❌ (`noindex`) | +| `unlisted` — anyone with the link | ✅ via direct link¹ | ✅ via link¹ | ✅ full | ❌ never listed | ❌ (`noindex`) | +| `private` | ❌ | ❌ | ✅ full | ❌ | ❌ | + +¹ **Every non-member view passes through the privacy engine.** Living people are +redacted, and per-person `private` hides / `public` reveals, exactly as +`person_visibility()` already does (`backend/app/services/privacy.py:100-110`). +This is the single enforcement point — no public code path may issue a raw query. + +Decisions captured (2026-06-09): +- **Unlisted** = anyone with the link, no account required. The link must be + **unguessable** (the tree UUID is already non-enumerable; do not add a public + integer id). Unlisted trees are excluded from the directory and sitemap and + served `noindex`. +- **Public** discovery for v1 includes **an in-app public browse/search**, not + just search-engine indexing. +- **Public – Site Members** = *any* registered account on this instance (not an + invite list — that is already tree membership / `private`). + +## 2. Data model + +`TreeVisibility` enum (`backend/app/models/enums.py`) gains a value: + +``` +public # anyone on the web +site_members # any authenticated user of this instance <-- NEW +unlisted # anyone with the link +private # members only (default) +``` + +- Alembic migration to `ALTER TYPE tree_visibility ADD VALUE 'site_members'` + (Postgres enum add-value cannot run inside a transaction with other DDL — use + `op.execute` with autocommit, separate migration). +- Default stays `private`. Existing rows unchanged. +- `TreeRead`/`TreeUpdate`/`TreeCreate` schemas already carry the enum; they pick + up the new value automatically. The OpenAPI client regen (`gen:api`) exposes it + to the frontend. + +## 3. Privacy engine + +`can_view_tree()` today treats `public` and `unlisted` identically and ignores +whether the viewer is anonymous vs authenticated (`privacy.py:44-49`). Replace the +final line with explicit branching on viewer auth state: + +``` +if membership: return True # members always +match tree.visibility: + public, unlisted: return True # anonymous OK (unlisted gated only by knowing the link) + site_members: return user_id is not None # any logged-in account + private: return False +``` + +`person_visibility()` is unchanged — it already redacts living/private people for +non-members. Add focused unit tests: anonymous + each visibility; living person +redacted on public/unlisted; `site_members` denies anonymous but allows a +logged-in non-member; `private` denies both. + +## 4. The anonymous read path (the careful part) + +**Recommendation: a dedicated read-only public API namespace**, not optional-auth +on the existing endpoints. Rationale: it is far easier to audit a small, +purpose-built surface that *always* funnels through `person_visibility` than to +weaken the membership checks on the authenticated endpoints and hope every branch +is covered. + +- New router `app/api/v1/public.py`, mounted at `/api/v1/public`, with an + **optional-auth** dependency `CurrentUserOrNone` (returns `User | None`; never + 401s). Contrast with `CurrentUser` (`deps.py:30-36`) which hard-401s. +- Endpoints (read-only; no create/update/delete): + - `GET /public/trees` — directory: lists `public` to everyone; additionally + lists `site_members` when the caller is authenticated. Paginated, search via + existing `pg_trgm`. Never lists `unlisted`/`private`. + - `GET /public/trees/{id}` — tree metadata if `can_view_tree(user_or_none)`. + - `GET /public/trees/{id}/persons`, `/persons/{pid}`, `/relationships`, + `/events`, `/media`, … — each filtered through `person_visibility`, returning + redacted projections (a `PublicPersonRead` that omits PII for redacted people: + no exact dates, no living-person names beyond "Living", etc.). +- **A redacted response schema**, distinct from the member `PersonRead`, so the + serializer physically cannot emit fields a non-member shouldn't see. Redaction + happens in the service, not the route. +- **Rate limiting** on the public namespace (per-IP) to blunt scraping/enumeration. +- **Audit**: count public reads; do not log PII. + +## 5. Frontend public pages + +- New **server-rendered** routes outside the authed app shell, e.g. + `/p/[treeId]` (tree), `/p/[treeId]/[personId]` (person), `/explore` (directory). + Server components fetch the `/api/v1/public/*` endpoints; no login redirect. +- `robots`: allow + sitemap for `public`; `noindex, nofollow` meta for `unlisted` + and `site_members`. Sitemap lists only `public` trees/persons. +- The directory `/explore` is anonymous for `public`; shows `site_members` trees + only to logged-in users. +- Reuse the tree/person view components where possible, fed by the redacted + schema. + +## 6. UI control + +Update the visibility dropdown (`frontend/app/trees/page.tsx`, shipped in PR #41) +from 3 to 4 options with helper text: + +``` +Private — only you and people you invite +Public – Members — any signed-in user on this site +Unlisted — anyone with the link (not listed or indexed) +Public — anyone on the web; listed and search-indexable +``` + +A short confirmation when switching *to* `public` ("This makes visible to +anyone on the web. Living people stay hidden.") is worthwhile given the stakes. + +## 7. Guardrails / invariants + +- One enforcement point: every public response is built from `person_visibility` + output. No raw repository reads in the public router. +- Living-person protection holds regardless of tree visibility. +- Unlisted relies on UUID unguessability; never expose a sequential public id. +- `noindex` everything except `public`; sitemap is `public`-only. +- Tests gate the merge: privacy-engine matrix + an integration test that hits the + public endpoints anonymously and asserts no living-person PII leaks. + +## 8. Suggested phasing (small PRs) + +1. Enum value + migration + regen client (+ dropdown → 4 options). No behavior + change yet for non-members. +2. Privacy-engine branching + unit tests. +3. Public read API namespace (optional-auth, redacted schema, rate limit) + tests. +4. Public frontend pages (`/p/...`) + robots/sitemap. +5. In-app `/explore` directory + search. + +Steps 2–3 are the privacy-critical core and should be reviewed hardest. + +## 9. Open questions + +- Caching: public pages are cacheable for SEO, but cache keys must not blur the + redacted-vs-member rendering. Likely: cache only the anonymous projection at the + edge; never cache member responses. +- Do `site_members` trees appear in the sitemap for logged-in crawling? (Default: + no — `noindex`.) +- Per-tree opt-out of the directory even when `public`? (Probably unnecessary; + `unlisted` already covers "reachable but not listed.") diff --git a/frontend/app/trees/page.tsx b/frontend/app/trees/page.tsx index bcdf3f5..a68ad0a 100644 --- a/frontend/app/trees/page.tsx +++ b/frontend/app/trees/page.tsx @@ -95,10 +95,17 @@ export default function TreesPage() { setVisibility(tree.id, e.target.value as NonNullable) } aria-label="Tree visibility" - title="Who can see this tree. Living people stay protected even when public." + title={ + "Who can see this tree (living people stay protected regardless):\n" + + "• Private — only you and people you invite\n" + + "• Members — any signed-in user on this site\n" + + "• Unlisted — anyone with the link (not listed or indexed)\n" + + "• Public — anyone on the web; listed and search-indexable" + } className="rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-xs uppercase tracking-wide text-bronze focus-visible:border-bronze focus-visible:outline-none" > + diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index d9cd02b..50b2aff 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -1534,7 +1534,7 @@ export interface components { * TreeVisibility * @enum {string} */ - TreeVisibility: "public" | "unlisted" | "private"; + TreeVisibility: "public" | "site_members" | "unlisted" | "private"; /** UserRead */ UserRead: { /** diff --git a/frontend/openapi.json b/frontend/openapi.json index b073713..1e4af0f 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -5552,6 +5552,7 @@ "type": "string", "enum": [ "public", + "site_members", "unlisted", "private" ], -- 2.52.0