Discord: per-bot OAuth "Connect a Discord server" backend (#225 S6) #260
No Reviewers
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
No labels
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#260
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.
Slice S6 of #225 (plan comment 3108). Per-bot OAuth "Connect a Discord server" backend, owner decision Q1. Built on main
e54191a(S1–S4 + #227). Do not merge from here; the main session merges after review.What it does
An org owner invites ONE radio bot through that bot's own OAuth2 invite. The
stateis a single-use, 10-minute nonce bound to org + user. Discord redirects back withcode; C2 exchanges it with that bot application's client secret and links the server Discord's exchange names (theguildobject). The redirect'sguild_idquery is ignored.routers/discord_oauth.py(new):GET /discord/oauth/options,POST /discord/oauth/start,POST /discord/oauth/complete.internal/discord_oauth.py(new): state mint/consume transaction, code exchange, token revoke, client-credentials secret check. Uses discord_rest's shared client (User-Agent, test transport) and counts 401/403/429 against its breaker.discord_guild_link.py(plan finding 3): one transaction body_apply_link+ one settle step_settle./drb linkredeem (redeem_and_settle), adminPUT /discord/guilds/{id}/linkand OAuth (link_and_settle) all go through it. The org the server acted for before is now read inside the link transaction (streams.acting_org_of), not before it. Admin keeps its move-between-orgs override (allow_move=True); self-serve paths 409. The admin link now also writes an audit entry.routers/tokens.py:_maskedis an allowlist (+oauth_enabled,server_count,server_cap).PUT /tokens/{id}/oauth(admin) checks the secret with Discord (client-credentials grant, token revoked) before storing; wrong secret → 400, Discord unreachable → 503, nothing stored.DELETE /tokens/{id}/oauth. Deleting a bot deletes its secret.firestore.rules: explicit deny forbot_oauthanddiscord_oauth_states(the catch-all already denied them).Secrets
bot_oauth/{token_id}={client_secret, enabled, set_by, set_at, checked_at}, server-only, never onbot_tokens, never returned, never logged, never in an error string. Request models take the secret/code as plainstrand length-check in the handler, because a pydantic 422 echoes the input back. No app-level encryption: the codebase has no pattern for it (bot tokens are stored the same way), so the secret relies on Firestore's at-rest encryption.Before any secret is set (plan rollout notes)
bot_tokensandbot_oauthand expect PERMISSION_DENIED. If that read succeeds, the bot tokens are already exposed, and that comes first.scope=botcode exchange returnguild? The code assumes it does and returns 400no_guildotherwise. Not verified against live Discord.https://drb.cusano.net/discord/connected. Do not enable "Requires OAuth2 Code Grant".DISCORD_OAUTH_REDIRECT_URIto the same string. Empty = every route 404s.Contract for S7 (OAuth UI)
Errors are JSON
{detail, code};detailis user-facing copy.GET /discord/oauth/options?node_id=&token_id=&org_id=(org owner or platform admin;org_idis admin only). Mints nothing, so call it to decide whether to show the button.{available: true, bot: {name, reason, own_bot}}.reasonis one ofchosen|preferred|org_bot|pool.POST /discord/oauth/start?org_id=, body{node_id?, token_id?, return_to?}(org owner or admin; 5 per 10 min per uid, then 429).{authorize_url, expires_at, bot: {name, reason, own_bot}}. Navigate toauthorize_url(thediscord.cominvite withscope=bot,permissions=3146752,response_type=code,redirect_uri,state).return_tomust be a same-site path (/…, not//…, max 200 chars). Anything else is stored as null.token_idthe org may use → the radio bot preferred fornode_id's system → the org's own bot (by name) → the pool bot with the fewest servers underdiscord_pool_bot_guild_cap. Only bots with an OAuth secret count. Another org's bot or node is a 404.POST /discord/oauth/complete, body{code, state}(any signed-in user; must be the one who started it; 10 per 10 min per uid).stateuses the S3 voice-check states:ready|needs_fix|unknown|busy|no_bot|bad_channel|unchecked.fixis the first fix text from the bots.checked=falsemeans the 5 s permission check didn't finish; the link still stands, and the picker's badges will say.400 invalid_state: unknown state.409 state_used: replayed.410 state_expired.403 wrong_user: someone else's state. The state is burned.403 not_owner: no longer the org's owner (freshorg_membersread).409 bot_unavailable: the bot lost its secret, was marked invalid or was moved to another org.400 code_rejected: Discord refused the code.502 discord_unavailable.400 no_guild.409 linked_elsewhere: another org owns the server. Also carriesguild_nameandbot_name. The bot did join; nothing was linked.error=access_deniedin the callback, show "Nothing changed" without calling complete.Admin (RadioBotsPanel):
PUT /tokens/{id}/oauth, body{client_secret}→{ok, token_id, oauth_enabled: true, redirect_uri_configured}.DELETE /tokens/{id}/oauth→{ok, token_id, oauth_enabled: false}.GET /tokensrows addoauth_enabled,server_count(null when unknown) andserver_cap(pool cap, null for BYO), for the "N of 100 servers" count.Heads-up for S5/S7:
_org_guilds(S5's file) lists a server only while the main bot hasn't markedbot_present: false. Until S5 lands, a server linked through OAuth with no main bot does show. It has novoice_channelsdirectory, though, so the picker lists channels from the radio bot's snapshot (S3's fallback).Tests
tests/test_discord_oauth.py(28 tests) covers:Existing link/admin tests pass unchanged through the shared helper.
Full suite:
838 passed, 4 warnings(~/venvs/drb-5c/bin/python -m pytest -qin drb-c2-core). flake8--max-line-length=120on the changed files is clean. No frontend files changed, so tsc wasn't run.🤖 Generated with Claude Code
An org owner links a server by inviting one radio bot through that bot's own OAuth2 invite (owner decision Q1). The state is a single-use, 10-minute nonce bound to org + user (stored only as its sha256); the server linked is the one Discord's code exchange names, never the redirect query. - routers/discord_oauth.py: GET /discord/oauth/options, POST /start, POST /complete. 404 while discord_oauth_redirect_uri is empty or no usable bot has a secret. Bot order: explicit token_id, the node system's preferred bot, the org's own bot, least-full pool bot under the cap. - internal/discord_oauth.py: state mint/consume transaction (login-CSRF: state.uid must be the caller; any presentation burns it), code exchange, revoke, client-credentials secret check. Through discord_rest's shared client and breaker; secrets/codes/tokens never logged or in errors. - discord_guild_link: one link transaction body (_apply_link) and one settle step that stops a server's streams when the org it acted for changes (plan finding 3). /drb link redeem, the admin PUT link and OAuth all use it; the previous org is now read inside the link transaction. - tokens: GET /tokens is an allowlist (+ oauth_enabled, server_count, server_cap); PUT/DELETE /tokens/{id}/oauth (admin) checks the secret with Discord before storing it in bot_oauth/{id}, never echoes it. - firestore.rules: explicit deny for bot_oauth and discord_oauth_states. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>