- 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>
126 lines
5.8 KiB
Python
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"]}
|