Merge pull request 'Discord: Connect a Discord server — OAuth UI (#225 S7) — merge after S6' (#259) from feat/225-s7-oauth-ui into main
Build & Deploy / Build & push images (push) Successful in 4m11s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 30s
Build & Deploy / Deploy to VM (push) Successful in 1m34s
Build & Deploy / Report a failed deploy (push) Skipped

This commit was merged in pull request #259.
This commit is contained in:
2026-10-05 03:11:23 -04:00
17 changed files with 846 additions and 65 deletions
@@ -84,6 +84,8 @@ def _channel_state(rows: list[dict], enabled: bool) -> str:
return "needs_fix" # a start is refused with missing_permissions
if any(r["ok"] for r in rows):
return "busy"
if any(r["status"] == discord_rest.INVALID for r in rows):
return "invalid" # the bot IS here, but Discord rejected its token (#257)
return "no_bot"
+28 -7
View File
@@ -14,7 +14,9 @@ Every route is a 404 while ``discord_oauth_redirect_uri`` is empty or no
eligible bot has an OAuth secret (PUT /tokens/{id}/oauth).
Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the
radio bot preferred for the node's system (``node_id``); else the org's own
radio bot preferred for ``system_id`` (the org's own system or a shared one;
strict: a preferred bot that can't connect is a 404, not a quiet swap); else
the radio bot preferred for the node's system (``node_id``); else the org's own
bring-your-own bot; else the pool bot with the fewest servers that is under
``discord_pool_bot_guild_cap`` (owner decision Q3). Only bots with an OAuth
secret set count. A bot the caller's org can't use is a 404, never a 403.
@@ -30,7 +32,7 @@ application's credentials, after the org-scope checks and rate limits here.
from __future__ import annotations
import asyncio
from typing import Optional
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
@@ -81,7 +83,7 @@ def _usable(tok: dict, org_id: str, oauth_ids: set) -> bool:
async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
token_id: Optional[str] = None) -> tuple[dict, str]:
token_id: Optional[str] = None, system_id: Optional[str] = None) -> tuple[dict, str]:
"""(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` |
``pool``. Raises HTTPException 404 when nothing fits."""
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
@@ -96,6 +98,21 @@ async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
"right now.")
return tok, "chosen"
if system_id:
# Same visibility as a node's system: the org's own or a shared one
# (no org_id). Another org's system is a 404, never a 403.
system = await fstore.doc_get("systems", system_id)
if not system or system.get("org_id") not in (None, org_id):
raise HTTPException(404, "System not found.")
pref_id = system.get("preferred_token_id") or ""
if pref_id:
# The caller asked for THIS system's bot by name: inviting a
# different one instead would be the silent swap this exists to stop.
pref = tokens.get(pref_id)
if not pref or not _usable(pref, org_id, oauth_ids):
raise HTTPException(404, "That system's radio bot can't be used to connect a server right now.")
return pref, "preferred"
if node_id:
node = await fstore.doc_get("nodes", node_id)
if not node or node.get("org_id") != org_id:
@@ -133,17 +150,19 @@ def _bot_summary(tok: dict, why: str) -> dict:
async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID),
token_id: Optional[str] = Query(None, pattern=_ID),
org_id: Optional[str] = None,
decoded: dict = Depends(require_org_owner_token)):
decoded: dict = Depends(require_org_owner_token),
system_id: Annotated[Optional[str], Query(pattern=_ID)] = None):
"""Whether Connect is offered here, and which bot it would invite."""
_require_enabled()
scope = resolve_org_scope(decoded, org_id)
tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id)
tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id, system_id=system_id)
return {"available": True, "bot": _bot_summary(tok, why)}
class StartBody(BaseModel):
node_id: Optional[str] = Field(None, pattern=_ID)
token_id: Optional[str] = Field(None, pattern=_ID)
system_id: Optional[str] = Field(None, pattern=_ID)
return_to: Optional[str] = None
@@ -154,12 +173,14 @@ async def oauth_start(body: StartBody, org_id: Optional[str] = None,
scope = resolve_org_scope(decoded, org_id)
uid, email = describe_actor(decoded)
start_limiter.check(f"{uid}:discord_oauth_start")
tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id)
tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id,
system_id=body.system_id)
return_to = oauth.safe_return_to(body.return_to)
nonce, expires_at = await oauth.create_state(org_id=scope, uid=uid, token_id=tok["token_id"],
node_id=body.node_id, return_to=return_to)
await write_audit(uid, email, "discord.oauth.start", details={
"org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id})
"org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id,
"system_id": body.system_id})
return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce),
"expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)}