docs/adr/0295-a-self-hosted-bot-admits-its-own-activity-viewers.md

ADR 0295: A self-hosted bot admits its own Activity viewers

Status: accepted (VUH-2833, 2026-10-25). Extends ADR 0233.

VUH-2594 closed the anonymous legacy /frames stream to everything except direct loopback. A self-hosted official bot's /activity launch reaches the viewer from discordsays.com through the tunnel, with no fragment and no credential, so it had nothing to admit it. Hosted bodies already admit through the gateway; self-hosting needed its own server half of Discord's Activity handshake.

The activity server stays a rendering client with no credentials. When an Activity application id is configured, it answers /.proxy/activity/config with {mode:"official", applicationId, delegated:true} for every request that is not direct loopback, and relays POST /.proxy/activity/admit {code,instanceId} over loopback to the service's POST /v1/activity/admit. That route accepts only the broker-held activity producer bearer, which both processes already resolve.

The service performs the handshake:

  1. It exchanges the code at Discord's OAuth2 token endpoint with the application's client secret, held only in the credential broker as discord_activity_client_secret and entered through /discord → Tokens. The token must carry identify; /users/@me names the viewer.
  2. With the bot token it reads GET /applications/{app}/activity-instances/{instanceId}. The instance must belong to this application, and the viewer must be in its users. Its location alone, never an SDK guild or channel claim, selects the destination.
  3. The destination must be a channel where Clankie's own bot posted the launch: a successful activity_start presence write records it, and activity_stop retires it and stops its shares. Each launch is its own incarnation and owns the shares it opened or claimed; a relaunch retires the previous one. Admissions and stop cleanup run one at a time across every channel, because all channels share one producer client whose status listing reconciles all of its shares. Choosing, claiming and granting a share never interleaves with cleanup ending it, here or in another channel. An admission rechecks its launch after every await and, once that launch is retired, ends what it took and refuses. Stop ends only retired launches' shares, so a replacement launch's media survives an older launch's cleanup. A caller waits at most ten seconds and is then refused; its abandoned admission takes nothing when it resumes. The queue stays held until that work settles, bounded by each producer request's own 15-second abort. Releasing it early is unsafe: the share client reconciles its status listings, so a stale listing delivered after the next admission opened a share would evict that share. The client itself now ignores a listing's silence about any share it learned after requesting that listing, which also protects shares the owner opens through the sharing API meanwhile. Stop cleanup always finishes. A participant in an instance someone else started gets nothing.
  4. It grants the channel's play share, opening one on first admission while play is running, and returns {session,grant,expiresAt,mediaPath,accessToken}.

Missing secrets fail closed with a 503 that names the console step. Every other failure is a 403, and no path falls back to anonymous media.

Grants last at most five minutes. A self-hosted client renews before expiry by authorizing again with prompt: "none" through the same SDK instance and admitting again, then swaps sockets without clearing the picture. Each renewal re-reads the instance.

Known limit

A viewer who leaves the voice channel, or loses permission, keeps the current grant until it lapses. Revocation is bounded at about five minutes, against hosted's 15-second revalidation. VUH-2834 tracks tightening it.

Room share tools launch through the same path (VUH-2835): with self-hosted admission configured, a room turn's start or image opens the share in the voice channel the bot occupies in that server and posts the invite through the room body's existing watch_start action, whose activity_start presence write records the launch above. No new launch authority exists; a transport failure is reported as uncertain, never resent. Room share operations run whole and in order across turns, because the invite is posted outside the sharing queue: a stop waits for a pending launch, so no older launch republishes after it. A caller's timeout does not prove the body stopped, so each room launch reserves its call id before the body call, a stop revokes the channel's reservations, and the presence route refuses a revoked launch's activity_start before it posts an invite or registers. A launch the route already accepted is checked again at every Discord effect the runtime commits (the invite post, each stale-invite delete and the message post) and before it registers. If it was revoked meanwhile, whatever already landed is taken back: its invite is revoked, which kills the link in any message it posted, unless a newer launch holds the channel. That check repeats before every invite the cleanup deletes, so cleanup that is still listing invites never reaches a replacement's. A stop waits a bounded ten seconds for such accepted launches to finish that cleanup; until they have, its receipt is uncertain, not confirmed.

A #share fragment still opens a delegated grant on a self-hosted viewer outside Discord. Hosted config never sets delegated, so hosted viewers keep their official-only behaviour.