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:
co-authored by
Claude Opus 5.5
parent
dc1df72c86
commit
455c55f5c2
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user