Skip to content

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-Id asserts the acting user (+ optional X-User-Handle); (2) JWTAuthorization: Bearer <jwt> verified offline against Social:Auth:JwksUrl. Tenancy headers X-Lattice-Game-Key / X-Lattice-Game-Secret resolve a gameId + cross-game visibility filter.
  • Identity privacy (S4): the internal account GUID is never in any response. Users are addressed by an opaque publicId and a full handle Name#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 typeerror 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",...}}