diff --git a/drb-c2-core/app/config.py b/drb-c2-core/app/config.py
index aa258d3..a770d95 100644
--- a/drb-c2-core/app/config.py
+++ b/drb-c2-core/app/config.py
@@ -249,7 +249,11 @@ class Settings(BaseSettings):
discord_lease_offline_release_seconds: int = 600
# Org a Discord server that was never linked to an org (discord_guilds/{id})
# acts for. Default keeps today's single-org deployment working unchanged;
- # set it to "" to require every server to be linked first.
+ # set it to "" to require every server to be linked first. Do that once
+ # every server that should keep working has been linked (/drb link, or
+ # PUT /discord/guilds/{id}/link) — unlinked servers then get only
+ # /drb link and /help (server-26#213). A server an org explicitly unlinked
+ # never falls back to this org either way.
discord_unlinked_guild_org_id: str = "founding"
class Config:
diff --git a/drb-c2-core/app/internal/discord_guild_link.py b/drb-c2-core/app/internal/discord_guild_link.py
new file mode 100644
index 0000000..c846457
--- /dev/null
+++ b/drb-c2-core/app/internal/discord_guild_link.py
@@ -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)
diff --git a/drb-c2-core/app/internal/discord_streams.py b/drb-c2-core/app/internal/discord_streams.py
index 9c49ee4..dbe7556 100644
--- a/drb-c2-core/app/internal/discord_streams.py
+++ b/drb-c2-core/app/internal/discord_streams.py
@@ -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
diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py
index 4725942..fe5504f 100644
--- a/drb-c2-core/app/main.py
+++ b/drb-c2-core/app/main.py
@@ -20,6 +20,7 @@ from app.internal.auth import (
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
from app.routers import discord as discord_router
+from app.routers import discord_link as discord_link_router
from app.internal import dynsec
from app.internal import firestore as fstore
@@ -158,6 +159,7 @@ app.include_router(media.router)
# auth is per-endpoint: bot routes take the service key (or a platform admin);
# the token-fetch route takes the calling node's own API key.
app.include_router(discord_router.router)
+app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
# is archived (no CVE patches) and was rejected for an internet-facing
diff --git a/drb-c2-core/app/routers/discord_link.py b/drb-c2-core/app/routers/discord_link.py
new file mode 100644
index 0000000..5a85500
--- /dev/null
+++ b/drb-c2-core/app/routers/discord_link.py
@@ -0,0 +1,125 @@
+"""
+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"]}
diff --git a/drb-c2-core/tests/test_discord_guild_link.py b/drb-c2-core/tests/test_discord_guild_link.py
new file mode 100644
index 0000000..536372f
--- /dev/null
+++ b/drb-c2-core/tests/test_discord_guild_link.py
@@ -0,0 +1,251 @@
+"""
+Self-serve Discord server -> org linking (server-26#213 item 1).
+
+Race tests run two real transactions against tests/fake_firestore.py, held
+together at the commit point, so the interleaving the single-use guarantee
+has to survive actually happens.
+"""
+import asyncio
+import threading
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from fastapi import HTTPException
+
+from app.config import settings
+from app.internal import auth as auth_mod
+from app.internal import discord_guild_link as glink
+from app.internal import discord_streams as streams
+from app.routers import discord_link as router
+from tests.fake_firestore import FakeFirestore
+
+OWNER_A = {"uid": "owner-a", "email": "a@example.test", "org_id": "org-a", "org_role": "owner"}
+OWNER_B = {"uid": "owner-b", "email": "b@example.test", "org_id": "org-b", "org_role": "owner"}
+
+
+@pytest.fixture
+def fs(monkeypatch):
+ fake = FakeFirestore.install(monkeypatch)
+ fake.put("organizations", "org-a", {"org_id": "org-a", "name": "Riverton Fire Co. 7"})
+ fake.put("organizations", "org-b", {"org_id": "org-b", "name": "Harbor Towing"})
+ for lim in (router.mint_limiter, router.redeem_guild_limiter, router.redeem_user_limiter):
+ lim._log.clear()
+ stopped: list = []
+
+ async def _stop_guild(guild_id, reason, channel_id=None):
+ stopped.append((guild_id, reason))
+ return [f"{guild_id}:c1"]
+ monkeypatch.setattr(streams, "stop_guild", _stop_guild)
+ fake.stopped = stopped
+ monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
+ return fake
+
+
+def _audit(fake, action):
+ return [e for e in (fake.data.get("audit_log") or {}).values() if e["action"] == action]
+
+
+async def _mint(decoded=OWNER_A):
+ return await router.create_link_code(org_id=None, decoded=decoded)
+
+
+async def _redeem(code, guild="g1", user="u1", name="Station 7"):
+ return await router.redeem_link_code(
+ guild, router.RedeemBody(code=code, discord_user_id=user, guild_name=name), principal={"service": True})
+
+
+def _status(resp):
+ return getattr(resp, "status_code", 200)
+
+
+def _hold_both_at_commit(fake):
+ barrier = threading.Barrier(2, timeout=5)
+
+ def _hook(attempt):
+ if attempt == 0:
+ barrier.wait()
+ fake.before_commit = _hook
+
+
+# ---------------------------------------------------------------------------
+# Mint
+# ---------------------------------------------------------------------------
+
+async def test_mint_returns_display_code_and_audits(fs):
+ out = await _mint()
+ assert len(out["code"]) == 12 and out["code"][3] == "-" and out["code"][8] == "-"
+ assert out["expires_minutes"] == glink.CODE_TTL_MINUTES
+ assert out["command"] == f"/drb link code:{out['code']}"
+ doc = fs.get(glink.CODES, glink.normalize(out["code"]))
+ assert doc["org_id"] == "org-a" and doc["redeemed_at"] is None and doc["revoked"] is False
+ assert len(_audit(fs, "discord.link_code.create")) == 1
+
+
+async def test_minting_again_revokes_the_previous_code(fs):
+ first = await _mint()
+ second = await _mint()
+ assert fs.get(glink.CODES, glink.normalize(first["code"]))["revoked"] is True
+ resp = await _redeem(first["code"])
+ assert _status(resp) == 404
+ assert _status(await _redeem(second["code"])) == 200
+
+
+async def test_mint_rate_limited_per_user(fs):
+ for _ in range(5):
+ await _mint()
+ with pytest.raises(HTTPException) as e:
+ await _mint()
+ assert e.value.status_code == 429
+
+
+async def test_member_cannot_mint(fs, monkeypatch):
+ member = {**OWNER_A, "org_role": "member"}
+ monkeypatch.setattr(auth_mod.firebase_auth, "verify_id_token", lambda _t: member)
+
+ class _Cred:
+ credentials = "tok"
+ with pytest.raises(HTTPException) as e:
+ await auth_mod.require_org_owner_token(_Cred())
+ assert e.value.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# Redeem
+# ---------------------------------------------------------------------------
+
+async def test_redeem_links_the_guild_and_is_single_use(fs):
+ out = await _mint()
+ resp = await _redeem(out["code"].lower()) # pasted in lower case, dashes kept
+ assert resp["org_id"] == "org-a" and resp["org_name"] == "Riverton Fire Co. 7"
+ assert await streams.resolve_guild_org("g1") == "org-a"
+ g = fs.get(streams.GUILDS, "g1")
+ assert g["linked_by"] == "discord:u1" and g["linked_via"] == "code" and g["guild_name"] == "Station 7"
+ code_doc = fs.get(glink.CODES, glink.normalize(out["code"]))
+ assert code_doc["redeemed_guild_id"] == "g1"
+ assert len(_audit(fs, "discord.guild.link")) == 1
+
+ again = await _redeem(out["code"], guild="g2", user="u2")
+ assert _status(again) == 409
+ assert fs.get(streams.GUILDS, "g2") is None
+ assert [e["details"]["reason"] for e in _audit(fs, "discord.guild.link_refused")] == ["code_used"]
+
+
+async def test_two_servers_racing_for_one_code_only_one_wins(fs):
+ out = await _mint()
+ _hold_both_at_commit(fs)
+ r1, r2 = await asyncio.gather(_redeem(out["code"], guild="g1", user="u1"),
+ _redeem(out["code"], guild="g2", user="u2"))
+ assert fs.retries >= 1 # the race really happened
+ assert sorted([_status(r1), _status(r2)]) == [200, 409]
+ linked = [g for g in ("g1", "g2") if (fs.get(streams.GUILDS, g) or {}).get("org_id")]
+ assert len(linked) == 1
+ assert fs.get(glink.CODES, glink.normalize(out["code"]))["redeemed_guild_id"] == linked[0]
+
+
+async def test_one_server_racing_two_orgs_codes_ends_on_exactly_one(fs):
+ a = await _mint(OWNER_A)
+ b = await _mint(OWNER_B)
+ _hold_both_at_commit(fs)
+ r1, r2 = await asyncio.gather(_redeem(a["code"], user="u1"), _redeem(b["code"], user="u2"))
+ assert sorted([_status(r1), _status(r2)]) == [200, 409]
+ winner = "org-a" if _status(r1) == 200 else "org-b"
+ loser_code = b["code"] if winner == "org-a" else a["code"]
+ assert fs.get(streams.GUILDS, "g1")["org_id"] == winner
+ # The refused code was not consumed — its org can still use it elsewhere.
+ assert fs.get(glink.CODES, glink.normalize(loser_code))["redeemed_at"] is None
+
+
+async def test_expired_code_is_refused(fs):
+ out = await _mint()
+ key = glink.normalize(out["code"])
+ past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
+ fs.put(glink.CODES, key, {**fs.get(glink.CODES, key), "expires_at": past})
+ resp = await _redeem(out["code"])
+ assert _status(resp) == 410
+ assert fs.get(streams.GUILDS, "g1") is None
+ assert fs.get(glink.CODES, key)["redeemed_at"] is None
+
+
+async def test_unknown_or_malformed_code_is_refused(fs):
+ assert _status(await _redeem("NOPE")) == 404
+ assert _status(await _redeem("ABC-DEFG-HJK")) == 404
+
+
+async def test_wrong_org_cannot_take_over_a_linked_server(fs):
+ a = await _mint(OWNER_A)
+ assert _status(await _redeem(a["code"])) == 200
+ b = await _mint(OWNER_B)
+ resp = await _redeem(b["code"], user="u9")
+ assert _status(resp) == 409
+ assert fs.get(streams.GUILDS, "g1")["org_id"] == "org-a"
+ assert fs.get(glink.CODES, glink.normalize(b["code"]))["redeemed_at"] is None
+ assert fs.stopped == []
+
+
+async def test_same_org_relink_is_idempotent(fs):
+ first = await _mint()
+ await _redeem(first["code"])
+ linked_at = fs.get(streams.GUILDS, "g1")["linked_at"]
+ second = await _mint()
+ resp = await _redeem(second["code"])
+ assert resp["already_linked"] is True
+ assert fs.get(streams.GUILDS, "g1")["linked_at"] == linked_at
+
+
+async def test_redeem_rate_limited_per_server(fs):
+ for _ in range(5):
+ await _redeem("WRONGCODE1", user="u-many")
+ with pytest.raises(HTTPException) as e:
+ await _redeem("WRONGCODE1", user="u-other")
+ assert e.value.status_code == 429
+
+
+# ---------------------------------------------------------------------------
+# DISCORD_UNLINKED_GUILD_ORG_ID back-compat
+# ---------------------------------------------------------------------------
+
+async def test_linking_away_from_the_fallback_org_stops_its_streams(fs, monkeypatch):
+ monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
+ assert await streams.resolve_guild_org("g1") == "founding"
+ out = await _mint()
+ await _redeem(out["code"])
+ assert fs.stopped == [("g1", "relinked")]
+ assert await streams.resolve_guild_org("g1") == "org-a"
+
+
+async def test_linking_to_the_fallback_org_itself_stops_nothing(fs, monkeypatch):
+ monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "org-a")
+ out = await _mint()
+ await _redeem(out["code"])
+ assert fs.stopped == []
+
+
+# ---------------------------------------------------------------------------
+# List / unlink
+# ---------------------------------------------------------------------------
+
+async def test_list_is_scoped_to_the_callers_org(fs, monkeypatch):
+ await _redeem((await _mint(OWNER_A))["code"], guild="g1")
+ await _redeem((await _mint(OWNER_B))["code"], guild="g2", user="u2")
+ out = await router.linked_guilds(org_id="org-b", decoded=OWNER_A) # non-admin override ignored
+ assert [g["guild_id"] for g in out["guilds"]] == ["g1"]
+ assert out["unlinked_guilds_restricted"] is True
+ monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
+ assert (await router.linked_guilds(org_id=None, decoded=OWNER_A))["unlinked_guilds_restricted"] is False
+
+
+async def test_unlink_stops_streams_and_never_falls_back(fs, monkeypatch):
+ monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
+ await _redeem((await _mint())["code"])
+ fs.stopped.clear()
+ with pytest.raises(HTTPException) as e:
+ await router.unlink_guild("g1", org_id=None, decoded=OWNER_B) # not B's server
+ assert e.value.status_code == 404
+ out = await router.unlink_guild("g1", org_id=None, decoded=OWNER_A)
+ assert out["ok"] and fs.stopped == [("g1", "unlinked")]
+ assert await streams.resolve_guild_org("g1") is None # NOT "founding"
+ assert len(_audit(fs, "discord.guild.unlink")) == 1
+ # ...and it can be linked again, by any org, with a fresh code.
+ resp = await _redeem((await _mint(OWNER_B))["code"])
+ assert resp["org_id"] == "org-b"
+ assert await streams.resolve_guild_org("g1") == "org-b"
diff --git a/drb-frontend/app/settings/organization/page.tsx b/drb-frontend/app/settings/organization/page.tsx
index e74e31d..c5d4d97 100644
--- a/drb-frontend/app/settings/organization/page.tsx
+++ b/drb-frontend/app/settings/organization/page.tsx
@@ -10,6 +10,7 @@ import { Card, CardHeader } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Skeleton } from "@/components/ui/Skeleton";
+import { DiscordServersCard } from "@/components/DiscordServersCard";
function StatTile({ label, value }: { label: string; value: string | number }) {
return (
@@ -118,6 +119,8 @@ export default function OrganizationSettingsPage() {
+
(null);
+ const [justLinked, setJustLinked] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [actionError, setActionError] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [now, setNow] = useState(() => Date.now());
+ const known = useRef | null>(null);
+
+ const load = useCallback(async () => {
+ try {
+ const res = await c2api.listLinkedDiscordGuilds();
+ setRestricted(res.unlinked_guilds_restricted);
+ setLoadError(null);
+ setGuilds(res.guilds);
+ return res.guilds;
+ } catch (err) {
+ setLoadError(err instanceof Error ? err.message : "Could not load Discord servers.");
+ return null;
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!canEdit) return;
+ load().then((g) => { if (g) known.current = new Set(g.map((x) => x.guild_id)); });
+ }, [canEdit, load]);
+
+ const msLeft = code ? new Date(code.expires_at).getTime() - now : 0;
+ const live = !!code && msLeft > 0;
+
+ // While a code is live: tick the countdown, and poll the list so the page
+ // notices the moment someone runs /drb link in Discord.
+ useEffect(() => {
+ if (!live) return;
+ const tick = setInterval(() => setNow(Date.now()), 1000);
+ const poll = setInterval(async () => {
+ const g = await load();
+ if (!g) return;
+ const prev = known.current;
+ const added = prev ? g.find((x) => !prev.has(x.guild_id)) : undefined;
+ known.current = new Set(g.map((x) => x.guild_id));
+ if (added) {
+ setCode(null);
+ setJustLinked(added.guild_name || `guild ${shortId(added.guild_id)}`);
+ }
+ }, POLL_MS);
+ return () => { clearInterval(tick); clearInterval(poll); };
+ }, [live, load]);
+
+ async function generate() {
+ setBusy(true);
+ setActionError(null);
+ setJustLinked(null);
+ setCopied(false);
+ try {
+ const res = await c2api.createDiscordLinkCode();
+ setNow(Date.now());
+ setCode(res);
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : "Could not generate a code.");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function unlink(g: Guild) {
+ const name = g.guild_name || `guild ${shortId(g.guild_id)}`;
+ if (!window.confirm(`Unlink ${name}? Its radio channels stop, and it can't use DRB until it is linked again.`)) {
+ return;
+ }
+ setActionError(null);
+ try {
+ await c2api.unlinkDiscordGuild(g.guild_id);
+ const next = await load();
+ if (next) known.current = new Set(next.map((x) => x.guild_id));
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : "Could not unlink that server.");
+ }
+ }
+
+ function copy() {
+ if (!code) return;
+ navigator.clipboard?.writeText(code.command).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ }
+
+ return (
+
+
+ A link code is run as /drb link by someone with Manage Server in that Discord server.>}
+ action={
+
+ }
+ />
+
+
+ {(code || justLinked || actionError) && (
+
+ {actionError && {actionError}
}
+ {justLinked && Linked {justLinked}. Its /join, /leave and /status now act for this organization.
}
+ {code && live && (
+
+
+ {code.code}
+ single use · expires in {fmtLeft(msLeft)}
+
+
+ {code.command}
+
+
+
+ Run it in the Discord server you want to link. That server can then use this organization's
+ systems and radio channels — nothing from any other organization. Generating a new code cancels this one.
+
+
+ )}
+ {code && !live && That code expired. Generate a new one.
}
+
+ )}
+
+
+
+
+
+ Server
+ Linked
+ Status
+
+
+
+
+ {guilds === null && !loadError && (
+ Loading…
+ )}
+ {loadError && (
+ {loadError}
+ )}
+ {guilds?.length === 0 && (
+ No Discord servers linked yet.
+ )}
+ {guilds?.map((g) => (
+
+
+ {g.guild_name || "Unnamed server"}
+ guild {shortId(g.guild_id)}
+
+ {fmtDate(g.linked_at)}
+ Linked
+
+ {canEdit && (
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ {restricted ? (
+ <>Unlinked servers see only /drb link and /help. >
+ ) : isAdmin ? (
+ <>Platform setting: unlinked servers still act for the default organization (DISCORD_UNLINKED_GUILD_ORG_ID) until linked. >
+ ) : null}
+ Every command and autocomplete is scoped to the linked organization — no server sees another
+ organization's nodes, systems or bots.
+
+
+ );
+}
diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts
index da9c7bc..c1dd9a3 100644
--- a/drb-frontend/lib/c2api.ts
+++ b/drb-frontend/lib/c2api.ts
@@ -384,6 +384,24 @@ export const c2api = {
revokeEnrollmentToken: (tokenId: string) =>
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
+ // Discord server ↔ org linking (server-26#213). Owner mints a single-use
+ // code; someone with Manage Server redeems it with /drb link in Discord.
+ createDiscordLinkCode: () =>
+ request<{ code: string; expires_at: string; expires_minutes: number; command: string }>(
+ "/discord/link-codes",
+ { method: "POST" }
+ ),
+ listLinkedDiscordGuilds: () =>
+ request<{
+ guilds: { guild_id: string; guild_name: string | null; linked_at: string | null; linked_via: string }[];
+ unlinked_guilds_restricted: boolean;
+ }>("/discord/linked-guilds"),
+ unlinkDiscordGuild: (guildId: string) =>
+ request<{ ok: boolean; guild_id: string; streams_stopped: number }>(
+ `/discord/linked-guilds/${encodeURIComponent(guildId)}`,
+ { method: "DELETE" }
+ ),
+
// 511NY cameras/events inside a map bbox (server-26#183)
get511: (bbox: { south: number; west: number; north: number; east: number }, layers: string) =>
request(`/traffic/511?${new URLSearchParams({
diff --git a/drb-server-discord-bot/app/bot.py b/drb-server-discord-bot/app/bot.py
index 4679d81..06a116c 100644
--- a/drb-server-discord-bot/app/bot.py
+++ b/drb-server-discord-bot/app/bot.py
@@ -2,16 +2,19 @@ import asyncio
import discord
from discord.ext import commands
from app.config import settings
+from app.internal.guild_gate import GatedTree
from app.internal.logger import logger
class DRBBot(commands.Bot):
def __init__(self):
intents = discord.Intents.default()
- super().__init__(command_prefix="!", intents=intents)
+ # GatedTree: unlinked servers get only /drb link and /help (server-26#213).
+ super().__init__(command_prefix="!", intents=intents, tree_cls=GatedTree)
async def setup_hook(self):
await self.load_extension("app.commands.radio")
+ await self.load_extension("app.commands.drb")
await self.load_extension("app.commands.trips")
if settings.dev_guild_id:
diff --git a/drb-server-discord-bot/app/commands/drb.py b/drb-server-discord-bot/app/commands/drb.py
new file mode 100644
index 0000000..b1d7070
--- /dev/null
+++ b/drb-server-discord-bot/app/commands/drb.py
@@ -0,0 +1,55 @@
+"""/drb — server-level DRB setup. Today: /drb link (server-26#213 item 1)."""
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from app.internal import guild_gate
+from app.internal.c2_client import c2
+
+
+class DRBCommands(commands.Cog):
+ drb = app_commands.Group(
+ name="drb",
+ description="Set up DRB in this server.",
+ guild_only=True,
+ default_permissions=discord.Permissions(manage_guild=True),
+ )
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @drb.command(name="link", description="Link this server to your DRB organization.")
+ @app_commands.describe(code="The code from DRB → Settings → Organization → Discord servers.")
+ async def link(self, interaction: discord.Interaction, code: str):
+ await interaction.response.defer(ephemeral=True)
+ # default_permissions is only the default — a server admin can grant
+ # the command to anyone under Integrations. Linking decides which
+ # organization's radio this whole server gets, so hold the line here.
+ perms = getattr(interaction.user, "guild_permissions", None)
+ if not perms or not perms.manage_guild:
+ await interaction.followup.send("Only members with **Manage Server** can link this server.",
+ ephemeral=True)
+ return
+ ok, body = await c2.redeem_guild_link(
+ interaction.guild_id, code, str(interaction.user.id),
+ interaction.guild.name if interaction.guild else None,
+ )
+ guild_gate.forget(interaction.guild_id)
+ radio = self.bot.get_cog("RadioCommands")
+ if radio is not None:
+ radio._overview.pop(interaction.guild_id, None)
+ if not ok:
+ await interaction.followup.send(body.get("detail") or "DRB could not link this server.",
+ ephemeral=True)
+ return
+ org = body.get("org_name") or "your DRB organization"
+ if body.get("already_linked"):
+ text = f"This server was already linked to **{org}**. Nothing changed."
+ else:
+ text = (f"This server is now linked to **{org}**.\n"
+ "`/join`, `/leave` and `/status` now act for that organization only.")
+ await interaction.followup.send(text, ephemeral=True)
+
+
+async def setup(bot: commands.Bot):
+ await bot.add_cog(DRBCommands(bot))
diff --git a/drb-server-discord-bot/app/commands/radio.py b/drb-server-discord-bot/app/commands/radio.py
index 0c7e8ab..e3feea0 100644
--- a/drb-server-discord-bot/app/commands/radio.py
+++ b/drb-server-discord-bot/app/commands/radio.py
@@ -226,6 +226,15 @@ class RadioCommands(commands.Cog):
@app_commands.command(name="help", description="How to use this bot.")
async def help(self, interaction: discord.Interaction):
embed = discord.Embed(title="DRB Radio Bot — Help", color=0x5865f2)
+ embed.add_field(
+ name="/drb link `code`",
+ value=(
+ "Link this server to your DRB organization — needed before anything below works. "
+ "An organization owner gets the code in DRB under Settings → Organization → Discord servers; "
+ "someone with Manage Server runs this."
+ ),
+ inline=False,
+ )
embed.add_field(
name="/join `system` [`bot`] [`persistent`]",
value=(
diff --git a/drb-server-discord-bot/app/internal/c2_client.py b/drb-server-discord-bot/app/internal/c2_client.py
index b623df7..18a8f7e 100644
--- a/drb-server-discord-bot/app/internal/c2_client.py
+++ b/drb-server-discord-bot/app/internal/c2_client.py
@@ -68,6 +68,28 @@ class C2Client:
logger.error(f"C2 stop_guild failed: {e}")
return None
+ async def redeem_guild_link(self, guild_id: int | str, code: str, discord_user_id: str,
+ guild_name: Optional[str]) -> tuple[bool, dict]:
+ """/drb link code: (server-26#213). Returns (ok, body); a refusal body
+ carries C2's own ``detail`` text, which is written for the end user."""
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.post(
+ f"{self.base}/discord/guilds/{guild_id}/link/redeem",
+ json={"code": code, "discord_user_id": discord_user_id, "guild_name": guild_name},
+ headers=self._headers(),
+ )
+ body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
+ if r.status_code == 429:
+ return False, {"detail": "Too many link attempts from this server. Wait ten minutes "
+ "and try again.", "code": "rate_limited"}
+ if r.status_code >= 400:
+ return False, body if isinstance(body, dict) else {"detail": str(body)}
+ return True, body
+ except Exception as e:
+ logger.error(f"C2 redeem_guild_link failed: {e}")
+ return False, {"detail": "Could not reach DRB. Try again in a minute.", "code": "c2_unreachable"}
+
# ------------------------------------------------------------------
# Trips
# ------------------------------------------------------------------
diff --git a/drb-server-discord-bot/app/internal/guild_gate.py b/drb-server-discord-bot/app/internal/guild_gate.py
new file mode 100644
index 0000000..25fcf5a
--- /dev/null
+++ b/drb-server-discord-bot/app/internal/guild_gate.py
@@ -0,0 +1,75 @@
+"""
+Unlinked servers see only /drb link and /help (server-26#213).
+
+Discord registers slash commands globally, so an unlinked server still lists
+the others in its command picker; this gate answers them with how to link
+instead of running them. Per-guild command sync would hide them outright but
+costs one sync per server per change — not worth it while C2 refuses every
+unlinked request anyway (this gate is UX, C2 is the enforcement).
+
+Whether a server is "linked" is C2's answer: its own discord_guilds link, or
+DISCORD_UNLINKED_GUILD_ORG_ID when that is set — so with the default
+("founding") nothing is gated, exactly as before.
+"""
+from __future__ import annotations
+
+import time
+from typing import Optional
+
+import discord
+from discord import app_commands
+
+from app.internal.c2_client import c2
+from app.internal.logger import logger
+
+ALWAYS_ALLOWED = frozenset({"drb link", "help"})
+LINKED_TTL = 60.0
+UNLINKED_TTL = 10.0 # short, so a fresh /drb link (or a web-side link) shows up fast
+
+UNLINKED_TEXT = (
+ "This server is not linked to a DRB organization yet.\n"
+ "An owner of the DRB organization generates a code under **Settings → Organization → Discord servers**; "
+ "then someone with **Manage Server** here runs `/drb link code:`."
+)
+
+_cache: dict[int, tuple[float, bool]] = {}
+
+
+async def is_linked(guild_id: int) -> Optional[bool]:
+ """True/False from C2, or None when C2 could not be reached."""
+ hit = _cache.get(guild_id)
+ if hit and time.monotonic() < hit[0]:
+ return hit[1]
+ data = await c2.guild_overview(guild_id)
+ if data.get("error"):
+ return None
+ linked = bool(data.get("org_id"))
+ _cache[guild_id] = (time.monotonic() + (LINKED_TTL if linked else UNLINKED_TTL), linked)
+ return linked
+
+
+def forget(guild_id: int) -> None:
+ _cache.pop(guild_id, None)
+
+
+class GatedTree(app_commands.CommandTree):
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ if interaction.guild_id is None:
+ return True
+ if interaction.type not in (discord.InteractionType.application_command,
+ discord.InteractionType.autocomplete):
+ return True
+ cmd = interaction.command
+ if cmd is not None and cmd.qualified_name in ALWAYS_ALLOWED:
+ return True
+ # None = C2 unreachable: let the command run and report that itself.
+ if await is_linked(interaction.guild_id) is not False:
+ return True
+ try:
+ if interaction.type is discord.InteractionType.autocomplete:
+ await interaction.response.autocomplete([])
+ else:
+ await interaction.response.send_message(UNLINKED_TEXT, ephemeral=True)
+ except discord.HTTPException as e:
+ logger.warning(f"unlinked-guild reply failed: {e}")
+ return False