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
No Branch/Tag Specified
Labels
Clear labels
area/ai-pipeline
area/auth
area/billing
area/correlation
area/discord
area/frontend
area/governance
area/growth
area/infra-deploy
area/legal
area/map
area/mqtt
area/node-ota
area/osint
area/recording-audio
area/sdr-radio
area/transcription
board
board:agenda
minutes:draft
minutes:final
role:ceo
role:cfo
role:cio
role:ciso
role:cmo
role:coo
role:cto
Scene/entity extraction, summarizer, AI providers, embeddings
AuthN/Z, roles, tenancy/org scoping, API keys, credentials
Plans, entitlements, AI spend accounting and caps
Incident correlation, linking, lifecycle, severity, corr_debug
Discord bot, radio bots, voice streams, bot tokens, webhooks
Web UI: Next.js app and the edge-node local dashboard
Board process, minutes, unattended runner, agent tooling
Go-to-market, outreach, cold email, discovery conversations
CI/CD, VM, registry, Firestore rules/indexes deploy, Terraform/Caddy
ToS, retention, licences/DAAs, CAN-SPAM, E&O, name suppression
Live map: pins, paths, tiles, legend, geocoding
Node-C2 MQTT, checkins, commands, node sweeper, dynsec
Node install, enrollment, OTA updater, image skew
External feeds: cameras, 511, ADS-B, AIS, outages, and their licences
Call capture, boundaries, PulseAudio, ring buffer, trimming, attribution
OP25 decode, SDR hardware/tuning, conventional, second SDR, mesh radio
STT, transcript correction, place verification, name accuracy
Board process: minutes and board-filed items
Needs board scoping at the next sitting; CEO removes the label once scoped
One exec pre-read, closed by CEO
CEO ruling; open until enacted
Filed by the CEO
Filed by the CFO
Filed by the CIO
Filed by the CISO
Filed by the CMO
Filed by the COO
Filed by the CTO
kind
behavior-bug
Wrong product behavior; the instrument reads it correctly
kind
measurement-bug
Corrupts the recording/instrument (debug data, evals, health checks), not behavior
priority
p0
Drop everything: prod broken, live exposure, or owner-ruled P0
priority
p1
Next up: blocks a gate, a customer, or core incident quality
priority
p2
Normal: real defect or planned work, schedule in turn
priority
p3
Low: minor, cosmetic, or someday
status
blocked
Waiting on an external party, spend, or another issue
status
in-progress
Branch or PR open against it
status
needs-decision
Waiting on an owner or board ruling before work
status
needs-info
Root cause or repro unknown; needs a reading first
type
bug
Something built is wrong: code, data, or a measurement
type
chore
Maintenance, refactor, tests, pinning, cleanup: no user-visible change
type
decision
A ruling to make or record: board minutes, owner calls, legal reviews
type
docs
Docs, runbooks, CLAUDE.md, skills, SOPs
type
feature
New capability or an extension of an existing one
type
security
Security, privacy, credential or tenant-isolation exposure
type
tracking
Tracker, checklist, initiative or punch-list spanning several changes
Milestone
No items
No Milestone
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: logan/server-26#225
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 linkis a main-bot command (#218). Without the main bot, an org owner can't self-link; only an admin can, from /settings/discord.PUT /discord/guilds/{id}/directory).That makes customers invite two bots.
Direction to evaluate:
stateso the redirect returnsguild_id, instead of a slash command.The request-time permission check is the first consumer of this. Owner asked for it on 2026-10-05.
🤖 Filed by Claude Code
Owner requirement (2026-10-05). Both setups must work:
The main bot is optional in every flow.
server-26#225: final implementation plan (judged from 3 designs)
Grounded in Server
origin/main16a609eand Clientorigin/main5770eca (node-26#38). I checked every claim kept below against the source.Verdict
Why Design 0 wins. It puts the check inside
streams.start_stream, before the lease, and feeds the result intopick_token. That is the only placement that covers every caller. There are four entry points:POST /discord/streamsPOST /discord/nodes/{id}/streamsrouters/nodes.pydiscord_joinreconcile_onceDesigns 1 and 2 add the check in the routers, so they miss
nodes.pyand the reconciler.What I took from the other two:
/users/@me/guildspaginated.tokens._maskedallowlist.What I dropped:
_RateLimiterand_publish_locksalready make.Findings none of the three designs caught (verified in code)
PUT /tokens/{id}/prefer/{system_id}writessystems.preferred_token_id.start_streamnever reads it. Only an explicitpreferred_token_idparameter reachespick_token.middleware.tsredirects signed-out requests to/loginon the server, andnew URL("/login", …)drops the query.code/statein sessionStorage first" therefore never runs.NO_SESSION_COOKIE_GATE, so the page does its own auth check. Fixed in S7.discord_link.py(redeem) anddiscord.py(admin link). It is not in_redeem_txn.start_streamevery 30 s for each persistent stream that is not placed.discord_web.pydocstring 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-channelis at16a609ewith 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)
app/internal/discord_perms.py,tests/test_discord_perms.py.effective_permissions(guild, member, channel) -> (bits, trace), in Discord's order:check_voice(bits, channel) -> {ok, missing[], user_limit, limit_bypass}RADIO_BOT_PERMISSIONS.tracenames what removed each bit, e.g. "denied by #dispatch's override for @Muted".S2: REST client, cache and settings (wave 1, parallel with S1)
Files: new
app/internal/discord_rest.py; the bottom half ofdiscord_leases.py(inspect_token,refresh_membership,leave_guildmove onto the client);config.py(every new setting for every slice, added here once);tests/test_discord_rest.py.Client:
httpx.AsyncClientwith User-AgentDiscordBot (https://drb.cusano.net, 1).X-RateLimit-Bucketplus guild_id.retry_after≤ 3 s; otherwise the result is unknown. A global 429 pauses that token.Status mapping:
valid=False; never retriedguild_idsguild_idsfrom it aloneSnapshot per (token, guild): three GETs:
/guilds/{g}/guilds/{g}/members/{bot_user_id}(needs no privileged intent)/guilds/{g}/channelsCache: 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/guildspages withafter=. Today it readslimit=200once and silently truncates.Settings:
discord_perm_check_enabled=True(kill switch)discord_access_ttl_seconds=120discord_rest_invalid_budget=4000discord_oauth_redirect_uri=""(empty means OAuth is off)discord_pool_bot_guild_cap=90S3: wire the check into token picking (MVP backend) (wave 2; needs S1 and S2)
discord_leases.py;discord_streams.py; newrouters/discord_checks.py; one include line inmain.py; tests.pick_token(..., perms=None):_candidate_order, confirmed-ok sorts before unknown.missing_permissionswithdenied=[{name, missing, reason}]. It is returned only when no bot is usable and at least one member bot was denied.lease_for_streamfalls through when its token is denied.start_stream:preferred_token_idtosystems/{id}.preferred_token_id(finding 1).pick_tokencould choose: up to 4 at once, 3 s budget, fail-open on unknown.lease_for_stream.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."/joinshows this text through_refusal_text, and the reconciler shows it through_mark.handle_actual: the codesmissing_permissions,channel_full,not_in_guildandbad_channelcallinvalidate(token_id, guild_id).GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check?refresh=0|1:_node_for_caller, plus the guild must be in_org_guilds(node.org_id). Anything else is a 404.{user_limit, any_ok, bots:[{name, ok, missing, reason, available}]}.refresh=1is limited to 1 per 10 s per uid and guild.GET /discord/guilds/{guild_id}/voice-check?channel_id=:require_service_key_or_admin.channel_fullstays the enforcement.S4: picker UI (MVP frontend) (wave 2, parallel with S3; build against the S3 JSON contract)
NodeDiscordRadio.tsx,lib/discordPicker.ts,discord/StreamsPanel.tsx(AddChannelForm),lib/c2api.ts(one block afterremoveNodeDiscordStream).drb-qa-reviewandtsc --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)
routers/discord_web.py, plusdiscord_rest.guild_directory()(append-only)._org_guildsshows a server whenorg_id == organd either the main bot'sbot_presentis set or a valid eligible radio bot'sguild_idscontains the server.discord_guildsdocs for unlinked servers.join_options: whendirectory_atis older than 5 min, refresh the channel list in the background from any eligible member radio bot's cached/channels. It writesvoice_channels,guild_name,directory_at,directory_source,radio_bot_present.start_node_stream: checks the channel against that merged list.PUT …/directorystays and also invalidates the guild's cache entry./guilds/{g}/channelsreturn channels the bot can't View? The copy depends on the answer.S6: OAuth linking backend (wave 3, parallel with S5; needs S2)
routers/discord_oauth.pydiscord_guild_link.py: extractlink_guild_txn(org_id, guild_id, guild_name, linked_by, via)from_redeem_txn, and an asynclink_and_settle()that runsstop_guildwhen the org it resolves to changes (finding 3)routers/discord_link.pyand therouters/discord.pylink route calllink_and_settle()routers/tokens.py:_maskedbecomes an allowlist; addPUT /tokens/{id}/oauth(admin), which checks the secret with a client-credentials grant and never echoes itmain.pytests/test_discord_oauth.pybot_oauth/{token_id}{client_secret, enabled}, never onbot_tokens.POST /discord/oauth/start(require_org_owner_token+resolve_org_scope; at most 5 per 10 min per uid):node_idis given), else the org's BYO bot, else the pool bot with the fewest servers that is under the cap and has OAuth enabled.discord_oauth_states/{sha256(nonce)}={org_id, uid, token_id, return_to, expires_at: +10m, used_at}.scope=bot,permissions=RADIO_BOT_PERMISSIONS,response_type=code, the exact configuredredirect_uri,state.POST /discord/oauth/complete(Firebase, body{code, state}):state.uidmust equal the caller (login-CSRF). The state is consumed even when a later step fails.guildobject. The queryguild_idis ignored. If the exchange has no guild, return 400.link_and_settle(via="oauth"). The never-move-between-orgs rule gives a 409.guild_ids, revoke the access token, write auditdiscord.guild.link, invalidate the cache.{guild_name, channels_ready, channels_blocked, return_to}.S7: OAuth UI (wave 3, parallel with S5 and S6; build against the S6 contract)
app/discord/connected/page.tsx;middleware.ts(add the path toNO_SESSION_COOKIE_GATE);DiscordServersCard.tsx;discord/RadioBotsPanel.tsx(form to set the secret, plus an "N of 100 servers" count); newlib/discordOAuth.ts(stays out ofc2api.ts).codeandstate, then callsreplaceState, with<meta name=referrer content=no-referrer>.error=access_deniedas "Nothing changed".DiscordServersCard, "Connect a Discord server" becomes the primary button and/drb linkbecomes a collapsed secondary option.drb-qa-reviewand thesecurity-reviewskill (on S6 and S7).S8: main bot optional, cleanup (wave 4; after S5 and S7 are live)
ServersPanel.tsx, bot/help).discord_web.pydocstring.Merge order
deploy-check, then the smoke test.stream_idchange does not touch the cache keys, which are (token, guild, channel).#223 (the caps race) stays its own PR, after S3. It touches the same function but different lines.
Rollout notes
_join_refusalstays as the backstop at join time.DISCORD_PERM_CHECK_ENABLED=falseto restore today's token picking exactly./joinand the reconciler.https://drb.cusano.net/discord/connected.PUT /tokens/{id}/oauth.invite_urlin use today.bot_tokensandbot_oauthwith a non-admin ID token and expect PERMISSION_DENIED. If that read succeeds, the bot tokens are already exposed, and that comes first./channelsreturn channels the bot can't view?scope=botcode exchange returnguild?missing_permissions.Open questions for the owner
PUT /tokens/{id}/prefer).org_idare shared, so a preference set there would affect other orgs.🤖 Posted by Claude Code
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.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.S3 (check wired into token picking, MVP backend) is up for review: PR #249 #249. The JSON contract for S4 (the 409
missing_permissionsbody andGET /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.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.
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).
S6 (per-bot OAuth linking backend) is up as PR #260: #260. The S7 contract is in the PR body.
discord_guild_link(_apply_link+_settle). Redeem, admin link and OAuth all go through it.bot_oauth/{id}, are checked with Discord before they are stored, and are never returned or logged.scope=botexchange spike (does the exchange returnguild?).S5 (channel list and presence without the main bot) is in PR #262: #262. It is based on main
e54191aand 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.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.
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:
OAuth linking stays OFF until DISCORD_OAUTH_REDIRECT_URI is set and a live Discord test confirms the code exchange returns the guild.