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

pred := privacy.BuildListPredicate(g, viewerID, "m.user_id", "m.visibility")
db.Where(pred.Clause, pred.Args...)

The predicate 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 IN ($friend_set))
   OR (visibility = 'close_friends'  AND author_id IN ($close_set)))

Empty friend/close 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 BuildListPredicate 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.

Where to look