Design doc for a user-scoped capability-invalidation event on the existing events WebSocket, so clients refresh cached capability payloads (e.g. /downloads/capability) when admin permission or server settings change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4.7 KiB
Handoff: account.capabilities_changed event on the events WebSocket
Date: 2026-07-06 Requested by: Apple client team (silo-apple) Status: Proposed — server implementation needed
Problem
Clients cache user-facing capability payloads (e.g. GET /api/v1/downloads/capability)
and have no way to learn that an admin changed something that affects them. Concrete
incident: enabling "Allow transcoded downloads" (users.download_transcode_allowed)
for a user did not surface the transcode quality presets in the iOS app until the
client's cache expired (previously 24h).
The Apple client now re-fetches capability on app-foreground and when the download options UI opens (pull-based safety net, shipped in silo-apple). This handoff covers the push half: an invalidation event so connected clients react immediately, without polling.
Existing infrastructure (no new transport needed)
- Events WebSocket:
GET /api/v1/events/ws(internal/api/handlers/events_ws.go), channel-based subscribe with per-role channel ACLs (allowedChannelsForRole). user_statechannel is already available to non-admin users and the hub already filters envelopes byenv.UserIDfor non-admins (allowsEventForClaims), so a user-targeted event on this channel is only delivered to that user (and admins).- Publishing helper:
evt.Hub.PublishJSON(ctx, channel, type, payload, opts)(internal/events/hub.go), pattern example ininternal/api/handlers/user_state_events.go.
Proposed contract
Channel: user_state (reuse; it is the only user-scoped channel non-admins can
subscribe to, and clients that care already subscribe to it).
Event type: account.capabilities_changed
Payload — intentionally minimal (invalidation ping, not a data carrier):
{
"scope": "downloads" | "playback" | "all"
}
Do not include the new capability values. Clients respond by re-fetching the REST
endpoints they already consume (/api/v1/downloads/capability, playback prefs, etc.).
This keeps the event contract trivial and guarantees it never drifts from the REST
response shapes. scope is a coarse hint so clients can skip irrelevant re-fetches;
when in doubt publish "all".
Targeting:
- Per-user change →
PublishOptions{UserID: <affected user>}so only that user's connections receive it. - Server-wide setting change → publish once with no
UserID(fan-out to all connected users). VerifyallowsEventForClaimspasses envelopes withUserID == 0to non-admins — it does today (env.UserID > 0guard).
Publish sites
- Admin user update handlers (
internal/api/handlers/admin.gouser create/update paths): publish scope"downloads"when any ofdownload_allowed,download_transcode_allowedchange;"playback"for playback-affecting fields (max_streams,max_transcodes, quality limits);"all"if simpler. Target the affectedUserID. - Server settings writes (settings handler / config store): publish untargeted
scope
"downloads"when anydownload.*key changes (notablydownload.transcode_enabled,download.enabled,defaults.download_*);"playback"forplayback.*gates that shape client-visible capability. - Access-group / policy-engine changes that alter
download/download_transcodeaction outcomes, if applicable — same event,"all"scope, untargeted (membership makes per-user targeting fiddly; the re-fetch is cheap).
No snapshot frame needed for this event type (snapshotForChannel for user_state
already returns null).
Client behavior (for reference / Android alignment)
- Apple: will add a
ServerEventsClientsubscribing touser_state+notifications; onaccount.capabilities_changedwith scopedownloads/all, calls its existing capability re-fetch. Until then the pull-based refresh already covers correctness. - Android: already has
NotificationsRealtimeClienton this socket; should handle the same event the same way. - Clients must treat the event as best-effort: sockets are down while backgrounded, so they keep refresh-on-foreground / refresh-on-use regardless.
Acceptance checks
- Toggle
download_transcode_allowedfor a connected non-admin user → that user's socket receivesaccount.capabilities_changed(scopedownloads); other users receive nothing. - Flip
download.transcode_enabledserver setting → all connected users receive the event once. - A non-admin subscribed to
user_statenever receives another user's targeted capability events (existingallowsEventForClaimsfiltering). GET /api/v1/downloads/capabilityimmediately after the event reflects the new presets (it already does — the event carries no data to go stale).