Files
server-26/drb-c2-core/app/routers/discord_link.py
T
Logan CusanoandClaude Opus 5.5 455c55f5c2 feat(discord): self-serve server->org linking with /drb link (#213 item 1)
- c2-core: POST /discord/link-codes (org owner/admin) mints a single-use,
  10-minute code; minting revokes the org's older codes. The bot redeems it
  via POST /discord/guilds/{id}/link/redeem (service key) inside one
  Firestore transaction, so two servers racing for one code cannot both win
  and a server linked to org A refuses org B's code. GET/DELETE
  /discord/linked-guilds lists/unlinks the caller's org's servers; unlink
  stops that server's streams. Rate-limited (mint per user, redeem per
  server and per Discord user); every mint/link/refusal/unlink audited.
- resolve_guild_org: an explicitly unlinked server no longer falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID. Linking away from the fallback org stops
  the streams it had running for that org.
- Discord bot: /drb link code: (Manage Server, enforced at runtime too);
  GatedTree answers every other command in an unlinked server with how to
  link (autocomplete returns nothing). With the default fallback org set,
  nothing is gated — unchanged behaviour.
- Frontend: "Linked Discord servers" card on Settings -> Organization —
  generate code with countdown + copy, polls for the new link, unlink.
- 16 tests: single-use, two-server race, one-server/two-org race, expiry,
  revoked/malformed codes, wrong-org takeover, idempotent relink, rate
  limits, fallback stop, scoped list, unlink without fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:01:25 -04:00

126 lines
5.8 KiB
Python

"""
Self-serve Discord server -> org linking (server-26#213 item 1).
POST /discord/link-codes org owner / platform admin — mint a code
GET /discord/linked-guilds org owner / platform admin — this org's servers
DELETE /discord/linked-guilds/{guild_id} org owner / platform admin — unlink one
POST /discord/guilds/{guild_id}/link/redeem Discord bot (service key) — /drb link code:
Platform admins may pass ?org_id= to act on another org (resolve_org_scope),
same as the rest of the org-scoped API. The admin-only
``PUT /discord/guilds/{id}/link`` in routers/discord.py stays for support.
Every mint, link, refusal and unlink is written to audit_log. Rate limits are
the in-process sliding-window limiter from internal/auth.py (single c2-core
instance; not shared across replicas).
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_streams as streams
from app.internal import firestore as fstore
from app.internal.audit import write_audit
from app.internal.auth import (
_RateLimiter, describe_actor, require_org_owner_token, require_service_key, resolve_org_scope,
)
from app.internal.logger import logger
router = APIRouter(prefix="/discord", tags=["discord"])
# Minting: 5 codes per user per 10 min — each mint revokes the previous one,
# so this only stops a runaway client.
mint_limiter = _RateLimiter(max_calls=5, window_seconds=600)
# Redeeming: 5 attempts per server and 10 per Discord user per 10 min. With a
# 10-character code over 31 symbols and a 10-minute TTL, guessing is hopeless
# at this rate even across many throwaway servers.
redeem_guild_limiter = _RateLimiter(max_calls=5, window_seconds=600)
redeem_user_limiter = _RateLimiter(max_calls=10, window_seconds=600)
def _caller_org(decoded: dict, org_id: Optional[str]) -> str:
return resolve_org_scope(decoded, org_id)
@router.post("/link-codes")
async def create_link_code(org_id: Optional[str] = None, decoded: dict = Depends(require_org_owner_token)):
"""Mint a single-use code an org owner pastes into ``/drb link code:``."""
scope = _caller_org(decoded, org_id)
uid, email = describe_actor(decoded)
mint_limiter.check(f"{uid}:discord_link_code")
out = await glink.mint_code(scope, uid)
await write_audit(uid, email, "discord.link_code.create",
details={"org_id": scope, "expires_at": out["expires_at"]})
logger.info(f"Discord link code minted for org={scope!r} by uid={uid}")
return {**out, "command": f"/drb link code:{out['code']}"}
@router.get("/linked-guilds")
async def linked_guilds(org_id: Optional[str] = None, decoded: dict = Depends(require_org_owner_token)):
scope = _caller_org(decoded, org_id)
return {
"guilds": await glink.list_org_guilds(scope),
# False while DISCORD_UNLINKED_GUILD_ORG_ID is set: unlinked servers
# then still act for that org instead of seeing only /drb link + /help.
"unlinked_guilds_restricted": not settings.discord_unlinked_guild_org_id,
}
@router.delete("/linked-guilds/{guild_id}")
async def unlink_guild(guild_id: str, org_id: Optional[str] = None,
decoded: dict = Depends(require_org_owner_token)):
scope = _caller_org(decoded, org_id)
uid, email = describe_actor(decoded)
try:
await glink.unlink(scope, guild_id, uid)
except glink.LinkError as e:
raise HTTPException(e.status, e.message)
# Its radio channels were playing for this org; they go with the link.
stopped = await streams.stop_guild(guild_id, "unlinked")
await write_audit(uid, email, "discord.guild.unlink",
details={"org_id": scope, "guild_id": guild_id, "streams_stopped": len(stopped)})
return {"ok": True, "guild_id": guild_id, "streams_stopped": len(stopped)}
class RedeemBody(BaseModel):
code: str
discord_user_id: str
guild_name: Optional[str] = None
@router.post("/guilds/{guild_id}/link/redeem")
async def redeem_link_code(guild_id: str, body: RedeemBody, principal: dict = Depends(require_service_key)):
"""The Discord bot redeems a code for the server it was typed in. The bot
has already checked the member holds Manage Server."""
redeem_guild_limiter.check(f"guild:{guild_id}")
redeem_user_limiter.check(f"user:{body.discord_user_id}")
actor = f"discord:{body.discord_user_id}"
before = await streams.resolve_guild_org(guild_id)
try:
result = await glink.redeem_code(body.code, guild_id, body.guild_name, body.discord_user_id)
except glink.LinkError as e:
await write_audit(actor, "", "discord.guild.link_refused",
details={"guild_id": guild_id, "reason": e.code})
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
org_id = result["org_id"]
stopped: list = []
if before and before != org_id:
# The server was acting for DISCORD_UNLINKED_GUILD_ORG_ID; anything it
# had playing belongs to that org, not the one it just linked to.
stopped = await streams.stop_guild(guild_id, "relinked")
await write_audit(actor, "", "discord.guild.link", details={
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
})
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
org = await fstore.doc_get("organizations", org_id)
return {"ok": True, "guild_id": guild_id, "org_id": org_id,
"org_name": (org or {}).get("name"), "already_linked": result["already_linked"]}