Visibility phase 1: add site_members value + 4-option dropdown

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) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-09 08:54:45 -04:00
parent 251652a935
commit 4a3fe983fa
6 changed files with 199 additions and 5 deletions
+4 -3
View File
@@ -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):
@@ -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
+157
View File
@@ -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 <tree> 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 23 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.")
+8 -1
View File
@@ -95,10 +95,17 @@ export default function TreesPage() {
setVisibility(tree.id, e.target.value as NonNullable<Tree["visibility"]>)
}
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"
>
<option value="private">Private</option>
<option value="site_members">Public Members</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</option>
</select>
+1 -1
View File
@@ -1534,7 +1534,7 @@ export interface components {
* TreeVisibility
* @enum {string}
*/
TreeVisibility: "public" | "unlisted" | "private";
TreeVisibility: "public" | "site_members" | "unlisted" | "private";
/** UserRead */
UserRead: {
/**
+1
View File
@@ -5552,6 +5552,7 @@
"type": "string",
"enum": [
"public",
"site_members",
"unlisted",
"private"
],