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¶
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¶
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