Skip to content

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):

  1. V is T.
  2. V and T share an active club membership (club_members.is_active).
  3. 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).
  4. 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 valueWho sees the face
absent / club_membersshared active club or accepted friend (the default)
friendsaccepted friends only
privateonly the owner
publicany 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.profiles and 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).

KindPathsFix
SQL projection, other users13each 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, self1get_own_profile() unchanged: the self read
Raw column (PostgREST)1REVOKE SELECT (avatar_url) from anon, authenticated (00539 / 00680 precedent)
Storage listing1the avatars SELECT policy is limited to the caller's own objects; the public object URL stays by design (Residual risks)

Paths ​

#KindPathClient file(s)BeforeFix in 00691
1SQL rootprivate.mask_profile_row(viewer, profiles), last defined 00503none (internal)'avatar_url', v_target.avatar_url, always visibleCASE WHEN … AND public.can_view_face(v_viewer, v_target.id) THEN avatar_url END
2SQLget_public_profile_view(uuid) → 1adapters/supabase/profile/reads.ts (findPublicView), presentation/hooks/queries/use-public-profile.tsvia 1via 1
3SQLget_public_profile_views(uuid[]) → 1profile/reads.ts (findByIds, findByClub), friendship.supabase.ts, dm.supabase.tsvia 1via 1
4SQLsearch_public_profiles(text, int) → 1profile/reads.ts (searchByDisplayName)via 1via 1
5SQLget_public_profile_leaderboard(text, int, int) → 1profile/leaderboard.ts (findTopByRating, getRegionalLeaderboard)via 1via 1
6SQLget_profile_view(uuid), non-self branch → 2profile/reads.ts (findById)via 1via 1 (self branch is path 13)
7SQLget_club_detail_bundle (INVOKER): memberProfiles, postAuthorProfiles, mediaUploaderProfiles → 3club-detail-bundle.supabase.tsvia 1via 1
8SQLget_session_detail_bundle (INVOKER): memberProfiles, participantProfiles → 3session-detail-bundle.supabase.tsvia 1via 1
9SQLget_home_snapshot (INVOKER): playerStats.profiles → 3home-snapshot.supabase.tsvia 1via 1
10SQLget_club_discovery_summaries, member_avatar_urls (anon-callable)club/discovery.ts, mapped in club/mappers.tspublic default, or club_members when the viewer is in THIS club; anon got public facesAND public.can_view_face((SELECT auth.uid()), p.id)
11SQLget_club_discovery_summaries, next_session_avatar_urls (anon-callable)samesame gating on the RSVP'd participantssame predicate
12SQLget_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 anonCASE WHEN … can_view_face((SELECT auth.uid()), p.id): NULL for anon
13SQLprivate.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 photoCASE WHEN … can_view_face(p_uid, c.applicant_id)
14SQL selfget_own_profile()profile/mutations.ts, export-user-data edge function, apps/web/app/status/status-client.tsxself only, auth.uid(), no target parameterunchanged
15RawGET /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 facetable-level REVOKE, then re-grant the 31-column allow-list without avatar_url; self reads use path 14
16Storageavatars bucket listing (POST /storage/v1/object/list/avatars)storage.supabase.ts uploads, builds public URLs, signs URLs and removes, and never listsavatars 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 ​

SurfaceEvidence
Whole-row JSON of a profiles rowOne match, public.signals_claim_pushable (service-only, returns push_token, no photo). The new pgTAP scan pins this allow-list.
Other functions that mention avatarprivate.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 nameOnly 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, constraintsNone references avatar_url (catalog dependency scan). The only policies that name an avatar are the four storage.objects ones.
Realtimeprofiles 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 functionsexport-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 profilesOnly 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 (ClubPhotoSeam renders 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 ​

  1. Friendship counts (condition 3): mutual consent, as above.
  2. Explicit narrowing is honored. friends and private still narrow below the default; the four conditions are necessary, not sufficient. The alternative (ignore the key unless it is public) would show a photo to clubmates of someone who chose "only me".
  3. Legacy public is reset. Until 00675 the privacy screen merged its display default (avatar: 'public') into a whole-object write, so any edit of any field persisted avatar = 'public' for that user. "Public is an explicit opt-in" cannot honor a value written that way, so the migration strips avatar = 'public' from profile_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.
  4. can_view_face is 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 revoked can_view_profile_field). Every caller is SECDEF and passes auth.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_summaries keeps search_path = 'public', 'pg_temp' (the one documented exemption in 00033_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 mentions avatar_url calls can_view_face; no function emits a whole profiles row as JSON).
  • packages/app/src/adapters/supabase/__tests__/profile-face-reads.test.ts: no product code selects avatar_url from profiles or embeds it (detector canaries included).
  • The migration's own guard block; AGENTS.md DATA-27; the canon section.

Markdown remains the source of truth. Run yarn docs:check before handoff.