lattice-social — Friends, presence, parties, invites, chat + WebSocket¶
A global friend graph with presence/rich-presence, parties + lobby gating, game/party invites,
chat, and private notes — with per-game cross-game visibility (tenancy). Source:
control-plane/lattice-social/src/Program.cs.
- Auth (two additive modes): (1) API-key server-to-server —
X-Api-Key(constant-time compared) +X-User-Idasserts the acting user (+ optionalX-User-Handle); (2) JWT —Authorization: Bearer <jwt>verified offline againstSocial:Auth:JwksUrl. Tenancy headersX-Lattice-Game-Key/X-Lattice-Game-Secretresolve a gameId + cross-game visibility filter. - Identity privacy (S4): the internal account GUID is never in any response. Users are
addressed by an opaque
publicIdand a full handleName#1234. - DTOs use camelCase. Public paths bypass auth:
/health,/healthz,/ready.
Self / users¶
| Method · Path | Purpose |
|---|---|
GET /v1/me |
MeResponse{publicId, handle, discriminator, fullHandle, profile:ProfileDto} (ProfileDto{displayName, avatarUrl, status, visibility, invisible}) |
PATCH /v1/me/profile |
{displayName?, avatarUrl?, status?, visibility?, invisible?} → ProfileDto |
POST /v1/me/name |
{displayName} → MeResponse (fans out friendProfileChanged) |
GET /v1/me/export |
privacy export {user, profile, friends[], blocked[], partyId?} |
DELETE /v1/me |
GDPR erasure → {state:"deleted"} |
GET /v1/users/search?handle= |
UserSummary[] (exact Name#1234 or prefix; block-gated) |
POST /v1/reports |
{targetHandle?, targetPublicId?, reason, context?} → 202 {status:"received"} |
Friends / blocks / mutes¶
| Method · Path | Purpose |
|---|---|
GET /v1/friends |
FriendSummary[]{publicId, fullHandle, displayName, presenceState, muted, richPresence?, note?, sameGame} (visibility-filtered) |
POST /v1/friends/requests |
{toHandle?, toPublicId?} → 201 RequestSummary{requestId, publicId, fullHandle, direction, state} |
GET /v1/friends/requests?dir=incoming\|outgoing |
RequestSummary[] |
POST /v1/friends/requests/{publicId}/accept |
{state:"accepted"} |
POST /v1/friends/requests/{publicId}/decline |
{state:"declined"} |
DELETE /v1/friends/requests/{publicId} |
204 (cancel outgoing) |
DELETE /v1/friends/{publicId} |
204 (remove friend) |
POST / DELETE /v1/blocks/{publicId} |
block / unblock |
POST / DELETE /v1/mutes/{publicId} |
mute / unmute |
Parties¶
PartyDto{partyId, leaderPublicId, maxSize, visibility, sessionHandle?, members:PartyMemberDto[], join?},
InviteDto{inviteId, kind, fromPublicId, toPublicId, targetId?, targetHandle?, state, expiresAt, join?},
JoinMetadataDto{lobbyId?, bag?}.
| Method · Path | Purpose |
|---|---|
POST /v1/parties |
{maxSize?, visibility?, join?} → 201 PartyDto |
GET /v1/parties/{id} |
members only (non-members 404) |
POST /v1/parties/{id}/invites |
{toHandle?, toPublicId?} → 201 InviteDto |
POST /v1/parties/{id}/join |
{inviteId} → JoinResultDto (gated; 403 yields no metadata) |
POST /v1/parties/{id}/leave |
{state:"left"} |
POST /v1/parties/{id}/kick |
{handle?, publicId?} → PartyDto |
POST /v1/parties/{id}/leader |
transfer leader → PartyDto |
POST /v1/parties/{id}/session |
{sessionHandle?} → PartyDto |
POST /v1/parties/{id}/visibility |
{visibility} (Public/FriendsOnly/Private) → PartyDto |
POST /v1/parties/{id}/join-metadata |
leader only · {lobbyId?, bag?} → PartyDto |
Presence & game invites¶
RichPresenceDto{gameId?, activity?, partyId?, joinable, sessionHandle?, extra?, join?, originGameId?}.
| Method · Path | Purpose |
|---|---|
GET /v1/presence/{publicId} |
PresenceResponse{publicId, state, richPresence?} (visibility-gated; 404 not_visible) |
POST /v1/presence |
{state?, richPresence?} (accepts Busy/DnD) → PresenceResponse |
POST /v1/invites/game |
{toHandle?, toPublicId?, sessionHandle?, join?} → 201 InviteDto |
POST /v1/invites/game/{inviteId}/accept |
JoinResultDto (returns join metadata) |
Notes & chat¶
| Method · Path | Purpose |
|---|---|
GET /v1/notes/{publicId} |
author-private note NoteResponse{subjectPublicId, subjectFullHandle, text, updatedAt} (404 no_note) |
PUT /v1/notes/{publicId} |
{text} → NoteResponse (empty → 204 clears) |
DELETE /v1/notes/{publicId} |
204 |
GET /v1/chat/{kind}/{channelId}/messages?limit= |
kind=dm|party; only when Features:Chat is on (else 404) |
WebSocket protocol — /v1/ws¶
Endpoint GET /v1/ws (LB-fronted wss://<host>:9443/v1/ws). Must be a WS upgrade (400
otherwise) and authenticated (401 otherwise; same middleware as HTTP). On connect the server
subscribes the socket to the user's notify:{userId} channel and (if in a party) party:{partyId},
and sets presence Online.
Envelope. Server→client frames are {"type":"<eventType>","data":<payload>}. Client→server
frames are flat objects with a type field.
Client → server frames¶
{ "type": "heartbeat" }
{ "type": "presenceUpdate",
"state": "online|away|busy|dnd|invisible",
"richPresence": { "gameId": "...", "activity": "...", "partyId": "<guid>?",
"joinable": true, "sessionHandle": "...",
"extra": {}, "join": { "lobbyId": "...", "bag": {} } } }
{ "type": "chatSend", "channel": "party|dm", "channelId": "<guid>", "body": "<text>" }
heartbeat refreshes the presence TTL. chatSend requires Features:Chat (else
error feature_disabled; missing fields → error bad_frame). Unknown type → error unknown_type;
unparseable → error bad_frame.
Server → client event types¶
Payloads are domain objects identifying users by publicId.
type |
Meaning |
|---|---|
friendPresenceChanged |
a friend's presence/rich-presence changed |
friendRequestReceived / friendRequestAccepted |
inbound friend request lifecycle |
friendRemoved {publicId} |
a friend removed you / was removed |
friendProfileChanged {publicId, handle/displayName} |
a friend's profile/name changed |
partyInviteReceived / partyMemberJoined / partyMemberLeft / partyLeaderChanged |
party lifecycle |
gameInviteReceived |
a game invite arrived |
chatMessage |
a chat message in a subscribed channel |
error {code, message} |
a problem with the last client frame |
sequenceDiagram
participant A as Client A
participant WS as social /v1/ws
participant B as Client B (friend)
A->>WS: upgrade + auth → subscribed to notify:{A}
A->>WS: {"type":"presenceUpdate","state":"online", "richPresence":{...}}
WS-->>B: {"type":"friendPresenceChanged","data":{publicId:A,...}}
B->>WS: {"type":"chatSend","channel":"dm","channelId":"<dm>","body":"hi"}
WS-->>A: {"type":"chatMessage","data":{fromPublicId:B,body:"hi",...}}