Discord: self-serve server→org linking with /drb link (#213 item 1) #218

Merged
logan merged 2 commits from feat/213-guild-link into main 2026-10-04 22:04:31 -04:00
Owner

Slice B of server-26#213 — item 1: self-serve Discord server → org linking. Refs #213 (items 2–4 are sibling slices; this PR pairs with the #213 admin-page / node-join / systems-picker PRs, and only touches a shared file additively: drb-frontend/lib/c2api.ts, plus a 4-line change in discord_streams.resolve_guild_org).

What changed

c2-core (new routers/discord_link.py, internal/discord_guild_link.py)

  • POST /discord/link-codes — org owner / platform admin mints a code (XXX-XXXX-XXX, 10 chars over a 31-symbol unambiguous alphabet, 10-min TTL, single use). Minting revokes the org's older unredeemed codes.
  • POST /discord/guilds/{guild_id}/link/redeem — Discord bot only (service key). One Firestore transaction reads the code + guild docs before writing, so two servers racing for one code → exactly one wins, and a server linked to org A refuses org B's code (409) until A unlinks it.
  • GET /discord/linked-guilds, DELETE /discord/linked-guilds/{id} — the caller's org's servers; unlink stops that server's radio channels.
  • Rate limits (in-process _RateLimiter): mint 5/user/10 min; redeem 5/server and 10/Discord user per 10 min.
  • write_audit on every mint (discord.link_code.create), link (discord.guild.link), refusal (discord.guild.link_refused, with reason), unlink (discord.guild.unlink). Redeemer stored as a Discord user id only — no usernames.
  • resolve_guild_org: a server an org explicitly unlinked no longer falls back to DISCORD_UNLINKED_GUILD_ORG_ID (otherwise unlinking org B's server would hand it to the founding org). Linking a server away from the fallback org stops the streams it had playing for that org.

Discord bot

  • /drb link code: (new commands/drb.py) — default_permissions(manage_guild) and a runtime Manage Server check (server admins can re-grant default perms under Integrations).
  • GatedTree (internal/guild_gate.py): in an unlinked server every command except /drb link and /help is answered with how to link; autocomplete returns nothing. Discord registers commands globally, so they still appear in the picker — per-guild sync would hide them but costs a sync per server per change; C2 refuses unlinked requests regardless, so the gate is UX, C2 is enforcement. C2 unreachable → command runs and reports that itself.
  • /help gains a /drb link entry.

Frontend — Settings → Organization → Linked Discord servers card (matches the mockup's Servers panel): Generate link code → code + live countdown + copy /drb link code:…; polls every 5 s while the code is live and shows "Linked " when it lands; table of linked servers with Unlink. Plan / incident-forum / roles / limits columns from the mockup are omitted — nothing backs them yet. Every button hits a real endpoint.

DISCORD_UNLINKED_GUILD_ORG_ID — back-compat

Default stays founding: every unlinked server keeps acting for the founding org and nothing is gated — today's deployment is unchanged by this merge.
Set it to "" once every Discord server that should keep working has been linked (via /drb link, or admin PUT /discord/guilds/{id}/link). From then on an unlinked server sees only /drb link and /help. The org settings card tells a platform admin which mode is live (unlinked_guilds_restricted).

Verification (real runs)

  • ~/venvs/drb-5c/bin/python -m pytest -q in drb-c2-core: 624 passed (16 new in tests/test_discord_guild_link.py: single-use, two-server race, one-server/two-org race — both assert the race actually retried — expiry, revoked/malformed codes, wrong-org takeover, idempotent same-org relink, mint/redeem rate limits, member can't mint, fallback-org stop, scoped list, unlink never falls back).
  • Frontend npx tsc --noEmit (Node 20.20.2): exit 0.
  • Bot: no test harness exists for drb-server-discord-bot; import-checked with discord.py 2.7.1 and drove GatedTree.interaction_check against the real loaded command tree (unlinked: /drb link + /help allowed, /join blocked with the link text, autocomplete → []; linked → allowed; C2 down → allowed; DMs → allowed). Not run against a live Discord gateway.

Not in this PR

  • /trip and the account /link are also gated in unlinked servers (the spec's "only /drb link and /help"). With the default fallback set this changes nothing.
  • Rate limits are per c2-core process (same as every other limiter here).

🤖 Generated with Claude Code

Slice B of server-26#213 — **item 1: self-serve Discord server → org linking.** Refs #213 (items 2–4 are sibling slices; this PR pairs with the #213 admin-page / node-join / systems-picker PRs, and only touches a shared file additively: `drb-frontend/lib/c2api.ts`, plus a 4-line change in `discord_streams.resolve_guild_org`). ## What changed **c2-core** (new `routers/discord_link.py`, `internal/discord_guild_link.py`) - `POST /discord/link-codes` — org owner / platform admin mints a code (`XXX-XXXX-XXX`, 10 chars over a 31-symbol unambiguous alphabet, **10-min TTL, single use**). Minting revokes the org's older unredeemed codes. - `POST /discord/guilds/{guild_id}/link/redeem` — Discord bot only (service key). One Firestore transaction reads the code + guild docs before writing, so **two servers racing for one code → exactly one wins**, and **a server linked to org A refuses org B's code** (409) until A unlinks it. - `GET /discord/linked-guilds`, `DELETE /discord/linked-guilds/{id}` — the caller's org's servers; unlink stops that server's radio channels. - Rate limits (in-process `_RateLimiter`): mint 5/user/10 min; redeem 5/server and 10/Discord user per 10 min. - `write_audit` on every mint (`discord.link_code.create`), link (`discord.guild.link`), refusal (`discord.guild.link_refused`, with reason), unlink (`discord.guild.unlink`). Redeemer stored as a Discord user id only — no usernames. - `resolve_guild_org`: a server an org **explicitly unlinked** no longer falls back to `DISCORD_UNLINKED_GUILD_ORG_ID` (otherwise unlinking org B's server would hand it to the founding org). Linking a server away from the fallback org stops the streams it had playing for that org. **Discord bot** - `/drb link code:` (new `commands/drb.py`) — `default_permissions(manage_guild)` **and** a runtime Manage Server check (server admins can re-grant default perms under Integrations). - `GatedTree` (`internal/guild_gate.py`): in an unlinked server every command except `/drb link` and `/help` is answered with how to link; autocomplete returns nothing. Discord registers commands globally, so they still appear in the picker — per-guild sync would hide them but costs a sync per server per change; C2 refuses unlinked requests regardless, so the gate is UX, C2 is enforcement. C2 unreachable → command runs and reports that itself. - `/help` gains a `/drb link` entry. **Frontend** — Settings → Organization → **Linked Discord servers** card (matches the mockup's Servers panel): Generate link code → code + live countdown + copy `/drb link code:…`; polls every 5 s while the code is live and shows "Linked <server>" when it lands; table of linked servers with Unlink. Plan / incident-forum / roles / limits columns from the mockup are omitted — nothing backs them yet. Every button hits a real endpoint. ## `DISCORD_UNLINKED_GUILD_ORG_ID` — back-compat Default stays `founding`: every unlinked server keeps acting for the founding org and **nothing is gated** — today's deployment is unchanged by this merge. **Set it to `""`** once every Discord server that should keep working has been linked (via `/drb link`, or admin `PUT /discord/guilds/{id}/link`). From then on an unlinked server sees only `/drb link` and `/help`. The org settings card tells a platform admin which mode is live (`unlinked_guilds_restricted`). ## Verification (real runs) - `~/venvs/drb-5c/bin/python -m pytest -q` in drb-c2-core: **624 passed** (16 new in `tests/test_discord_guild_link.py`: single-use, two-server race, one-server/two-org race — both assert the race actually retried — expiry, revoked/malformed codes, wrong-org takeover, idempotent same-org relink, mint/redeem rate limits, member can't mint, fallback-org stop, scoped list, unlink never falls back). - Frontend `npx tsc --noEmit` (Node 20.20.2): **exit 0**. - Bot: no test harness exists for `drb-server-discord-bot`; import-checked with discord.py 2.7.1 and drove `GatedTree.interaction_check` against the real loaded command tree (unlinked: `/drb link` + `/help` allowed, `/join` blocked with the link text, autocomplete → `[]`; linked → allowed; C2 down → allowed; DMs → allowed). Not run against a live Discord gateway. ## Not in this PR - `/trip` and the account `/link` are also gated in unlinked servers (the spec's "only /drb link and /help"). With the default fallback set this changes nothing. - Rate limits are per c2-core process (same as every other limiter here). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-04 20:02:26 -04:00
- 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>
logan added 1 commit 2026-10-04 20:28:57 -04:00
The card only loads for owners/admins (the list endpoint is owner-only), so a
member saw "Loading…" forever. Show an owner-only note instead. Also assert the
one-server/two-org race really retried, as the PR body already claimed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan merged commit dbb7d7f944 into main 2026-10-04 22:04:31 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#218