Discord: per-bot OAuth "Connect a Discord server" backend (server-26#225 S6)

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>
This commit is contained in:
Logan Cusano
2026-10-05 02:52:57 -04:00
co-authored by Claude Opus 5.5
parent e54191a5d0
commit db4e326332
10 changed files with 1177 additions and 60 deletions
+12 -6
View File
@@ -93,6 +93,17 @@ def _parse_ts(value) -> Optional[datetime]:
# Guild -> org
# ---------------------------------------------------------------------------
def acting_org_of(link: Optional[dict]) -> Optional[str]:
"""``resolve_guild_org`` for an already-read ``discord_guilds`` doc (None =
no doc). Pure, so a link transaction can decide from the snapshot it read
(discord_guild_link.link_guild_txn, server-26#225 S6)."""
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
async def resolve_guild_org(guild_id: str) -> Optional[str]:
"""The org a Discord server acts for: its ``discord_guilds`` link, else
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
@@ -100,12 +111,7 @@ async def resolve_guild_org(guild_id: str) -> Optional[str]:
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
gets no fallback: otherwise unlinking org B's server would quietly hand it
to the founding org."""
link = await fstore.doc_get(GUILDS, str(guild_id))
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
return acting_org_of(await fstore.doc_get(GUILDS, str(guild_id)))
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]: