Faces are members-only: inventory of every path (2026-10-11)
Status: Active · Last reviewed: 2026-10-11
Owner ruling, 2026-10-11: "As for the avatar default, no we are not showing strangers' faces by default. Non-members should see no faces." This page lists every way another user's profiles.avatar_url could reach a client, the file behind each path, and how migration 00691_faces_members_only.sql closes it. Canon: data-and-hooks.md § Faces.
The rule
A face of user T reaches viewer V only if one of these holds, decided by the one predicate public.can_view_face(p_viewer, p_target):
- V is T.
- V and T share an active club membership (
club_members.is_active). - V and T are accepted friends. Decision taken for the owner, logged for veto: friendship is mutual consent (00678 makes the recipient accept; a pending or blocked row never counts).
- T opted in to
public(profile_visibility->>'avatar' = 'public'), and V is signed in. An anonymous viewer never gets a face.
Everyone else gets NULL; the client already falls back to the initials monogram. The avatar key picks the audience, and an absent key means club_members, the safe default of can_view_profile_field (00212):
| Key value | Who sees the face |
|---|---|
absent / club_members | shared active club or accepted friend (the default) |
friends | accepted friends only |
private | only the owner |
public | any signed-in viewer (explicit opt-in) |
The four conditions are necessary, not sufficient: an explicit friends or private narrows further, as it already did for the discovery stacks under 00260. Migration 00260's "public or unset shows to everyone" default is gone.
Method
Every function was read from the live catalog of the local database at migration 00690 (the hosted project is at 00690 too, per docs/external-blockers.md), not from migration files:
- every function (all schemas) whose source mentions
avatar: 8 hits. Six touch the URL (mask_profile_row,get_club_discovery_summaries,get_public_share_preview,venue_owner_restricted,get_own_profile, and the erasure job); the other two (dev_reconcile_seeded_world,patch_profile_visibility) only say the word; - every function that references
public.profilesand builds whole-row JSON (to_jsonb(p),row_to_json,p.*,select * from public.profiles): 1 hit, service-only, no face (below); - every column named like a face anywhere in
public/private; every view and materialized view; every policy, trigger and dependency on the column; the Realtime publication; the edge functions; the client source.
Summary
16 paths: 14 SQL projections (13 other-user, 1 self), 1 raw column read, 1 storage listing. The public object URL is a seventeenth fact, not a path: a by-design capability (below). Edge functions: 0 paths. Client code: 0 direct reads (every adapter already consumes a masked RPC).
| Kind | Paths | Fix |
|---|---|---|
| SQL projection, other users | 13 | each asks can_view_face: nine ride on the one root mask_profile_row (1 to 9), four are own projections (10 to 13) |
| SQL projection, self | 1 | get_own_profile() unchanged: the self read |
| Raw column (PostgREST) | 1 | REVOKE SELECT (avatar_url) from anon, authenticated (00539 / 00680 precedent) |
| Storage listing | 1 | the avatars SELECT policy is limited to the caller's own objects; the public object URL stays by design (Residual risks) |
Paths
| # | Kind | Path | Client file(s) | Before | Fix in 00691 |
|---|---|---|---|---|---|
| 1 | SQL root | private.mask_profile_row(viewer, profiles), last defined 00503 | none (internal) | 'avatar_url', v_target.avatar_url, always visible | CASE WHEN … AND public.can_view_face(v_viewer, v_target.id) THEN avatar_url END |
| 2 | SQL | get_public_profile_view(uuid) → 1 | adapters/supabase/profile/reads.ts (findPublicView), presentation/hooks/queries/use-public-profile.ts | via 1 | via 1 |
| 3 | SQL | get_public_profile_views(uuid[]) → 1 | profile/reads.ts (findByIds, findByClub), friendship.supabase.ts, dm.supabase.ts | via 1 | via 1 |
| 4 | SQL | search_public_profiles(text, int) → 1 | profile/reads.ts (searchByDisplayName) | via 1 | via 1 |
| 5 | SQL | get_public_profile_leaderboard(text, int, int) → 1 | profile/leaderboard.ts (findTopByRating, getRegionalLeaderboard) | via 1 | via 1 |
| 6 | SQL | get_profile_view(uuid), non-self branch → 2 | profile/reads.ts (findById) | via 1 | via 1 (self branch is path 13) |
| 7 | SQL | get_club_detail_bundle (INVOKER): memberProfiles, postAuthorProfiles, mediaUploaderProfiles → 3 | club-detail-bundle.supabase.ts | via 1 | via 1 |
| 8 | SQL | get_session_detail_bundle (INVOKER): memberProfiles, participantProfiles → 3 | session-detail-bundle.supabase.ts | via 1 | via 1 |
| 9 | SQL | get_home_snapshot (INVOKER): playerStats.profiles → 3 | home-snapshot.supabase.ts | via 1 | via 1 |
| 10 | SQL | get_club_discovery_summaries, member_avatar_urls (anon-callable) | club/discovery.ts, mapped in club/mappers.ts | public default, or club_members when the viewer is in THIS club; anon got public faces | AND public.can_view_face((SELECT auth.uid()), p.id) |
| 11 | SQL | get_club_discovery_summaries, next_session_avatar_urls (anon-callable) | same | same gating on the RSVP'd participants | same predicate |
| 12 | SQL | get_public_share_preview('profile', id) (anon-callable) | public-share-preview.supabase.ts, apps/web/lib/public-share-preview.ts (the /p/<id> page reads the name only) | 'avatarUrl', p.avatar_url to anyone, including anon | CASE WHEN … can_view_face((SELECT auth.uid()), p.id): NULL for anon |
| 13 | SQL | private.venue_owner_restricted(claim, uid) ← get_venue_owner_claim, get_venue_owner_claims, get_venue_owner_transfer_proposals, export_my_venue_owner_claims, set_venue_owner_transfer_consent (all SECDEF) | venue-owner-claim.rpc.ts, wire map mappers/venue-owner-claim.wire.ts (applicant.avatar_url) | the outgoing owner saw the applicant's photo | CASE WHEN … can_view_face(p_uid, c.applicant_id) |
| 14 | SQL self | get_own_profile() | profile/mutations.ts, export-user-data edge function, apps/web/app/status/status-client.tsx | self only, auth.uid(), no target parameter | unchanged |
| 15 | Raw | GET /rest/v1/profiles?select=avatar_url (and embeds) | none in product code; the guard test scans packages/**, apps/** | profiles_select is USING (true) and the column was granted: any signed-in user read every face | table-level REVOKE, then re-grant the 31-column allow-list without avatar_url; self reads use path 14 |
| 16 | Storage | avatars bucket listing (POST /storage/v1/object/list/avatars) | storage.supabase.ts uploads, builds public URLs, signs URLs and removes, and never lists | avatars read let ANY signed-in user list the whole bucket (<userId>-<ms>.<ext> names) | policy limited to the caller's own prefix (root <uid>-… and the legacy avatars/<uid>-…) |
Paths 7 to 9 are INVOKER functions that delegate every profile to get_public_profile_views; none selects profiles itself, so they needed no edit. Path 13 sits behind SECDEF readers, so the revoked client EXECUTE on can_view_face does not matter there.
The public object URL (/storage/v1/object/public/avatars/<name>) is the second storage fact: a public bucket serves any object to anyone who knows the exact name, through every route (verified on the local Storage API: the public, object/, object/authenticated/ and object/info/ routes all return 200 to a stranger). RLS governs listing, upload and delete only. Rendering relies on that, so it stays. See Residual risks.
Checked, not paths
| Surface | Evidence |
|---|---|
Whole-row JSON of a profiles row | One match, public.signals_claim_pushable (service-only, returns push_token, no photo). The new pgTAP scan pins this allow-list. |
| Other functions that mention avatar | private.anonymize_withdrawn_profiles writes avatar_url = NULL (erasure); public.dev_reconcile_seeded_world says "avatar tier rings" in a comment; patch_profile_visibility names the key. None reads the URL. |
| Columns that hold a face under another name | Only profiles.avatar_url. dm_threads.photo_url is a group-chat photo, club_posts.image_urls post media, court_venues.photo_urls venue photos: not a user's face. |
| Views, materialized views, policies, triggers, constraints | None references avatar_url (catalog dependency scan). The only policies that name an avatar are the four storage.objects ones. |
| Realtime | profiles is not in the supabase_realtime publication. |
GraphQL (pg_graphql) | Follows column privileges: verified on the local stack, avatar_url is an unknown field on profiles for a signed-in user after the revoke (REST select=avatar_url, select=* and an embed all return 403 / 42501). |
| Edge functions | export-user-data reads through get_own_profile (self). send-push, seed-scenario, simulate-activity use service credentials and never return a profile to a client. |
JSONB payloads (signals.payload, context, …) | No function writes an avatar into them (the source scan); canon already forbids PII values there. |
auth.users.raw_user_meta_data (OAuth picture) | Readable only by the user themself through the auth API. |
Client reads of profiles | Only insert … on conflict do nothing, update, and one dev tool update: writes need no SELECT on the column. No .select() and no embed names avatar_url. |
Replacing client embeds
The brief asked for the cheapest canonical replacement wherever the client embeds profiles(…, avatar_url). There are none to replace. Earlier slices (00394, 00539) already moved every cross-user read to the masked RPC family, and every adapter consumes it: get_public_profile_views is the batch read, get_public_profile_view the single one. A SECDEF masked view would have been a second projection of the same rule and one more object to keep in step; the RPC family already is the canonical shape (Protocol A bulk sibling included). If a future query needs a profile inside another read, it joins through get_public_profile_views in SQL, as the three bundles do. The raw shape cannot come back unnoticed: the column is revoked (a runtime failure) and packages/app/src/adapters/supabase/__tests__/profile-face-reads.test.ts fails the build with file and line.
What changes on screen (follows from the ruling, not extra)
A viewer outside the audience now sees the initials monogram in:
- discovery club cards: the seam stack is empty for a non-member (
ClubPhotoSeamrenders real photos only, so the capacity pill stands alone); - profile pages, search results, the three leaderboard tabs, the podium;
- session participant rows when the participant shares no club and is not a friend (see the open question);
- DM threads and the friends list for a stranger;
- the join-review screen's applicant (not yet a member) and the venue-owner transfer applicant.
Self, clubmates and friends see no difference. Cached faces refresh at the next fetch.
Decisions for the owner's veto
- Friendship counts (condition 3): mutual consent, as above.
- Explicit narrowing is honored.
friendsandprivatestill narrow below the default; the four conditions are necessary, not sufficient. The alternative (ignore the key unless it ispublic) would show a photo to clubmates of someone who chose "only me". - Legacy
publicis reset. Until 00675 the privacy screen merged its display default (avatar: 'public') into a whole-object write, so any edit of any field persistedavatar = 'public'for that user. "Public is an explicit opt-in" cannot honor a value written that way, so the migration stripsavatar = 'public'fromprofile_visibility(the last statement before the guard; delete it to keep legacy values). The local database holds no such row. A user who wants a public photo picks it again. can_view_faceis not client-executable. A caller-chosen viewer would let any signed-in user ask whether two other people are friends or clubmates (the reason 00394 revokedcan_view_profile_field). Every caller is SECDEF and passesauth.uid().
The one question for the owner
Pickup co-participants. A player in a pickup session who shares no club with you and is not your friend gets no face in the participant list, the strict reading of the ruling. Options: (a) keep it (initials until you share a club or become friends); (b) show faces to everyone confirmed in the same session, for the session's duration. Recommendation: (a), because the ruling names non-members and a pickup session is not a membership. It follows from the same line that the join-review applicant (a non-member) also shows initials.
Residual risks and what is not closed
- Capability URLs. A face URL, once shown, works for anyone who has it, for as long as the object exists (public bucket). The name is
<userId>-<ms epoch>.<ext>; a stranger who knows only the user id cannot enumerate it any more, and the client deletes the replaced object. The complete fix, a private bucket with short-lived signed URLs per viewer, changes every face render and its caching, so it is not part of this change. - A former clubmate keeps a URL they already saw until the owner replaces or removes the photo (same cause).
get_club_discovery_summarieskeepssearch_path = 'public', 'pg_temp'(the one documented exemption in00033_secdef_search_path_empty); the function was re-created with the same setting to keep this diff to the face expression.- Not verified on the hosted project. The main thread pushes the migration after review; the migration ends with a guard block that aborts the push if the closed state is not what it expects.
Prevention shipped
supabase/tests/00691_faces_members_only.test.sql(94 assertions): the column and predicate privileges; every masked path for a stranger, clubmate, friend, former clubmate, pending and blocked friend, an other-club member and the owner, each asserting the path still returned the row (a NULL can never be a vacuous pass); the four audience levels; anonymous callers; the venue-owner view; storage list, upload, replace and remove; and two source scans with canaries (every function that mentionsavatar_urlcallscan_view_face; no function emits a wholeprofilesrow as JSON).packages/app/src/adapters/supabase/__tests__/profile-face-reads.test.ts: no product code selectsavatar_urlfromprofilesor embeds it (detector canaries included).- The migration's own guard block; AGENTS.md DATA-27; the canon section.