Discord: Connect a Discord server — OAuth UI (#225 S7) — merge after S6 #259

Merged
logan merged 4 commits from feat/225-s7-oauth-ui into main 2026-10-05 03:11:23 -04:00
Owner

Merge after S6 (the OAuth backend). Without S6 the Connect button gets a 404 and the card falls back to the link code, so nothing breaks, but nothing new works either.

Part of #225, slice S7 of the plan (comment 3108). Mostly frontend; one c2-core change (system_id on OAuth start/options) plus the #257 voice-check state.

Fixes #256 #257 #258

What changed

  • /discord/connected (new): the redirect URI to register on each bot app (https://drb.cusano.net/discord/connected).
    • Stashes code/state in sessionStorage (10 min TTL), strips them from the URL with replaceState, no-referrer via metadata and a response header.
    • Signed out: /login?next=/discord/connected, then resumes. error=access_denied: "Nothing changed". Other error values: only a short code is echoed, never free text.
    • Sends POST /discord/oauth/complete {code, state} once (ref guard; the code is single-use). Result card: "Linked X and added Bot. N voice channels ready, M need a permission fix" + a button to return_to.
  • middleware.ts: callback path added to NO_SESSION_COOKIE_GATE (plan finding 2). New finding: it is needed for signed-in users too. drb_session is SameSite=Strict, so the browser does not send it on the cross-site redirect back from discord.com, and the middleware would have bounced everyone to /login with the code dropped.
  • /login: honours ?next= for an allowlist of exactly one path, so there is no open redirect.
  • DiscordServersCard: "Connect a Discord server" is the primary button, with a radio-bot picker: Automatic / the bot for a system / the org's own bots (from GET /tokens/org/preferred). /drb link is a collapsed secondary option and opens itself when start answers 404.
  • RadioBotsPanel (admin): write-only client-secret field per bot, "Connect on/off", "Turn off", and "N of 100 servers" with the 90-server pool cap warning.
  • lib/discordOAuth.ts (new, kept out of c2api.ts): the only navigation target it accepts is an https://discord.com/.../oauth2/authorize URL.

Contract assumed (S6 was still uncommitted when this was built; I read its in-progress internal/discord_oauth.py)

Call Body / response this UI uses
POST /discord/oauth/start {token_id?, system_id?, node_id?, return_to} → {url | authorize_url, bot_name?}; 404 = no fitting bot has a secret
POST /discord/oauth/complete {code, state} → {guild_id, guild_name, channels_ready, channels_blocked, return_to, bot_name?, stopped?} (counts or lists both accepted)
PUT /tokens/{id}/oauth {client_secret}; never echoed
DELETE /tokens/{id}/oauth clears it (matches S6's clear_secret)
GET /tokens oauth_enabled: bool in the allowlist
overview limits.pool_bot_guild_cap optional; UI defaults to 90

Alignment points for S6 (resolved in 32d223e: start/options accept system_id): S6's state stores node_id, not system_id. If start ignores system_id, the "bot for a system" choice quietly falls back to Automatic. Either accept system_id in S6, or I'll drop that option. If GET /tokens doesn't expose oauth_enabled, the admin column always reads "Connect off".

Verification

  • tsc --noEmit: exit 0
  • next build (dummy Firebase env): success, /discord/connected prerendered static
  • Not run: drb-qa-review, security-review skill (plan asks for both on S6+S7; for the main session after S6 lands). No live OAuth test: needs S6 + a registered redirect URI.

Update 32d223e (after merging main 2c254c1)

  • c2-core: POST /discord/oauth/start and GET /discord/oauth/options take an optional system_id (org's own or shared system; another org's = 404). The system's preferred bot is strict: if it can't connect, 404, never a quiet swap to Automatic.
  • #257: new invalid channel state + "Radio bot needs replacing" badge, no invite links.
  • #256: Start re-checks with Discord on click instead of staying disabled on "Needs fix".
  • #258: tooltip, "DRB support" wording, plain "not self-serve yet" copy (no support address exists; #47), load-error retry.
  • c2-core pytest 913 passed; tsc exit 0.

🤖 Generated with Claude Code

**Merge after S6** (the OAuth backend). Without S6 the Connect button gets a 404 and the card falls back to the link code, so nothing breaks, but nothing new works either. Part of #225, slice S7 of the plan (comment 3108). Mostly frontend; one c2-core change (system_id on OAuth start/options) plus the #257 voice-check state. Fixes #256 #257 #258 ## What changed - **`/discord/connected`** (new): the redirect URI to register on each bot app (`https://drb.cusano.net/discord/connected`). - Stashes `code`/`state` in sessionStorage (10 min TTL), strips them from the URL with `replaceState`, `no-referrer` via metadata **and** a response header. - Signed out: `/login?next=/discord/connected`, then resumes. `error=access_denied`: "Nothing changed". Other `error` values: only a short code is echoed, never free text. - Sends `POST /discord/oauth/complete {code, state}` once (ref guard; the code is single-use). Result card: "Linked X and added Bot. N voice channels ready, M need a permission fix" + a button to `return_to`. - **`middleware.ts`**: callback path added to `NO_SESSION_COOKIE_GATE` (plan finding 2). **New finding:** it is needed for signed-in users too. `drb_session` is `SameSite=Strict`, so the browser does not send it on the cross-site redirect back from discord.com, and the middleware would have bounced *everyone* to `/login` with the code dropped. - **`/login`**: honours `?next=` for an allowlist of exactly one path, so there is no open redirect. - **`DiscordServersCard`**: "Connect a Discord server" is the primary button, with a radio-bot picker: Automatic / the bot for a system / the org's own bots (from `GET /tokens/org/preferred`). `/drb link` is a collapsed secondary option and opens itself when start answers 404. - **`RadioBotsPanel`** (admin): write-only client-secret field per bot, "Connect on/off", "Turn off", and "N of 100 servers" with the 90-server pool cap warning. - **`lib/discordOAuth.ts`** (new, kept out of `c2api.ts`): the only navigation target it accepts is an `https://discord.com/.../oauth2/authorize` URL. ## Contract assumed (S6 was still uncommitted when this was built; I read its in-progress `internal/discord_oauth.py`) | Call | Body / response this UI uses | |---|---| | `POST /discord/oauth/start` | `{token_id?, system_id?, node_id?, return_to}` → `{url \| authorize_url, bot_name?}`; 404 = no fitting bot has a secret | | `POST /discord/oauth/complete` | `{code, state}` → `{guild_id, guild_name, channels_ready, channels_blocked, return_to, bot_name?, stopped?}` (counts or lists both accepted) | | `PUT /tokens/{id}/oauth` | `{client_secret}`; never echoed | | `DELETE /tokens/{id}/oauth` | clears it (matches S6's `clear_secret`) | | `GET /tokens` | `oauth_enabled: bool` in the allowlist | | overview `limits.pool_bot_guild_cap` | optional; UI defaults to 90 | **Alignment points for S6 (resolved in 32d223e: start/options accept system_id):** S6's state stores `node_id`, not `system_id`. If start ignores `system_id`, the "bot for a system" choice quietly falls back to Automatic. Either accept `system_id` in S6, or I'll drop that option. If `GET /tokens` doesn't expose `oauth_enabled`, the admin column always reads "Connect off". ## Verification - `tsc --noEmit`: exit 0 - `next build` (dummy Firebase env): success, `/discord/connected` prerendered static - Not run: `drb-qa-review`, `security-review` skill (plan asks for both on S6+S7; for the main session after S6 lands). No live OAuth test: needs S6 + a registered redirect URI. ## Update 32d223e (after merging main 2c254c1) - c2-core: `POST /discord/oauth/start` and `GET /discord/oauth/options` take an optional `system_id` (org's own or shared system; another org's = 404). The system's preferred bot is strict: if it can't connect, 404, never a quiet swap to Automatic. - #257: new `invalid` channel state + "Radio bot needs replacing" badge, no invite links. - #256: Start re-checks with Discord on click instead of staying disabled on "Needs fix". - #258: tooltip, "DRB support" wording, plain "not self-serve yet" copy (no support address exists; #47), load-error retry. - c2-core pytest 913 passed; tsc exit 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 02:52:18 -04:00
- /discord/connected: Discord's OAuth redirect target. Stashes code/state
  in sessionStorage, strips them from the URL (replaceState), no-referrer
  (metadata + response header), sends a signed-out user to
  /login?next=/discord/connected and resumes, access_denied = "Nothing
  changed", result card "Linked X: N ready, M need a fix".
- middleware.ts: the callback path joins NO_SESSION_COOKIE_GATE (plan
  finding 2). Also needed for signed-in users: drb_session is
  SameSite=Strict, so it is not sent on the cross-site redirect back from
  discord.com.
- /login honours ?next= for an allowlist of one path (no open redirect).
- DiscordServersCard: Connect is the primary action with a radio-bot
  picker (automatic / the bot for a system / the org's own bots); the
  /drb link code moves to a collapsed secondary option and opens itself
  when /discord/oauth/start answers 404.
- RadioBotsPanel: write-only client-secret field per bot (PUT/DELETE
  /tokens/{id}/oauth), "Connect on/off", "N of 100 servers" with the
  pool cap (90) warning.
- lib/discordOAuth.ts: the S6 client, kept out of c2api.ts; only ever
  navigates to a discord.com /oauth2/authorize URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan added 1 commit 2026-10-05 02:56:24 -04:00
- complete: S6 returns streams_stopped, not stopped — the "N radio
  channels were stopped" note never showed.
- start: bot name is nested as bot.name.
- "The bot for a system" with an org-owned preferred bot now sends
  token_id (S6 accepts it today); system_id only for a platform-pinned
  pool bot, which still needs S6 to accept system_id.
- A 404 for one chosen bot no longer hides Connect and the picker
  (the message told the user to try Automatic, but the button was gone).
- Server count uses GET /tokens' per-bot server_cap when present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Review (S7, checked against S6's local commit db4e326, not yet pushed). Pushed eaa0be0 fixing: streams_stopped vs stopped on /complete (stopped-streams note never rendered), bot.name nesting on /start, a per-bot 404 hiding the Connect button, and 'bot for a system' now sends token_id when the preferred bot is the org's own. S6 db4e326 already has oauth_enabled on GET /tokens and DELETE /tokens/{id}/oauth. Still open before merge: S6's StartBody has no system_id, so choosing a system whose preferred bot is a platform-pinned pool bot silently falls back to Automatic (pydantic ignores the extra field). Either S6 adds system_id (same branch as its node_id path) or S7 drops those options. tsc exit=0; c2-core pytest 810 passed (no Python in this PR). No live OAuth run.

🤖 Generated with Claude Code

Review (S7, checked against S6's local commit db4e326, not yet pushed). Pushed eaa0be0 fixing: `streams_stopped` vs `stopped` on /complete (stopped-streams note never rendered), `bot.name` nesting on /start, a per-bot 404 hiding the Connect button, and 'bot for a system' now sends `token_id` when the preferred bot is the org's own. S6 db4e326 already has `oauth_enabled` on GET /tokens and `DELETE /tokens/{id}/oauth`. **Still open before merge:** S6's `StartBody` has no `system_id`, so choosing a system whose preferred bot is a platform-pinned pool bot silently falls back to Automatic (pydantic ignores the extra field). Either S6 adds `system_id` (same branch as its `node_id` path) or S7 drops those options. tsc exit=0; c2-core pytest 810 passed (no Python in this PR). No live OAuth run. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 2 commits 2026-10-05 03:09:53 -04:00
- discord_oauth: POST /discord/oauth/start and GET /options take an optional
  system_id. The system must be the org's own or shared (another org's or
  unknown = 404). Its preferred bot is strict: one that can't connect is a
  404, never a quiet swap to Automatic. No preference = the usual order.
  The picker's "bot for a system" now always sends system_id.
- #257: _channel_state gives a bot whose token Discord rejected its own
  "invalid" state; the picker shows "Radio bot needs replacing" with no
  invite links, instead of "No radio bot".
- #256: Start no longer stays disabled on "Needs fix". It re-checks with
  Discord first (refresh=1) and starts only if the fresh answer allows it.
  "Not a voice channel" still disables Start.
- #258: own tooltip for "Not a voice channel"; "platform admin" -> "DRB
  support"; PreferredBotsCard says plainly that adding your own bot isn't
  self-serve (no support address exists yet, see #47), and a load failure
  gets a plain message + Try again instead of the raw C2 error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Pushed 32d223e (merged main 2c254c1 in 13a3525, no conflicts; eaa0be0 kept, no force-push).

  • Blocking gap closed: StartBody and GET /discord/oauth/options now take system_id. Same visibility as the node path: the org's own system or a shared one; another org's or unknown system = 404 ("System not found."). The system's preferred bot is strict: if it can't connect (no secret, at the pool cap, another org's bot), 404 instead of silently inviting Automatic. No preference = the usual order. The picker's "bot for a system" now always sends system_id. 3 new tests.
  • #257: _channel_state returns invalid when a member bot's token was rejected; the picker shows "Radio bot needs replacing" (DRB support) with no invite links. 1 new test.
  • #256: Start no longer stays disabled on "Needs fix": it re-checks with Discord (refresh=1) and starts only if the fresh answer allows it. "Not a voice channel" still disables Start, with its own tooltip. Node picker and admin Add form.
  • #258: "platform admin" -> "DRB support"; PreferredBotsCard says adding your own bot isn't self-serve yet. No support address exists anywhere (privacy/terms are TODO; #47), so no contact link was invented. Load failure: plain message + Try again.

Tests: c2-core pytest 913 passed, 4 warnings; frontend tsc --noEmit exit 0. Not run: live OAuth, drb-qa-review, security-review.

🤖 Generated with Claude Code

Pushed 32d223e (merged main 2c254c1 in 13a3525, no conflicts; eaa0be0 kept, no force-push). - **Blocking gap closed:** `StartBody` and `GET /discord/oauth/options` now take `system_id`. Same visibility as the node path: the org's own system or a shared one; another org's or unknown system = 404 ("System not found."). The system's preferred bot is strict: if it can't connect (no secret, at the pool cap, another org's bot), 404 instead of silently inviting Automatic. No preference = the usual order. The picker's "bot for a system" now always sends `system_id`. 3 new tests. - **#257:** `_channel_state` returns `invalid` when a member bot's token was rejected; the picker shows "Radio bot needs replacing" (DRB support) with no invite links. 1 new test. - **#256:** Start no longer stays disabled on "Needs fix": it re-checks with Discord (refresh=1) and starts only if the fresh answer allows it. "Not a voice channel" still disables Start, with its own tooltip. Node picker and admin Add form. - **#258:** "platform admin" -> "DRB support"; PreferredBotsCard says adding your own bot isn't self-serve yet. **No support address exists anywhere** (privacy/terms are TODO; #47), so no contact link was invented. Load failure: plain message + Try again. Tests: c2-core pytest `913 passed, 4 warnings`; frontend `tsc --noEmit` exit 0. Not run: live OAuth, drb-qa-review, security-review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan merged commit f49dc86111 into main 2026-10-05 03:11:23 -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#259