Skip to content

Privacy

internal/privacy is the single chokepoint for "who can see what". Every read that surfaces user content runs through CanView (single row) or a SQL predicate built from a FriendGraph (list reads). The graph itself is request-scoped and memoized on context so a request that gates N rows only touches the friend graph once.

Visibility model

The shared models.Visibility enum is used by every user-content carrier that accepts an audience choice (moments, user-stamp mint-holders, user-profile passport). Four values:

Value Audience
public Anyone authenticated
friends The author + the author's accepted friends
close_friends The author + everyone in the author's curated close-friends set
private The author only

Events use the parallel models.EventVisibility enum (public, friends, private). Event visibility is similar in spirit but the gate has an event-specific branch for private, which is participant-based, not friend-based.

Close friends

Close-friends is an owner-scoped, asymmetric list backing the VisibilityCloseFriends audience. Each row in the close_friends table is (owner_id, friend_id); "Alice considers Bob close" doesn't mean Bob considers Alice close. The audience must always remain a subset of the friend set; the service-layer add validates this.

Method Path Description
GET /api/v1/friends/close The caller's close-friend ids
POST /api/v1/friends/:id/close Add. 403 if the pair isn't an accepted friendship. Idempotent.
DELETE /api/v1/friends/:id/close Remove. Idempotent.

The FE renders an inline star toggle on each row in /hub/friends and a filter chip to flip the list between all friends and close-friends only.

The gate

allowed, err := privacy.CanView(ctx, viewerID, authorID, visibility, loader)

CanView is a four-line lookup:

  • viewer == author → true (the author always sees their own row).
  • public → true.
  • private → false (unless the author check above already returned).
  • friends → load the viewer's FriendGraph and check membership.
  • close_friends → same, but against the close-friends set.

Loader is the seam where the package meets the friendship repos. The production loader is RepoLoader{Friends, Close}; tests pass a literal FriendGraph directly.

FriendGraph + context memoization

type FriendGraph struct {
    ViewerID uuid.UUID
    Friends  map[uuid.UUID]struct{}
    Close    map[uuid.UUID]struct{}
}

The graph is loaded on first read and cached on context via GraphFor so a request that gates N rows runs the friend-graph load once. The cache key is the viewer id; a different viewer in the same context forces a reload (rare in production, where each request runs as one principal).

g, err := privacy.GraphFor(ctx, viewerID, loader)
// Subsequent CanView(ctx, viewer, ...) calls reuse g

List reads: SQL predicate pushdown

clause := privacy.ListClause(viewerID, "m.user_id", "m.visibility",
    friendIDs, closeOwnerIDs, blockedIDs, b.Arg)
b.Where(clause)

arg (here sqlbuilder's b.Arg) records each bound value and returns its placeholder, so the seam owns the visibility algebra while the caller owns the placeholder dialect. The clause composes into a single WHERE fragment so the gate runs at the DB instead of in a per-row loop. The shape:

(visibility = 'public'
   OR author_id = $viewer
   OR (visibility = 'friends'        AND author_id = ANY($friend_set::uuid[]))
   OR (visibility = 'close_friends'  AND author_id = ANY($close_set::uuid[])))
AND author_id <> ALL($blocked::uuid[])

Because close-friend inclusion is decided by the author's list, the caller passes closeOwnerIDs = the authors who marked the viewer close (the inverse relation the viewer's own graph can't carry). A block edge overrides every branch, public included, via the trailing <> ALL that BlockExclusion emits; curated reads that already restrict visibility more tightly (the map's friends-only standalone-moment pins) append BlockExclusion on its own rather than the full clause. Empty friend/close/blocked sets drop their branch cleanly so the query never emits an invalid IN ().

How services use it

Surface Pattern
MomentService.GetMomentDetail CanView after loading the moment row
EventService.GetEventDetail CanView for friends-visibility events; participant-table check for private events
Map / radar / feed ListClause / BlockExclusion in the SQL WHERE

DiscoveryService no longer hosts viewer-projected detail reads. Discovery still owns aggregate reads (map, radar, intent, location detail, user profile) where composition across domains is the value; single-aggregate reads moved into their owning service so the gate is a single helper away and the domain logic stays one layer deep.

The rule: one seam, never reimplement

Viewer-scoped visibility is decided in exactly one place: internal/privacy. A domain that surfaces user content consumes the seam; it never re-derives "can this viewer see this" from the friendship graph and a visibility value by hand.

  • Single rowprivacy.CanView(ctx, viewer, author, vis, loader).
  • List read → a request-scoped FriendGraph (via GraphFor) feeding ListClause / BlockExclusion into the SQL WHERE.
  • MembershipFriendGraph.IsFriend / IsCloseFriend / IsBlocked.

Profile-level limiting (a closed or limited passport) is a separate axis: the profile-visibility port (ProfileVisibilityReader.GetForViewerLimited). It composes with the friend-graph seam; it is not a reason to hand-roll one.

Anti-pattern (do not add; remove on sight). A domain-local "visibility ladder" that rebuilds the friend / close / public scope set from the friendship repo itself (for example a momentVisibilityLadder living inside a service). It duplicates the seam, drifts from it as rules evolve, and scatters the privacy contract across domains. Replace it with a FriendGraph (for the friend-graph axis) plus the profile-visibility port (for the passport-limited axis).

Where to look