* [studio] Complete editor uploads and queued cloud builds * [studio] Protect unpublished source and remove unused notification RPC
notify
Notifications Worker served on the notify subdomain. Hosts a SignalR hub at
/hub/v1.
The hub is implemented as a Durable Object (NotificationsHub) speaking the
SignalR JSON Hub Protocol over a hibernatable WebSocket. A single global DO
instance holds the shared hub state (one process across all connections).
Endpoints
POST /hub/v1/negotiate— SignalR negotiation. Returns aconnectionId/connectionTokenand advertises the WebSocket transport. The token is passed back as?id=on the WebSocket connect.GET /hub/v1(WebSocket upgrade) — the hub. Forwarded to the Durable Object. Non-upgrade requests get426.POST /internal/notify—{ playerId, notificationType, data? }. Send to a player's connections, queueing if they're offline. Admin-gated: requires a Bearer token carrying an admin role (developer/moderator) in itsroleclaim.POST /internal/broadcast—{ notificationType, data? }. Send to every connected client. Same admin-role gate as/internal/notify.POST /internal/coach-message-all—{ messageContent }. Broadcast a coach/systemMessageReceivedto every connected client (online-only; not persisted). Same admin-role gate.
Every /internal/* call — including one the role gate refuses, and one that 404s — is
recorded on the shared database's audit_log table, against the account that made it:
player_id is the caller, action is the path as a snake_case verb (coach_message),
and data carries the request body or query string plus the response status. The write
happens in the admin gate, so a new /internal/* endpoint is audited the day it is added.
A token carrying an admin role but no usable sub is refused (401) rather than let through
unattributable. A failed audit write never fails the call; it leaves an error in the worker
log. The table is owned by the api worker (apps/api/migrations/0024_audit_log.sql).
Hub protocol
- Client
POST /hub/v1/negotiate, then opens a WebSocket to/hub/v1?id=<token>. - Handshake: client sends
{"protocol":"json","version":1}␞, server replies{}␞(␞= record separator0x1e). - Server immediately sends the
OnConnectinvocation on connect. - Client→server invocations:
SubscribeToPlayers({ playerIds })— replaces this connection's subscriptions and flushes any queued notifications for those players.GetSubscriptions()→ completion with the subscribed player id array.
- Server→client:
Notificationinvocations carrying a JSON string{ Id: <PushNotificationId>, Msg: { ... } }. - Pings (type 6) are echoed.
State
Held in the DO's SQLite so it survives hibernation:
subscriptions(connectionId, playerId)— serves both the connection→players and player→connections lookups.pending(id, playerId, payload)— per-player queue delivered once the player subscribes.
A connection's rows are removed on webSocketClose.
TODO before production
- Authenticate the
/internal/*endpoints (or move them to a service binding / RPC reachable only by other workers). - Wire the real callers (relationships, gifts, storefront, events, chat, …) to
push through
notifyPlayer/broadcast. - Consider server-initiated keepalive pings if clients rely on them.