Discord: customers shouldn't need the main DRB bot in their server — radio bots (via C2) should cover linking, channel directory and permission checks #225

Closed
opened 2026-10-05 00:14:39 -04:00 by logan · 11 comments
Owner

Today: the radio itself only needs a radio bot, but setting a server up requires the main DRB bot to be in it as well:

  • /drb link is a main-bot command (#218). Without the main bot, an org owner can't self-link; only an admin can, from /settings/discord.
  • The voice-channel directory behind the node-page picker (#220) is reported by the main bot (PUT /discord/guilds/{id}/directory).
  • The planned request-time permission check ("radio bot X can't Speak here") would also come from the main bot.

That makes customers invite two bots.

Direction to evaluate:

  • C2 uses the radio bot tokens it already holds to call Discord REST: guild list, channels, its own member and roles, and the channel overwrites needed to compute effective View Channel / Connect / Speak.
  • Radio bot gateway sessions on nodes are deliberately short-lived (they log out when not streaming, #216), so C2 has to do this over REST, not through those sessions.
  • Link the server through the OAuth2 bot-invite flow, with the link code carried in state so the redirect returns guild_id, instead of a slash command.
  • The main bot stays optional: alerts, utility commands and trips.

The request-time permission check is the first consumer of this. Owner asked for it on 2026-10-05.

🤖 Filed by Claude Code

**Today:** the radio itself only needs a radio bot, but setting a server up requires the main DRB bot to be in it as well: - `/drb link` is a main-bot command (#218). Without the main bot, an org owner can't self-link; only an admin can, from /settings/discord. - The voice-channel directory behind the node-page picker (#220) is reported by the main bot (`PUT /discord/guilds/{id}/directory`). - The planned request-time permission check ("radio bot X can't Speak here") would also come from the main bot. That makes customers invite two bots. **Direction to evaluate:** - C2 uses the radio bot tokens it already holds to call Discord REST: guild list, channels, its own member and roles, and the channel overwrites needed to compute effective View Channel / Connect / Speak. - Radio bot gateway sessions on nodes are deliberately short-lived (they log out when not streaming, #216), so C2 has to do this over REST, not through those sessions. - Link the server through the OAuth2 bot-invite flow, with the link code carried in `state` so the redirect returns `guild_id`, instead of a slash command. - The main bot stays optional: alerts, utility commands and trips. The request-time permission check is the first consumer of this. Owner asked for it on 2026-10-05. 🤖 Filed by Claude Code
Author
Owner

Owner requirement (2026-10-05). Both setups must work:

  1. Single bot (no main bot): the org invites only the radio bot for their system and marks it preferred for that system. That alone gets them streaming. Linking, the channel list and the permission check all work without the main DRB bot.
  2. Full setup: the main DRB server bot plus several radio bots in the same Discord server. C2 decides which token can serve which stream, based on server membership, effective permissions on the target voice channel, the one-voice-channel-per-guild limit, and preferred-for-system. It picks automatically and explains when nothing fits.

The main bot is optional in every flow.

**Owner requirement (2026-10-05)**. Both setups must work: 1. **Single bot (no main bot):** the org invites only the radio bot for their system and marks it preferred for that system. That alone gets them streaming. Linking, the channel list and the permission check all work without the main DRB bot. 2. **Full setup:** the main DRB server bot plus several radio bots in the same Discord server. C2 decides which token can serve which stream, based on server membership, effective permissions on the target voice channel, the one-voice-channel-per-guild limit, and preferred-for-system. It picks automatically and explains when nothing fits. The main bot is optional in every flow.
Author
Owner

server-26#225: final implementation plan (judged from 3 designs)

Grounded in Server origin/main 16a609e and Client origin/main 5770eca (node-26#38). I checked every claim kept below against the source.

Verdict

Design Correctness Security Effort Customer experience Total /40
0: MVP-first 8 7 9 7 31 (winner)
1: security-first 6 9 5 7 27
2: customer-first 6 7 5 9 27

Why Design 0 wins. It puts the check inside streams.start_stream, before the lease, and feeds the result into pick_token. That is the only placement that covers every caller. There are four entry points:

  • bot POST /discord/streams
  • web picker POST /discord/nodes/{id}/streams
  • routers/nodes.py discord_join
  • the reconciler's re-place in reconcile_once

Designs 1 and 2 add the check in the routers, so they miss nodes.py and the reconciler.

What I took from the other two:

  • From Design 1:
    • REST hygiene: User-Agent, a breaker on the invalid-request budget, the token never logged, /users/@me/guilds paginated.
    • The rule that a fallback-org server is never discovered over REST.
    • Do not turn on "Requires OAuth2 Code Grant".
    • The isolation tests.
  • From Design 2:
    • Per-bot OAuth, which matches owner requirement #1.
    • Permissions fetched in the same request right after a link.
    • Fix-it copy that names the bot, the channel and the toggle.
    • The "bot busy on another node" state.
    • The tokens._masked allowlist.

What I dropped:

  • Design 1's Firestore snapshot collections. An in-process cache is enough for one c2-core process, the same assumption _RateLimiter and _publish_locks already make.
  • Design 2's folding of #223 into this work. That is scope creep. #223 stays separate.

Findings none of the three designs caught (verified in code)

  1. Preferred-for-system is never used.
    • PUT /tokens/{id}/prefer/{system_id} writes systems.preferred_token_id.
    • start_stream never reads it. Only an explicit preferred_token_id parameter reaches pick_token.
    • The owner requirement (comment on this issue) depends on it. Fixed in S3.
  2. A signed-out user loses the OAuth code.
    • middleware.ts redirects signed-out requests to /login on the server, and new URL("/login", …) drops the query.
    • Design 1's "stash code/state in sessionStorage first" therefore never runs.
    • The callback path must be added to NO_SESSION_COOKIE_GATE, so the page does its own auth check. Fixed in S7.
  3. The fallback stop lives in the routers.
    • The rule "re-linking a server that was acting through the fallback org stops its streams" is in discord_link.py (redeem) and discord.py (admin link). It is not in _redeem_txn.
    • OAuth must reuse it, so extract it as a helper and do not copy it. Fixed in S6.
  4. The reconciler calls start_stream every 30 s for each persistent stream that is not placed.
    • Without a cache and a negative TTL, that becomes a REST loop against Discord.
    • The cache in S2 is required, not optional.
  5. Reversed design principle. The discord_web.py docstring says "C2 never calls Discord with a token on the browser's behalf". This plan reverses that on purpose. Update the docstring, and keep the org-scoping and rate limits listed below.

Design 0's worktree claim is correct: fix/213-multi-stream-per-channel is at 16a609e with no commits.

Slices

Parallel-safe means no shared files with the other slices in the same wave.

S1: permission math (new files only; wave 1, parallel)

  • Files: app/internal/discord_perms.py, tests/test_discord_perms.py.
  • effective_permissions(guild, member, channel) -> (bits, trace), in Discord's order:
    1. Owner gets everything.
    2. Base = @everyone OR the member's roles. ADMINISTRATOR gets everything.
    3. Apply the @everyone overwrite.
    4. Apply the member's role overwrites: OR all their denies, then OR all their allows.
    5. Apply the member overwrite last.
    6. No VIEW_CHANNEL means 0.
    7. If the bot is timed out, keep only View and Read History.
  • check_voice(bits, channel) -> {ok, missing[], user_limit, limit_bypass}
    • Required bits are VIEW 1<<10, CONNECT 1<<20, SPEAK 1<<21, the same as RADIO_BOT_PERMISSIONS.
    • MOVE_MEMBERS 1<<24 bypasses the user limit.
    • Only type 2 channels count.
    • Labels match the edge's strings exactly ("View Channel", "Connect", "Speak").
  • trace names what removed each bit, e.g. "denied by #dispatch's override for @Muted".
  • Permission integers are parsed from strings, and values above 2^53 are tested.

S2: REST client, cache and settings (wave 1, parallel with S1)

  • Files: new app/internal/discord_rest.py; the bottom half of discord_leases.py (inspect_token, refresh_membership, leave_guild move onto the client); config.py (every new setting for every slice, added here once); tests/test_discord_rest.py.

  • Client:

    • One shared httpx.AsyncClient with User-Agent DiscordBot (https://drb.cusano.net, 1).
    • Per-token buckets keyed by X-RateLimit-Bucket plus guild_id.
    • On a 429, retry once only if retry_after ≤ 3 s; otherwise the result is unknown. A global 429 pauses that token.
    • A process-wide breaker on 401, 403 and non-shared 429 responses, set at 4000 per 10 min (Discord bans the IP at 10k). When it trips, results are unknown. That also protects the main bot, which shares the egress IP.
    • Never logs headers or the token.
  • Status mapping:

    Response Action
    401 valid=False; never retried
    10004 / 10007 not a member; drop the guild from guild_ids
    50001 unknown; confirm with one membership refresh, and do not mutate guild_ids from it alone
  • Snapshot per (token, guild): three GETs:

    • /guilds/{g}
    • /guilds/{g}/members/{bot_user_id} (needs no privileged intent)
    • /guilds/{g}/channels
  • Cache: in-process, keyed by (token_id, guild_id). Allowed results live 120 s; denied, not-member and invalid results live 600 s. Concurrent loads are single-flighted. invalidate(token_id=None, guild_id=None) clears it.

  • Pagination: /users/@me/guilds pages with after=. Today it reads limit=200 once and silently truncates.

  • Settings:

    • discord_perm_check_enabled=True (kill switch)
    • discord_access_ttl_seconds=120
    • discord_rest_invalid_budget=4000
    • discord_oauth_redirect_uri="" (empty means OAuth is off)
    • discord_pool_bot_guild_cap=90

S3: wire the check into token picking (MVP backend) (wave 2; needs S1 and S2)

  • Files: the top half of discord_leases.py; discord_streams.py; new routers/discord_checks.py; one include line in main.py; tests.
  • pick_token(..., perms=None):
    • Skips a token whose result is denied and collects the reason.
    • In _candidate_order, confirmed-ok sorts before unknown.
    • New error missing_permissions with denied=[{name, missing, reason}]. It is returned only when no bot is usable and at least one member bot was denied.
    • The reuse branch in lease_for_stream falls through when its token is denied.
  • start_stream:
    1. Default preferred_token_id to systems/{id}.preferred_token_id (finding 1).
    2. Compute permissions for the tokens that pick_token could choose: up to 4 at once, 3 s budget, fail-open on unknown.
    3. Pass the result into lease_for_stream.
    4. When every bot is denied, raise a 409 StreamError("missing_permissions", precise text). Example text: "DRB Radio 2 can't Speak in #dispatch (denied by the channel's override for @Muted). In Discord: Edit Channel → Permissions → allow Speak, then Re-check."
    5. The bot /join shows this text through _refusal_text, and the reconciler shows it through _mark.
  • handle_actual: the codes missing_permissions, channel_full, not_in_guild and bad_channel call invalidate(token_id, guild_id).
  • GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check?refresh=0|1:
    • Auth: _node_for_caller, plus the guild must be in _org_guilds(node.org_id). Anything else is a 404.
    • Returns per channel {user_limit, any_ok, bots:[{name, ok, missing, reason, available}]}.
    • Bot names only: no token ids for non-admins, never token strings.
    • refresh=1 is limited to 1 per 10 s per uid and guild.
  • GET /discord/guilds/{guild_id}/voice-check?channel_id=: require_service_key_or_admin.
  • User limit is advisory only. REST can't see occupancy, so the edge's channel_full stays the enforcement.

S4: picker UI (MVP frontend) (wave 2, parallel with S3; build against the S3 JSON contract)

  • Files: NodeDiscordRadio.tsx, lib/discordPicker.ts, discord/StreamsPanel.tsx (AddChannelForm), lib/c2api.ts (one block after removeNodeDiscordStream).
  • Each channel row gets a badge: Ready / Needs fix (missing permission named, with the fix text) / Couldn't check / Limit N.
  • Start is disabled only on a definite deny.
  • Footer: "Checked 40s ago · Re-check". Per-bot details expand.
  • The 409 refusal is shown inline.
  • Run drb-qa-review and tsc --noEmit (#207).

Ship S1–S4 as the MVP. Run deploy-check, then the live smoke test (see rollout).

S5: channel list and presence without the main bot (wave 3; needs S3)

  • Files: routers/discord_web.py, plus discord_rest.guild_directory() (append-only).
  • _org_guilds shows a server when org_id == org and either the main bot's bot_present is set or a valid eligible radio bot's guild_ids contains the server.
    • Servers that resolve only through the fallback org still require the main bot (Design 1's discovery guard).
    • REST never creates discord_guilds docs for unlinked servers.
  • join_options: when directory_at is older than 5 min, refresh the channel list in the background from any eligible member radio bot's cached /channels. It writes voice_channels, guild_name, directory_at, directory_source, radio_bot_present.
  • start_node_stream: checks the channel against that merged list.
  • The main bot's PUT …/directory stays and also invalidates the guild's cache entry.
  • Spike first: does /guilds/{g}/channels return channels the bot can't View? The copy depends on the answer.

S6: OAuth linking backend (wave 3, parallel with S5; needs S2)

  • Files:
    • new routers/discord_oauth.py
    • discord_guild_link.py: extract link_guild_txn(org_id, guild_id, guild_name, linked_by, via) from _redeem_txn, and an async link_and_settle() that runs stop_guild when the org it resolves to changes (finding 3)
    • routers/discord_link.py and the routers/discord.py link route call link_and_settle()
    • routers/tokens.py: _masked becomes an allowlist; add PUT /tokens/{id}/oauth (admin), which checks the secret with a client-credentials grant and never echoes it
    • one include line in main.py
    • tests/test_discord_oauth.py
  • Secrets go in a new server-only collection bot_oauth/{token_id} {client_secret, enabled}, never on bot_tokens.
  • POST /discord/oauth/start (require_org_owner_token + resolve_org_scope; at most 5 per 10 min per uid):
    • Picks the bot in this order: the system's preferred bot (when node_id is given), else the org's BYO bot, else the pool bot with the fewest servers that is under the cap and has OAuth enabled.
    • Stores discord_oauth_states/{sha256(nonce)} = {org_id, uid, token_id, return_to, expires_at: +10m, used_at}.
    • Returns the authorize URL: scope=bot, permissions=RADIO_BOT_PERMISSIONS, response_type=code, the exact configured redirect_uri, state.
    • If nothing is configured it returns 404 and the UI hides the button.
  • POST /discord/oauth/complete (Firebase, body {code, state}):
    1. A transaction consumes the state: it must exist, be unexpired and unused, and state.uid must equal the caller (login-CSRF). The state is consumed even when a later step fails.
    2. Re-check that the caller is still owner of the org, from a fresh read.
    3. Exchange the code with that app's secret.
    4. The guild is taken from the exchange's guild object. The query guild_id is ignored. If the exchange has no guild, return 400.
    5. Run link_and_settle(via="oauth"). The never-move-between-orgs rule gives a 409.
    6. Add the guild to guild_ids, revoke the access token, write audit discord.guild.link, invalidate the cache.
    7. Fetch permissions in the same request, with a 5 s cap.
    8. Return {guild_name, channels_ready, channels_blocked, return_to}.

S7: OAuth UI (wave 3, parallel with S5 and S6; build against the S6 contract)

  • Files: new app/discord/connected/page.tsx; middleware.ts (add the path to NO_SESSION_COOKIE_GATE); DiscordServersCard.tsx; discord/RadioBotsPanel.tsx (form to set the secret, plus an "N of 100 servers" count); new lib/discordOAuth.ts (stays out of c2api.ts).
  • The callback page:
    1. Stashes code and state, then calls replaceState, with <meta name=referrer content=no-referrer>.
    2. Sends a signed-out user to login, then resumes.
    3. Treats error=access_denied as "Nothing changed".
    4. Shows a result card: "Linked Server X: 5 ready, 2 need a fix → Add a radio channel on node Y".
  • On DiscordServersCard, "Connect a Discord server" becomes the primary button and /drb link becomes a collapsed secondary option.
  • Run drb-qa-review and the security-review skill (on S6 and S7).

S8: main bot optional, cleanup (wave 4; after S5 and S7 are live)

  • Remove copy that tells customers to invite the main bot (picker, ServersPanel.tsx, bot /help).
  • Admin overview gets a "main bot present" column.
  • Optional: the main bot's directory cog posts an invalidate on channel or role updates.
  • Update the discord_web.py docstring.
  • Confirm the deploy health gate doesn't depend on the discord-bot container.
  • Close #225.

Merge order

  1. S1 + S2 (no behaviour change beyond pagination, User-Agent and 401 handling).
  2. S3.
  3. S4. This is the MVP. Run deploy-check, then the smoke test.
  4. Rebase the multi-stream branch onto S3. Its stream_id change does not touch the cache keys, which are (token, guild, channel).
  5. S5.
  6. S6.
  7. S7.
  8. S8.

#223 (the caps race) stays its own PR, after S3. It touches the same function but different lines.

Rollout notes

  • No Client or edge change. node-26#38's _join_refusal stays as the backstop at join time.
  • Kill switch: set DISCORD_PERM_CHECK_ENABLED=false to restore today's token picking exactly.
  • Fail-open on unknown, everywhere. A Discord outage must never block a start, and must never starve the reconciler.
  • Smoke test after S4:
    1. In a test server, deny Speak to @everyone on one channel.
    2. Expect the picker badge.
    3. Expect a 409 with the same text from the web picker, /join and the reconciler.
    4. Fix the permission in Discord, press Re-check, and expect success.
  • OAuth, per bot app:
    • Register the redirect URI https://drb.cusano.net/discord/connected.
    • Set the secret through PUT /tokens/{id}/oauth.
    • Do not enable "Requires OAuth2 Code Grant": it breaks every plain invite_url in use today.
    • The routes stay at 404 until the first secret is set.
  • Rules check before S6 stores any secret. Firestore rules may not be live (DEFERRED.md infra/firestore row, #161). Read bot_tokens and bot_oauth with a non-admin ID token and expect PERMISSION_DENIED. If that read succeeds, the bot tokens are already exposed, and that comes first.
  • Spikes (about 30 min each, test server):
    • Does /channels return channels the bot can't view?
    • Does a scope=bot code exchange return guild?
    • Is a bot without Use Voice Activity still heard?
  • File as follow-ups:
    • Automatic failover when the edge reports missing_permissions.
    • A DEFERRED.md entry: voice occupancy can't be seen over REST, so the edge is the authority on whether a channel is full.

Open questions for the owner

  1. OAuth per bot or one "link app"?
    • Per bot: you enter each radio bot's client secret. This meets "invite only the radio bot for your system".
    • One link app: a single secret in env, but an org whose preferred bot is a different one invites two bots.
    • Recommend per bot.
  2. Who may mark a bot preferred for a system?
    • Today only a platform admin can (PUT /tokens/{id}/prefer).
    • Should org owners set it for their own systems and their own BYO bots?
    • Systems with no org_id are shared, so a preference set there would affect other orgs.
  3. Pool bots and the 100-server limit. Unverified Discord bots can't join more than 100 servers. Should DRB apply for verification, or cap each bot at 90 servers and add bots? The cap affects which bot "Connect" picks.

🤖 Posted by Claude Code

# server-26#225: final implementation plan (judged from 3 designs) Grounded in Server `origin/main` 16a609e and Client `origin/main` 5770eca (node-26#38). I checked every claim kept below against the source. ## Verdict | Design | Correctness | Security | Effort | Customer experience | Total /40 | |---|---|---|---|---|---| | 0: MVP-first | 8 | 7 | 9 | 7 | **31 (winner)** | | 1: security-first | 6 | 9 | 5 | 7 | 27 | | 2: customer-first | 6 | 7 | 5 | 9 | 27 | **Why Design 0 wins.** It puts the check inside `streams.start_stream`, before the lease, and feeds the result into `pick_token`. That is the only placement that covers every caller. There are **four** entry points: - bot `POST /discord/streams` - web picker `POST /discord/nodes/{id}/streams` - `routers/nodes.py` `discord_join` - the reconciler's re-place in `reconcile_once` Designs 1 and 2 add the check in the routers, so they miss `nodes.py` and the reconciler. **What I took from the other two:** - **From Design 1:** - REST hygiene: User-Agent, a breaker on the invalid-request budget, the token never logged, `/users/@me/guilds` paginated. - The rule that a fallback-org server is never discovered over REST. - Do not turn on "Requires OAuth2 Code Grant". - The isolation tests. - **From Design 2:** - Per-bot OAuth, which matches owner requirement #1. - Permissions fetched in the same request right after a link. - Fix-it copy that names the bot, the channel and the toggle. - The "bot busy on another node" state. - The `tokens._masked` allowlist. **What I dropped:** - Design 1's Firestore snapshot collections. An in-process cache is enough for one c2-core process, the same assumption `_RateLimiter` and `_publish_locks` already make. - Design 2's folding of #223 into this work. That is scope creep. #223 stays separate. ## Findings none of the three designs caught (verified in code) 1. **Preferred-for-system is never used.** - `PUT /tokens/{id}/prefer/{system_id}` writes `systems.preferred_token_id`. - `start_stream` never reads it. Only an explicit `preferred_token_id` parameter reaches `pick_token`. - The owner requirement (comment on this issue) depends on it. **Fixed in S3.** 2. **A signed-out user loses the OAuth code.** - `middleware.ts` redirects signed-out requests to `/login` on the server, and `new URL("/login", …)` drops the query. - Design 1's "stash `code`/`state` in sessionStorage first" therefore never runs. - The callback path must be added to `NO_SESSION_COOKIE_GATE`, so the page does its own auth check. **Fixed in S7.** 3. **The fallback stop lives in the routers.** - The rule "re-linking a server that was acting through the fallback org stops its streams" is in `discord_link.py` (redeem) and `discord.py` (admin link). It is not in `_redeem_txn`. - OAuth must reuse it, so extract it as a helper and do not copy it. **Fixed in S6.** 4. **The reconciler calls `start_stream` every 30 s** for each persistent stream that is not placed. - Without a cache and a negative TTL, that becomes a REST loop against Discord. - The cache in S2 is required, not optional. 5. **Reversed design principle.** The `discord_web.py` docstring says "C2 never calls Discord with a token on the browser's behalf". This plan reverses that on purpose. Update the docstring, and keep the org-scoping and rate limits listed below. Design 0's worktree claim is correct: `fix/213-multi-stream-per-channel` is at 16a609e with no commits. ## Slices Parallel-safe means no shared files with the other slices in the same wave. **S1: permission math** (new files only; wave 1, parallel) - **Files:** `app/internal/discord_perms.py`, `tests/test_discord_perms.py`. - **`effective_permissions(guild, member, channel) -> (bits, trace)`**, in Discord's order: 1. Owner gets everything. 2. Base = @everyone OR the member's roles. ADMINISTRATOR gets everything. 3. Apply the @everyone overwrite. 4. Apply the member's role overwrites: OR all their denies, then OR all their allows. 5. Apply the member overwrite last. 6. No VIEW_CHANNEL means 0. 7. If the bot is timed out, keep only View and Read History. - **`check_voice(bits, channel) -> {ok, missing[], user_limit, limit_bypass}`** - Required bits are VIEW 1<<10, CONNECT 1<<20, SPEAK 1<<21, the same as `RADIO_BOT_PERMISSIONS`. - MOVE_MEMBERS 1<<24 bypasses the user limit. - Only type 2 channels count. - Labels match the edge's strings exactly ("View Channel", "Connect", "Speak"). - **`trace`** names what removed each bit, e.g. "denied by #dispatch's override for @Muted". - Permission integers are parsed from strings, and values above 2^53 are tested. **S2: REST client, cache and settings** (wave 1, parallel with S1) - **Files:** new `app/internal/discord_rest.py`; the **bottom half** of `discord_leases.py` (`inspect_token`, `refresh_membership`, `leave_guild` move onto the client); `config.py` (**every** new setting for every slice, added here once); `tests/test_discord_rest.py`. - **Client:** - One shared `httpx.AsyncClient` with User-Agent `DiscordBot (https://drb.cusano.net, 1)`. - Per-token buckets keyed by `X-RateLimit-Bucket` plus guild_id. - On a 429, retry once only if `retry_after` ≤ 3 s; otherwise the result is unknown. A global 429 pauses that token. - A process-wide breaker on 401, 403 and non-shared 429 responses, set at 4000 per 10 min (Discord bans the IP at 10k). When it trips, results are unknown. That also protects the main bot, which shares the egress IP. - Never logs headers or the token. - **Status mapping:** | Response | Action | |---|---| | 401 | `valid=False`; never retried | | 10004 / 10007 | not a member; drop the guild from `guild_ids` | | 50001 | unknown; confirm with one membership refresh, and do not mutate `guild_ids` from it alone | - **Snapshot per (token, guild):** three GETs: - `/guilds/{g}` - `/guilds/{g}/members/{bot_user_id}` (needs no privileged intent) - `/guilds/{g}/channels` - **Cache:** in-process, keyed by (token_id, guild_id). Allowed results live 120 s; denied, not-member and invalid results live 600 s. Concurrent loads are single-flighted. `invalidate(token_id=None, guild_id=None)` clears it. - **Pagination:** `/users/@me/guilds` pages with `after=`. Today it reads `limit=200` once and silently truncates. - **Settings:** - `discord_perm_check_enabled=True` (kill switch) - `discord_access_ttl_seconds=120` - `discord_rest_invalid_budget=4000` - `discord_oauth_redirect_uri=""` (empty means OAuth is off) - `discord_pool_bot_guild_cap=90` **S3: wire the check into token picking (MVP backend)** (wave 2; needs S1 and S2) - **Files:** the **top half** of `discord_leases.py`; `discord_streams.py`; new `routers/discord_checks.py`; one include line in `main.py`; tests. - **`pick_token(..., perms=None)`:** - Skips a token whose result is denied and collects the reason. - In `_candidate_order`, confirmed-ok sorts before unknown. - New error `missing_permissions` with `denied=[{name, missing, reason}]`. It is returned only when no bot is usable and at least one member bot was denied. - The reuse branch in `lease_for_stream` falls through when its token is denied. - **`start_stream`:** 1. Default `preferred_token_id` to `systems/{id}.preferred_token_id` (finding 1). 2. Compute permissions for the tokens that `pick_token` could choose: up to 4 at once, 3 s budget, fail-open on unknown. 3. Pass the result into `lease_for_stream`. 4. When every bot is denied, raise a 409 `StreamError("missing_permissions", precise text)`. Example text: "DRB Radio 2 can't Speak in #dispatch (denied by the channel's override for @Muted). In Discord: Edit Channel → Permissions → allow Speak, then Re-check." 5. The bot `/join` shows this text through `_refusal_text`, and the reconciler shows it through `_mark`. - **`handle_actual`:** the codes `missing_permissions`, `channel_full`, `not_in_guild` and `bad_channel` call `invalidate(token_id, guild_id)`. - **`GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check?refresh=0|1`:** - Auth: `_node_for_caller`, plus the guild must be in `_org_guilds(node.org_id)`. Anything else is a 404. - Returns per channel `{user_limit, any_ok, bots:[{name, ok, missing, reason, available}]}`. - Bot names only: no token ids for non-admins, never token strings. - `refresh=1` is limited to 1 per 10 s per uid and guild. - **`GET /discord/guilds/{guild_id}/voice-check?channel_id=`:** `require_service_key_or_admin`. - **User limit is advisory only.** REST can't see occupancy, so the edge's `channel_full` stays the enforcement. **S4: picker UI (MVP frontend)** (wave 2, parallel with S3; build against the S3 JSON contract) - **Files:** `NodeDiscordRadio.tsx`, `lib/discordPicker.ts`, `discord/StreamsPanel.tsx` (`AddChannelForm`), `lib/c2api.ts` (one block after `removeNodeDiscordStream`). - Each channel row gets a badge: Ready / Needs fix (missing permission named, with the fix text) / Couldn't check / Limit N. - Start is disabled only on a definite deny. - Footer: "Checked 40s ago · Re-check". Per-bot details expand. - The 409 refusal is shown inline. - Run `drb-qa-review` and `tsc --noEmit` (#207). *Ship S1–S4 as the MVP. Run `deploy-check`, then the live smoke test (see rollout).* **S5: channel list and presence without the main bot** (wave 3; needs S3) - **Files:** `routers/discord_web.py`, plus `discord_rest.guild_directory()` (append-only). - **`_org_guilds` shows a server when** `org_id == org` **and** either the main bot's `bot_present` is set or a valid eligible radio bot's `guild_ids` contains the server. - Servers that resolve only through the fallback org still require the main bot (Design 1's discovery guard). - REST **never** creates `discord_guilds` docs for unlinked servers. - **`join_options`:** when `directory_at` is older than 5 min, refresh the channel list in the background from any eligible member radio bot's cached `/channels`. It writes `voice_channels`, `guild_name`, `directory_at`, `directory_source`, `radio_bot_present`. - **`start_node_stream`:** checks the channel against that merged list. - The main bot's `PUT …/directory` stays and also invalidates the guild's cache entry. - **Spike first:** does `/guilds/{g}/channels` return channels the bot can't View? The copy depends on the answer. **S6: OAuth linking backend** (wave 3, parallel with S5; needs S2) - **Files:** - new `routers/discord_oauth.py` - `discord_guild_link.py`: extract `link_guild_txn(org_id, guild_id, guild_name, linked_by, via)` from `_redeem_txn`, and an async `link_and_settle()` that runs `stop_guild` when the org it resolves to changes (finding 3) - `routers/discord_link.py` and the `routers/discord.py` link route call `link_and_settle()` - `routers/tokens.py`: `_masked` becomes an allowlist; add `PUT /tokens/{id}/oauth` (admin), which checks the secret with a client-credentials grant and never echoes it - one include line in `main.py` - `tests/test_discord_oauth.py` - **Secrets** go in a new server-only collection `bot_oauth/{token_id}` `{client_secret, enabled}`, never on `bot_tokens`. - **`POST /discord/oauth/start`** (`require_org_owner_token` + `resolve_org_scope`; at most 5 per 10 min per uid): - Picks the bot in this order: the system's preferred bot (when `node_id` is given), else the org's BYO bot, else the pool bot with the fewest servers that is under the cap and has OAuth enabled. - Stores `discord_oauth_states/{sha256(nonce)}` = `{org_id, uid, token_id, return_to, expires_at: +10m, used_at}`. - Returns the authorize URL: `scope=bot`, `permissions=RADIO_BOT_PERMISSIONS`, `response_type=code`, the exact configured `redirect_uri`, `state`. - If nothing is configured it returns 404 and the UI hides the button. - **`POST /discord/oauth/complete`** (Firebase, body `{code, state}`): 1. A transaction consumes the state: it must exist, be unexpired and unused, and **`state.uid` must equal the caller** (login-CSRF). The state is consumed even when a later step fails. 2. Re-check that the caller is still owner of the org, from a fresh read. 3. Exchange the code with that app's secret. 4. **The guild is taken from the exchange's `guild` object.** The query `guild_id` is ignored. If the exchange has no guild, return 400. 5. Run `link_and_settle(via="oauth")`. The never-move-between-orgs rule gives a 409. 6. Add the guild to `guild_ids`, revoke the access token, write audit `discord.guild.link`, invalidate the cache. 7. Fetch permissions in the same request, with a 5 s cap. 8. Return `{guild_name, channels_ready, channels_blocked, return_to}`. **S7: OAuth UI** (wave 3, parallel with S5 and S6; build against the S6 contract) - **Files:** new `app/discord/connected/page.tsx`; `middleware.ts` (add the path to `NO_SESSION_COOKIE_GATE`); `DiscordServersCard.tsx`; `discord/RadioBotsPanel.tsx` (form to set the secret, plus an "N of 100 servers" count); new `lib/discordOAuth.ts` (stays out of `c2api.ts`). - **The callback page:** 1. Stashes `code` and `state`, then calls `replaceState`, with `<meta name=referrer content=no-referrer>`. 2. Sends a signed-out user to login, then resumes. 3. Treats `error=access_denied` as "Nothing changed". 4. Shows a result card: "Linked Server X: 5 ready, 2 need a fix → Add a radio channel on node Y". - **On `DiscordServersCard`,** "Connect a Discord server" becomes the primary button and `/drb link` becomes a collapsed secondary option. - Run `drb-qa-review` and the `security-review` skill (on S6 and S7). **S8: main bot optional, cleanup** (wave 4; after S5 and S7 are live) - Remove copy that tells customers to invite the main bot (picker, `ServersPanel.tsx`, bot `/help`). - Admin overview gets a "main bot present" column. - Optional: the main bot's directory cog posts an invalidate on channel or role updates. - Update the `discord_web.py` docstring. - Confirm the deploy health gate doesn't depend on the discord-bot container. - Close #225. ## Merge order 1. S1 + S2 (no behaviour change beyond pagination, User-Agent and 401 handling). 2. S3. 3. S4. **This is the MVP.** Run `deploy-check`, then the smoke test. 4. Rebase the multi-stream branch onto S3. Its `stream_id` change does not touch the cache keys, which are (token, guild, channel). 5. S5. 6. S6. 7. S7. 8. S8. #223 (the caps race) stays its own PR, after S3. It touches the same function but different lines. ## Rollout notes - **No Client or edge change.** node-26#38's `_join_refusal` stays as the backstop at join time. - **Kill switch:** set `DISCORD_PERM_CHECK_ENABLED=false` to restore today's token picking exactly. - **Fail-open on unknown, everywhere.** A Discord outage must never block a start, and must never starve the reconciler. - **Smoke test after S4:** 1. In a test server, deny Speak to @everyone on one channel. 2. Expect the picker badge. 3. Expect a 409 with the same text from the web picker, `/join` and the reconciler. 4. Fix the permission in Discord, press Re-check, and expect success. - **OAuth, per bot app:** - Register the redirect URI `https://drb.cusano.net/discord/connected`. - Set the secret through `PUT /tokens/{id}/oauth`. - **Do not** enable "Requires OAuth2 Code Grant": it breaks every plain `invite_url` in use today. - The routes stay at 404 until the first secret is set. - **Rules check before S6 stores any secret.** Firestore rules may not be live (DEFERRED.md infra/firestore row, #161). Read `bot_tokens` and `bot_oauth` with a non-admin ID token and expect PERMISSION_DENIED. If that read succeeds, the bot tokens are already exposed, and that comes first. - **Spikes (about 30 min each, test server):** - Does `/channels` return channels the bot can't view? - Does a `scope=bot` code exchange return `guild`? - Is a bot without Use Voice Activity still heard? - **File as follow-ups:** - Automatic failover when the edge reports `missing_permissions`. - A DEFERRED.md entry: voice occupancy can't be seen over REST, so the edge is the authority on whether a channel is full. ## Open questions for the owner 1. **OAuth per bot or one "link app"?** - Per bot: you enter each radio bot's client secret. This meets "invite only the radio bot for your system". - One link app: a single secret in env, but an org whose preferred bot is a different one invites two bots. - **Recommend per bot.** 2. **Who may mark a bot preferred for a system?** - Today only a platform admin can (`PUT /tokens/{id}/prefer`). - Should org owners set it for their own systems and their own BYO bots? - Systems with no `org_id` are shared, so a preference set there would affect other orgs. 3. **Pool bots and the 100-server limit.** Unverified Discord bots can't join more than 100 servers. Should DRB apply for verification, or cap each bot at 90 servers and add bots? The cap affects which bot "Connect" picks. 🤖 Posted by Claude Code
Author
Owner

S1 (permission math) is up for review: PR #230, #230 (branch feat/225-s1-discord-perms, on top of c4e1f9a). New files only; 37 new tests, full c2-core suite 723 passed. Not merged.

S1 (permission math) is up for review: PR #230, https://git.vpn.cusano.net/logan/server-26/pulls/230 (branch feat/225-s1-discord-perms, on top of c4e1f9a). New files only; 37 new tests, full c2-core suite 723 passed. Not merged.
Author
Owner

S2 (REST client, cache and all #225 settings, plus the /users/@me/guilds pagination fix) is up for review: PR #231 #231 (branch feat/225-s2-discord-rest, on main c4e1f9a). c2-core pytest: 715 passed. S3 builds on discord_rest.guild_snapshot / invalidate / ttl_for. Not merged.

S2 (REST client, cache and all #225 settings, plus the /users/@me/guilds pagination fix) is up for review: PR #231 https://git.vpn.cusano.net/logan/server-26/pulls/231 (branch feat/225-s2-discord-rest, on main c4e1f9a). c2-core pytest: 715 passed. S3 builds on discord_rest.guild_snapshot / invalidate / ttl_for. Not merged.
logan added the area/discord
priority
p2
status
in-progress
type
feature
labels 2026-10-05 02:05:05 -04:00
Author
Owner

S3 (check wired into token picking, MVP backend) is up for review: PR #249 #249. The JSON contract for S4 (the 409 missing_permissions body and GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check) is in the PR body. One follow-up: org owners can't list their BYO bot ids for Q2's /prefer. That's added to #221 (org-scoped bot list). Not merged.

S3 (check wired into token picking, MVP backend) is up for review: PR #249 https://git.vpn.cusano.net/logan/server-26/pulls/249. The JSON contract for S4 (the 409 `missing_permissions` body and `GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check`) is in the PR body. One follow-up: org owners can't list their BYO bot ids for Q2's `/prefer`. That's added to #221 (org-scoped bot list). Not merged.
Author
Owner

S4 (picker UI) is up for review: PR #250, #250 (branch feat/225-s4-picker-ui). It is stacked on S3 (#249) and must merge after it. It adds per-channel permission badges with the fix text, Re-check, a Start button that is disabled only on a definite deny, and 409 refusals shown verbatim. Org owners get a Radio-bot-per-system card under Settings › Organization (Q2), backed by the new GET /tokens/org/preferred. The prefer route now drops the 5-min systems cache so a new preference applies to the next start. c2-core: 806 passed. tsc --noEmit: exit 0. drb-qa-review has not been run. Not merged.

S4 (picker UI) is up for review: PR #250, https://git.vpn.cusano.net/logan/server-26/pulls/250 (branch feat/225-s4-picker-ui). It is stacked on S3 (#249) and **must merge after it**. It adds per-channel permission badges with the fix text, Re-check, a Start button that is disabled only on a definite deny, and 409 refusals shown verbatim. Org owners get a Radio-bot-per-system card under Settings › Organization (Q2), backed by the new GET /tokens/org/preferred. The prefer route now drops the 5-min systems cache so a new preference applies to the next start. c2-core: 806 passed. tsc --noEmit: exit 0. drb-qa-review has not been run. Not merged.
Author
Owner

S7 (OAuth UI): PR #259 — #259 — merge after S6. Includes the middleware NO_SESSION_COOKIE_GATE fix (finding 2). Also found: drb_session is SameSite=Strict, so the cookie is missing on the redirect back from discord.com even for signed-in users. The fix covers that case too. The PR body lists the S6 contract points to align (system_id on start, oauth_enabled on GET /tokens, DELETE /tokens/{id}/oauth).

S7 (OAuth UI): PR #259 — https://git.vpn.cusano.net/logan/server-26/pulls/259 — merge after S6. Includes the middleware NO_SESSION_COOKIE_GATE fix (finding 2). Also found: drb_session is SameSite=Strict, so the cookie is missing on the redirect back from discord.com even for signed-in users. The fix covers that case too. The PR body lists the S6 contract points to align (system_id on start, oauth_enabled on GET /tokens, DELETE /tokens/{id}/oauth).
Author
Owner

S6 (per-bot OAuth linking backend) is up as PR #260: #260. The S7 contract is in the PR body.

  • The fallback-stop rule is extracted into discord_guild_link (_apply_link + _settle). Redeem, admin link and OAuth all go through it.
  • Secrets live in bot_oauth/{id}, are checked with Discord before they are stored, and are never returned or logged.
  • Still open before any secret is set: the rules check (#161) and the scope=bot exchange spike (does the exchange return guild?).
  • Plaintext secret storage is filed separately as #261.
S6 (per-bot OAuth linking backend) is up as PR #260: https://git.vpn.cusano.net/logan/server-26/pulls/260. The S7 contract is in the PR body. - The fallback-stop rule is extracted into `discord_guild_link` (`_apply_link` + `_settle`). Redeem, admin link and OAuth all go through it. - Secrets live in `bot_oauth/{id}`, are checked with Discord before they are stored, and are never returned or logged. - Still open before any secret is set: the rules check (#161) and the `scope=bot` exchange spike (does the exchange return `guild`?). - Plaintext secret storage is filed separately as #261.
Author
Owner

S5 (channel list and presence without the main bot) is in PR #262: #262. It is based on main e54191a and also fixes #251 (the interactive start re-reads a stale denied snapshot). The /channels-hides-unviewable spike was not run because there is no test server here. The code is correct either way (see PR). Not merged yet: waiting for review.

S5 (channel list and presence without the main bot) is in PR #262: https://git.vpn.cusano.net/logan/server-26/pulls/262. It is based on main e54191a and also fixes #251 (the interactive start re-reads a stale denied snapshot). The `/channels`-hides-unviewable spike was not run because there is no test server here. The code is correct either way (see PR). Not merged yet: waiting for review.
Author
Owner

S8 (main bot optional + cleanup): PR #271 — #271. Adds integration tests for both owner flows (one-bot org via BYO + OAuth; full setup with server bot + 4 radio bots), admin Bots column, copy + docstring updates, removes dead storage.download_audio. Remaining main-bot dependencies are listed in the PR body. Follow-up filed: #273 (BYO bot registration + OAuth secret are platform-admin only). Leave #225 open until #271 is reviewed and merged.

S8 (main bot optional + cleanup): PR #271 — https://git.vpn.cusano.net/logan/server-26/pulls/271. Adds integration tests for both owner flows (one-bot org via BYO + OAuth; full setup with server bot + 4 radio bots), admin Bots column, copy + docstring updates, removes dead storage.download_audio. Remaining main-bot dependencies are listed in the PR body. Follow-up filed: #273 (BYO bot registration + OAuth secret are platform-admin only). Leave #225 open until #271 is reviewed and merged.
Author
Owner

Done. All 8 slices are merged: S1 #230, S2 #231, S3 #249, S4 #250, S5 #262, S6 #260, S7 #259 and S8 #271 (merge 3ef8d58).

Both owner setups have end-to-end tests in tests/test_discord_owner_flows.py.

Follow-ups:

  • #273: adding a BYO bot and its OAuth secret is still admin-only.
  • #261: secrets are not encrypted at rest.
  • #266: OAuth state docs are never cleaned up.

OAuth linking stays OFF until DISCORD_OAUTH_REDIRECT_URI is set and a live Discord test confirms the code exchange returns the guild.

Done. All 8 slices are merged: S1 #230, S2 #231, S3 #249, S4 #250, S5 #262, S6 #260, S7 #259 and S8 #271 (merge 3ef8d58). Both owner setups have end-to-end tests in tests/test_discord_owner_flows.py. Follow-ups: - #273: adding a BYO bot and its OAuth secret is still admin-only. - #261: secrets are not encrypted at rest. - #266: OAuth state docs are never cleaned up. OAuth linking stays OFF until DISCORD_OAUTH_REDIRECT_URI is set and a live Discord test confirms the code exchange returns the guild.
logan closed this issue 2026-10-05 03:31:22 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#225