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:
- 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_secretand entered through/discord→ Tokens. The token must carryidentify;/users/@menames the viewer. - 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 itsusers. Itslocationalone, never an SDK guild or channel claim, selects the destination. - The destination must be a channel where Clankie's own bot posted the launch:
a successful
activity_startpresence write records it, andactivity_stopretires 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. - 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.
