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>
This commit is contained in:
Logan Cusano
2026-10-04 20:01:25 -04:00
co-authored by Claude Opus 5.5
parent dc1df72c86
commit 455c55f5c2
14 changed files with 960 additions and 3 deletions
@@ -0,0 +1,169 @@
"""
Self-serve Discord server -> DRB org linking (server-26#213 item 1).
Same shape as the account-link codes in routers/links.py (web mints a short
code, the Discord bot redeems it), with the gaps that pattern has closed:
* **Single-use under a race.** Redeem runs in one Firestore transaction that
reads the code doc and the guild doc before writing either, so two servers
redeeming one code at once cannot both win — the loser's transaction is
re-run, sees ``redeemed_at`` set, and is refused.
* **Short TTL** (``CODE_TTL_MINUTES``) and **one live code per org**: minting
a new code revokes the org's older unredeemed ones.
* **Never moves a server between orgs.** A server already linked to org A
refuses org B's code (409) until org A unlinks it from its own settings.
* An explicitly unlinked server does **not** fall back to
``DISCORD_UNLINKED_GUILD_ORG_ID`` (see ``discord_streams.resolve_guild_org``)
— otherwise unlinking org B's server would hand it to the founding org.
Firestore layout:
discord_link_codes/{CODE} CODE = 10 chars, no dashes, upper-case
org_id, created_by_uid, created_at, expires_at,
redeemed_at|None, redeemed_guild_id|None, revoked: bool
discord_guilds/{guild_id} (shared with discord_streams.py)
guild_id, org_id|None, guild_name, linked_by, linked_via, linked_at,
unlinked_at|None, unlinked_by|None
Person names are never stored: the redeemer is kept as a Discord user id.
"""
from __future__ import annotations
import re
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from app.internal import firestore as fstore
CODES = "discord_link_codes"
GUILDS = "discord_guilds"
CODE_TTL_MINUTES = 10
# Unambiguous alphabet: no 0/O, 1/I/L. 31^10 ~= 8e14 codes.
_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
_CODE_LEN = 10
class LinkError(Exception):
def __init__(self, status: int, code: str, message: str):
super().__init__(message)
self.status, self.code, self.message = status, code, message
def _now() -> datetime:
return datetime.now(timezone.utc)
def new_code() -> str:
return "".join(secrets.choice(_ALPHABET) for _ in range(_CODE_LEN))
def normalize(code: str) -> str:
"""Accept what a person pastes: any case, with or without dashes/spaces."""
return re.sub(r"[^A-Za-z0-9]", "", code or "").upper()
def display(code: str) -> str:
"""RVR7Q4K22X -> RVR-7Q4K-22X (the mockup's grouping, for reading aloud)."""
return f"{code[:3]}-{code[3:7]}-{code[7:]}"
async def mint_code(org_id: str, uid: str) -> dict:
"""Create a code for ``org_id`` and revoke the org's older unredeemed ones."""
for old in await fstore.collection_list(CODES, org_id=org_id):
if not old.get("redeemed_at") and not old.get("revoked") and old.get("code"):
await fstore.doc_update(CODES, old["code"], {"revoked": True})
code = new_code()
now = _now()
expires_at = now + timedelta(minutes=CODE_TTL_MINUTES)
await fstore.doc_set(CODES, code, {
"code": code,
"org_id": org_id,
"created_by_uid": uid,
"created_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
"redeemed_at": None,
"redeemed_guild_id": None,
"revoked": False,
}, merge=False)
return {"code": display(code), "expires_at": expires_at.isoformat(),
"expires_minutes": CODE_TTL_MINUTES}
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
"""Build the transaction body. Every read happens before any write."""
db = fstore.db
def _txn(txn):
code_ref = db.collection(CODES).document(code)
guild_ref = db.collection(GUILDS).document(guild_id)
code_snap = code_ref.get(transaction=txn)
guild_snap = guild_ref.get(transaction=txn)
doc = code_snap.to_dict() if code_snap.exists else None
guild = guild_snap.to_dict() if guild_snap.exists else None
if not doc or doc.get("revoked"):
return {"error": LinkError(404, "invalid_code",
"That link code is not valid. Generate a new one in DRB → "
"Settings → Organization.")}
if doc.get("redeemed_at"):
return {"error": LinkError(409, "code_used", "That link code has already been used.")}
if now > datetime.fromisoformat(doc["expires_at"]):
return {"error": LinkError(410, "code_expired",
"That link code has expired. Generate a new one in DRB → "
"Settings → Organization.")}
org_id = doc["org_id"]
current = (guild or {}).get("org_id")
if current and current != org_id:
return {"error": LinkError(409, "linked_elsewhere",
"This server is already linked to a different DRB organization. "
"That organization has to unlink it first.")}
iso = now.isoformat()
txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id,
"redeemed_by_discord_id": discord_user_id})
txn.set(guild_ref, {
"guild_id": guild_id,
"org_id": org_id,
"guild_name": (guild_name or "")[:100] or (guild or {}).get("guild_name"),
"linked_by": f"discord:{discord_user_id}",
"linked_via": "code",
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"created_by_uid": doc.get("created_by_uid")}
return _txn
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal."""
code = normalize(raw_code)
if len(code) != _CODE_LEN:
raise LinkError(404, "invalid_code",
"That link code is not valid. Generate a new one in DRB → Settings → Organization.")
result = await fstore.run_transaction(
_redeem_txn(code, str(guild_id), guild_name, str(discord_user_id), _now()))
if "error" in result:
raise result["error"]
return result
async def list_org_guilds(org_id: str) -> list[dict]:
return [
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
"linked_at": g.get("linked_at"), "linked_via": g.get("linked_via") or "admin"}
for g in await fstore.collection_list(GUILDS, org_id=org_id)
]
async def unlink(org_id: str, guild_id: str, uid: str) -> None:
guild = await fstore.doc_get(GUILDS, guild_id)
if not guild or guild.get("org_id") != org_id:
raise LinkError(404, "not_linked", "That Discord server is not linked to this organization.")
await fstore.doc_set(GUILDS, guild_id, {
"org_id": None, "unlinked_at": _now().isoformat(), "unlinked_by": uid,
}, merge=True)
+7 -1
View File
@@ -75,10 +75,16 @@ def _parse_ts(value) -> Optional[datetime]:
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)."""
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
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