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'sFriendGraphand 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 row →
privacy.CanView(ctx, viewer, author, vis, loader). - List read → a request-scoped
FriendGraph(viaGraphFor) feedingListClause/BlockExclusioninto the SQLWHERE. - Membership →
FriendGraph.IsFriend/IsCloseFriend/IsBlocked.
Profile-level limiting (a closed or limited passport) is a separate axis:
the profile-visibility port (ProfileVisibilityReader.GetForViewer → Limited).
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¶
backend/internal/privacy/visibility.go— Visibility enum + CanViewbackend/internal/privacy/loader.go— RepoLoader implementationbackend/internal/privacy/sql.go— list-read predicate builderbackend/internal/models/close_friend.go— modelbackend/internal/services/friend/close_friend_store.go— persistencebackend/internal/services/friend/close_friend_service.go— owner-side toggle