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:
@@ -9,9 +9,10 @@ import enum
|
|||||||
|
|
||||||
|
|
||||||
class TreeVisibility(enum.StrEnum):
|
class TreeVisibility(enum.StrEnum):
|
||||||
public = "public"
|
public = "public" # anyone on the web (anonymous), listed + search-indexable
|
||||||
unlisted = "unlisted"
|
site_members = "site_members" # any authenticated user of this instance
|
||||||
private = "private"
|
unlisted = "unlisted" # anyone with the link (anonymous), not listed/indexed
|
||||||
|
private = "private" # members only (default)
|
||||||
|
|
||||||
|
|
||||||
class MembershipRole(enum.StrEnum):
|
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
|
||||||
@@ -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 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.")
|
||||||
@@ -95,10 +95,17 @@ export default function TreesPage() {
|
|||||||
setVisibility(tree.id, e.target.value as NonNullable<Tree["visibility"]>)
|
setVisibility(tree.id, e.target.value as NonNullable<Tree["visibility"]>)
|
||||||
}
|
}
|
||||||
aria-label="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"
|
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="private">Private</option>
|
||||||
|
<option value="site_members">Public – Members</option>
|
||||||
<option value="unlisted">Unlisted</option>
|
<option value="unlisted">Unlisted</option>
|
||||||
<option value="public">Public</option>
|
<option value="public">Public</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1534,7 +1534,7 @@ export interface components {
|
|||||||
* TreeVisibility
|
* TreeVisibility
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
TreeVisibility: "public" | "unlisted" | "private";
|
TreeVisibility: "public" | "site_members" | "unlisted" | "private";
|
||||||
/** UserRead */
|
/** UserRead */
|
||||||
UserRead: {
|
UserRead: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5552,6 +5552,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"public",
|
"public",
|
||||||
|
"site_members",
|
||||||
"unlisted",
|
"unlisted",
|
||||||
"private"
|
"private"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user