Merge pull request 'Visibility phase 1: add site_members value + 4-option dropdown' (#42) from visibility-phase1-enum into main
This commit was merged in pull request #42.
This commit is contained in:
@@ -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
|
||||
@@ -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"]>)
|
||||
}
|
||||
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>
|
||||
|
||||
Vendored
+1
-1
@@ -1534,7 +1534,7 @@ export interface components {
|
||||
* TreeVisibility
|
||||
* @enum {string}
|
||||
*/
|
||||
TreeVisibility: "public" | "unlisted" | "private";
|
||||
TreeVisibility: "public" | "site_members" | "unlisted" | "private";
|
||||
/** UserRead */
|
||||
UserRead: {
|
||||
/**
|
||||
|
||||
@@ -5552,6 +5552,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"public",
|
||||
"site_members",
|
||||
"unlisted",
|
||||
"private"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user