diff --git a/drb-c2-core/app/routers/discord_checks.py b/drb-c2-core/app/routers/discord_checks.py index f3160bc..29baf8f 100644 --- a/drb-c2-core/app/routers/discord_checks.py +++ b/drb-c2-core/app/routers/discord_checks.py @@ -84,6 +84,8 @@ def _channel_state(rows: list[dict], enabled: bool) -> str: return "needs_fix" # a start is refused with missing_permissions if any(r["ok"] for r in rows): return "busy" + if any(r["status"] == discord_rest.INVALID for r in rows): + return "invalid" # the bot IS here, but Discord rejected its token (#257) return "no_bot" diff --git a/drb-c2-core/app/routers/discord_oauth.py b/drb-c2-core/app/routers/discord_oauth.py index 4a793da..dbb2e44 100644 --- a/drb-c2-core/app/routers/discord_oauth.py +++ b/drb-c2-core/app/routers/discord_oauth.py @@ -14,7 +14,9 @@ Every route is a 404 while ``discord_oauth_redirect_uri`` is empty or no eligible bot has an OAuth secret (PUT /tokens/{id}/oauth). Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the -radio bot preferred for the node's system (``node_id``); else the org's own +radio bot preferred for ``system_id`` (the org's own system or a shared one; +strict: a preferred bot that can't connect is a 404, not a quiet swap); else +the radio bot preferred for the node's system (``node_id``); else the org's own bring-your-own bot; else the pool bot with the fewest servers that is under ``discord_pool_bot_guild_cap`` (owner decision Q3). Only bots with an OAuth secret set count. A bot the caller's org can't use is a 404, never a 403. @@ -30,7 +32,7 @@ application's credentials, after the org-scope checks and rate limits here. from __future__ import annotations import asyncio -from typing import Optional +from typing import Annotated, Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse @@ -81,7 +83,7 @@ def _usable(tok: dict, org_id: str, oauth_ids: set) -> bool: async def _pick_bot(org_id: str, *, node_id: Optional[str] = None, - token_id: Optional[str] = None) -> tuple[dict, str]: + token_id: Optional[str] = None, system_id: Optional[str] = None) -> tuple[dict, str]: """(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` | ``pool``. Raises HTTPException 404 when nothing fits.""" tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")} @@ -96,6 +98,21 @@ async def _pick_bot(org_id: str, *, node_id: Optional[str] = None, "right now.") return tok, "chosen" + if system_id: + # Same visibility as a node's system: the org's own or a shared one + # (no org_id). Another org's system is a 404, never a 403. + system = await fstore.doc_get("systems", system_id) + if not system or system.get("org_id") not in (None, org_id): + raise HTTPException(404, "System not found.") + pref_id = system.get("preferred_token_id") or "" + if pref_id: + # The caller asked for THIS system's bot by name: inviting a + # different one instead would be the silent swap this exists to stop. + pref = tokens.get(pref_id) + if not pref or not _usable(pref, org_id, oauth_ids): + raise HTTPException(404, "That system's radio bot can't be used to connect a server right now.") + return pref, "preferred" + if node_id: node = await fstore.doc_get("nodes", node_id) if not node or node.get("org_id") != org_id: @@ -133,17 +150,19 @@ def _bot_summary(tok: dict, why: str) -> dict: async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID), token_id: Optional[str] = Query(None, pattern=_ID), org_id: Optional[str] = None, - decoded: dict = Depends(require_org_owner_token)): + decoded: dict = Depends(require_org_owner_token), + system_id: Annotated[Optional[str], Query(pattern=_ID)] = None): """Whether Connect is offered here, and which bot it would invite.""" _require_enabled() scope = resolve_org_scope(decoded, org_id) - tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id) + tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id, system_id=system_id) return {"available": True, "bot": _bot_summary(tok, why)} class StartBody(BaseModel): node_id: Optional[str] = Field(None, pattern=_ID) token_id: Optional[str] = Field(None, pattern=_ID) + system_id: Optional[str] = Field(None, pattern=_ID) return_to: Optional[str] = None @@ -154,12 +173,14 @@ async def oauth_start(body: StartBody, org_id: Optional[str] = None, scope = resolve_org_scope(decoded, org_id) uid, email = describe_actor(decoded) start_limiter.check(f"{uid}:discord_oauth_start") - tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id) + tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id, + system_id=body.system_id) return_to = oauth.safe_return_to(body.return_to) nonce, expires_at = await oauth.create_state(org_id=scope, uid=uid, token_id=tok["token_id"], node_id=body.node_id, return_to=return_to) await write_audit(uid, email, "discord.oauth.start", details={ - "org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id}) + "org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id, + "system_id": body.system_id}) return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce), "expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)} diff --git a/drb-c2-core/tests/test_discord_oauth.py b/drb-c2-core/tests/test_discord_oauth.py index c44489e..6eefc6d 100644 --- a/drb-c2-core/tests/test_discord_oauth.py +++ b/drb-c2-core/tests/test_discord_oauth.py @@ -187,6 +187,58 @@ async def test_preferred_bot_for_the_nodes_system_wins(fs): assert e.value.status_code == 404 +async def test_system_id_invites_that_systems_preferred_bot(fs): + """The S7 picker's "bot for a system" choice: a platform-pinned pool bot + is invited by system_id, never quietly swapped for Automatic.""" + fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-1"}) + out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id="s1") + assert out["bot"] == {"name": "DRB Radio 1", "reason": "preferred", "own_bot": False} + out, q = await _start(system_id="s1", return_to="/settings") + assert q["client_id"] == "201" and out["bot"]["reason"] == "preferred" + assert _audit(fs, "discord.oauth.start")[-1]["details"]["system_id"] == "s1" + # a shared system (no org_id) serves every org, as on the node path + fs.put("systems", "shared", {"system_id": "shared", "org_id": None, "preferred_token_id": "pool-1"}) + out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_B, + system_id="shared") + assert out["bot"]["name"] == "DRB Radio 1" + # system_id outranks node_id + fs.put("nodes", "n1", {"node_id": "n1", "org_id": "org-a", "assigned_system_id": "s2"}) + fs.put("systems", "s2", {"system_id": "s2", "org_id": "org-a", "preferred_token_id": "own-a"}) + out = await router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER_A, system_id="s1") + assert out["bot"]["name"] == "DRB Radio 1" + + +async def test_system_id_of_another_org_or_unknown_is_404(fs): + fs.put("systems", "sb", {"system_id": "sb", "org_id": "org-b", "preferred_token_id": "pool-1"}) + for sid in ("sb", "nope"): + with pytest.raises(HTTPException) as e: + await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id=sid) + assert e.value.status_code == 404 and e.value.detail == "System not found." + with pytest.raises(HTTPException) as e: + await _start(system_id=sid) + assert e.value.status_code == 404 + assert not _audit(fs, "discord.oauth.start") + # a platform admin acting for org-b may use it + out = await router.oauth_options(node_id=None, token_id=None, org_id="org-b", decoded=ADMIN, system_id="sb") + assert out["bot"]["name"] == "DRB Radio 1" + + +async def test_system_id_whose_bot_cannot_connect_is_404_not_a_swap(fs): + # pool-2 is at the cap; another org's bot pinned (bad data); no secret + fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-2"}) + fs.put("systems", "s2", {"system_id": "s2", "org_id": "org-a", "preferred_token_id": "own-b"}) + fs.put("systems", "s3", {"system_id": "s3", "org_id": "org-a", "preferred_token_id": "pool-1"}) + fs.data[oauth.OAUTH].pop("pool-1") + for sid in ("s1", "s2", "s3"): + with pytest.raises(HTTPException) as e: + await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id=sid) + assert e.value.status_code == 404 and "Harbor" not in str(e.value.detail) + # no preferred bot at all: the usual order (org's own bot first) + fs.put("systems", "s4", {"system_id": "s4", "org_id": "org-a"}) + out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id="s4") + assert out["bot"]["reason"] == "org_bot" + + async def test_explicit_bot_must_be_usable_by_the_org(fs): out = await router.oauth_options(node_id=None, token_id="pool-1", org_id=None, decoded=OWNER_A) assert out["bot"]["reason"] == "chosen" diff --git a/drb-c2-core/tests/test_discord_perm_check.py b/drb-c2-core/tests/test_discord_perm_check.py index 8667b28..ffb5bbd 100644 --- a/drb-c2-core/tests/test_discord_perm_check.py +++ b/drb-c2-core/tests/test_discord_perm_check.py @@ -84,7 +84,7 @@ def fs(monkeypatch): monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "") monkeypatch.setattr(settings, "discord_perm_check_enabled", True) - # Per-token snapshot behaviour: "ok" | "muted" | "unknown" | "slow" | "not_member" + # Per-token snapshot behaviour: "ok" | "muted" | "unknown" | "slow" | "not_member" | "invalid" fake.snap_mode = {} fake.snap_calls = [] @@ -99,6 +99,8 @@ def fs(monkeypatch): return _snap(tid, status=discord_rest.UNKNOWN) if mode == "not_member": return _snap(tid, status=discord_rest.NOT_MEMBER) + if mode == "invalid": + return _snap(tid, status=discord_rest.INVALID) return _snap(tid, muted=(mode == "muted")) monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot) @@ -413,6 +415,23 @@ def test_voice_check_needs_fix_when_every_free_bot_is_denied(fs): assert out["channels"][0]["state"] == "unknown" # can't tell -> start still allowed +def test_voice_check_invalid_token_is_not_no_bot(fs): + # server-26#257: the bot is in the server but Discord rejected its token. + # "No radio bot" (and an invite to a bot already there) would be wrong. + _token(fs, "t1") + fs.snap_mode["t1"] = "invalid" + out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER)) + assert out["channels"][0]["state"] == "invalid" + assert out["channels"][0]["bots"][0]["status"] == "invalid" + # a working bot elsewhere in the list still wins + _token(fs, "t2") + discord_rest.invalidate() + out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER)) + assert out["channels"][0]["state"] == "ready" + # no bot at all is still no_bot + assert checks._channel_state([], True) == "no_bot" + + def test_voice_check_is_org_scoped(fs): _token(fs, "t1") for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)): diff --git a/drb-frontend/app/discord/connected/layout.tsx b/drb-frontend/app/discord/connected/layout.tsx new file mode 100644 index 0000000..9b0e0d1 --- /dev/null +++ b/drb-frontend/app/discord/connected/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; + +// The URL Discord sends the browser back to carries a single-use OAuth code. +// The page strips it from the address bar on load; no-referrer (also set as a +// response header in middleware.ts) covers anything that loads before that. +export const metadata: Metadata = { + title: "Connect a Discord server", + referrer: "no-referrer", + robots: { index: false, follow: false }, +}; + +export default function DiscordConnectedLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/drb-frontend/app/discord/connected/page.tsx b/drb-frontend/app/discord/connected/page.tsx new file mode 100644 index 0000000..38d9ffe --- /dev/null +++ b/drb-frontend/app/discord/connected/page.tsx @@ -0,0 +1,158 @@ +"use client"; + +// Discord's OAuth redirect target for "Connect a Discord server" +// (server-26#225 S7). Registered as each radio bot app's redirect URI: +// https://drb.cusano.net/discord/connected +// +// middleware.ts lets this path through without the drb_session cookie, for +// two reasons: a signed-out visitor would otherwise be redirected to /login +// with the query (code + state) dropped; and drb_session is SameSite=Strict, +// so even a signed-in user's cookie is not sent on the cross-site redirect +// back from discord.com. This page does its own auth check instead. + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/components/AuthProvider"; +import { Card } from "@/components/ui/Card"; +import { LinkButton } from "@/components/ui/Button"; +import { parseC2Error } from "@/lib/discord"; +import { + OAUTH_CALLBACK_PATH, + type OAuthCallback, + type OAuthCompleteResult, + captureCallback, + clearCallback, + completeDiscordOAuth, +} from "@/lib/discordOAuth"; + +type View = + | { kind: "working" } + | { kind: "none" } + | { kind: "denied" } + | { kind: "error"; message: string } + | { kind: "done"; result: OAuthCompleteResult }; + +const SETTINGS = "/settings/organization"; + +function nextStepLabel(path: string): string { + if (path.startsWith("/nodes/")) return "Add a radio channel on this node"; + if (path.startsWith("/settings/discord")) return "Back to Discord settings"; + if (path.startsWith(SETTINGS)) return "Back to organization settings"; + return "Continue"; +} + +export default function DiscordConnectedPage() { + const { user, loading } = useAuth(); + const router = useRouter(); + const [view, setView] = useState({ kind: "working" }); + const cb = useRef(undefined); + const sent = useRef(false); + + useEffect(() => { + // First run: stash code/state for this tab and strip them from the + // address bar. Later runs (auth settling) read the stashed copy. + if (cb.current === undefined) cb.current = captureCallback(); + const c = cb.current; + if (!c) { + setView({ kind: "none" }); + return; + } + if (c.error) { + clearCallback(); + // Only a short code from the URL is ever echoed, never free text a crafted link could carry. + const code = /^[a-z_]{1,40}$/.test(c.error) ? ` (${c.error})` : ""; + setView(c.error === "access_denied" + ? { kind: "denied" } + : { kind: "error", message: `Discord didn't finish adding the bot${code}. Nothing was changed.` }); + return; + } + if (!c.code || !c.state) { + clearCallback(); + setView({ kind: "error", message: "Discord's reply was incomplete. Nothing was changed — start again from Connect a Discord server." }); + return; + } + if (loading) return; + if (!user) { + // Keep the stash; /login sends the user back here once signed in. + router.replace(`/login?next=${encodeURIComponent(OAUTH_CALLBACK_PATH)}`); + return; + } + if (sent.current) return; // the code is single use — never send it twice (StrictMode, re-renders) + sent.current = true; + clearCallback(); + completeDiscordOAuth(c.code, c.state) + .then((result) => setView({ kind: "done", result })) + .catch((err) => setView({ kind: "error", message: parseC2Error(err).message })); + }, [loading, user, router]); + + return ( +
+ +

Connect a Discord server

+ +
+
+ ); +} + +function Body({ view }: { view: View }) { + switch (view.kind) { + case "working": + return

Linking the server and checking the radio bot's permissions…

; + case "none": + return ( + <> +

There is nothing to finish here. Start from Connect a Discord server in your organization settings.

+ Organization settings + + ); + case "denied": + return ( + <> +

Nothing changed. The bot was not added, and no server was linked.

+ Back to organization settings + + ); + case "error": + return ( + <> +

{view.message}

+

If the bot did join the server, it stays there but cannot carry radio until the server is linked. Try Connect again, or use a link code.

+ Back to organization settings + + ); + case "done": + return ; + } +} + +function Done({ r }: { r: OAuthCompleteResult }) { + const name = r.guild_name || "the server"; + const next = r.return_to ?? SETTINGS; + const checked = r.channels_ready !== null || r.channels_blocked !== null; + const blocked = r.channels_blocked ?? 0; + return ( + <> +

+ Linked {name}{r.bot_name ? <> and added {r.bot_name} : null}. +

+ {checked ? ( +

+ {r.channels_ready ?? 0} voice channel{r.channels_ready === 1 ? "" : "s"} ready + {blocked > 0 && <>, {blocked} need{blocked === 1 ? "s" : ""} a permission fix}. +

+ ) : ( +

The bot's channel permissions couldn't be checked yet. The node's Discord picker shows them.

+ )} + {blocked > 0 && ( +

The node's Discord picker names the missing permission on each channel and how to allow it.

+ )} + {r.stopped > 0 && ( +

+ {r.stopped} radio channel{r.stopped === 1 ? " was" : "s were"} stopped: this server used to act for the default organization. +

+ )} + {nextStepLabel(next)} + + ); +} diff --git a/drb-frontend/app/login/page.tsx b/drb-frontend/app/login/page.tsx index 568605f..5d46326 100644 --- a/drb-frontend/app/login/page.tsx +++ b/drb-frontend/app/login/page.tsx @@ -9,6 +9,18 @@ import { useRouter } from "next/navigation"; import { useAuth } from "@/components/AuthProvider"; import { describeAuthError } from "@/lib/authErrors"; +// Pages that may send a signed-out user here and expect to be resumed after +// sign-in, via /login?next=. An allowlist, not "any relative path", so +// this can never become an open redirect. /discord/connected: the OAuth +// callback keeps its code/state in sessionStorage across this detour. +const RESUMABLE = new Set(["/discord/connected"]); + +function resumePath(): string | null { + if (typeof window === "undefined") return null; + const next = new URLSearchParams(window.location.search).get("next"); + return next && RESUMABLE.has(next) ? next : null; +} + export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -31,7 +43,7 @@ export default function LoginPage() { useEffect(() => { if (authLoading) return; if (!user) return; - router.replace(orgId ? "/" : "/onboarding"); + router.replace(orgId ? resumePath() ?? "/" : "/onboarding"); }, [authLoading, user, orgId, router]); async function handleSubmit(e: React.FormEvent) { diff --git a/drb-frontend/components/DiscordServersCard.tsx b/drb-frontend/components/DiscordServersCard.tsx index 56606a8..47f435c 100644 --- a/drb-frontend/components/DiscordServersCard.tsx +++ b/drb-frontend/components/DiscordServersCard.tsx @@ -4,14 +4,66 @@ // Matches design-canvas/discord-system-mockup.html's Servers panel, minus the // plan / incident-forum / roles / limits columns: none of those exist // server-side yet, and a column with nothing behind it is a dead surface. +// +// Two ways to link (server-26#225 S7): +// 1. Connect a Discord server (primary): pick a radio bot, add it in +// Discord, and the same click links the server (per-bot OAuth, S6). +// No main bot needed. Discord sends the browser back to +// /discord/connected, which finishes the link. +// 2. Link code (secondary, collapsed): /drb link, run by someone with +// Manage Server. Needs the main DRB bot in that server. import { useCallback, useEffect, useRef, useState } from "react"; import { c2api } from "@/lib/c2api"; import { Card, CardHeader } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; +import { parseC2Error, type OrgPreferredBots } from "@/lib/discordPicker"; +import { type OAuthStartRequest, OAuthNotConfigured, startDiscordOAuth } from "@/lib/discordOAuth"; type Guild = { guild_id: string; guild_name: string | null; linked_at: string | null; linked_via: string }; + +const SELECT = + "bg-page border border-line rounded-lg px-2.5 py-1.5 text-ink text-sm focus:outline-none focus:border-accent disabled:opacity-50 max-w-full"; + +// Option values for the bot picker: "" = let C2 choose, "tok:" = one of +// the org's own bots, "sys:" = whichever bot is preferred for that system. +function startRequest(choice: string): OAuthStartRequest { + const req: OAuthStartRequest = { return_to: "/settings/organization" }; + if (choice.startsWith("tok:")) req.token_id = choice.slice(4); + // c2-core resolves the system's preferred bot itself, including a pool bot + // a platform admin pinned (whose token_id the org never sees). + else if (choice.startsWith("sys:")) req.system_id = choice.slice(4); + return req; +} + +function BotChoices({ data }: { data: OrgPreferredBots | null }) { + const systems = (data?.systems ?? []).filter((s) => s.preferred_token_id || s.preferred_is_platform_bot); + const bots = data?.bots ?? []; + return ( + <> + + {systems.length > 0 && ( + + {systems.map((s) => ( + + ))} + + )} + {bots.length > 0 && ( + + {bots.map((b) => ( + + ))} + + )} + + ); +} type Code = { code: string; expires_at: string; command: string }; const POLL_MS = 5000; @@ -41,6 +93,14 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA const [copied, setCopied] = useState(false); const [now, setNow] = useState(() => Date.now()); const known = useRef | null>(null); + const [choices, setChoices] = useState(null); + const [choice, setChoice] = useState(""); + const [connecting, setConnecting] = useState(false); + // Set after /discord/oauth/start answers 404: no bot that fits has Connect + // turned on yet (a platform admin sets its client secret). The link code is + // then the only way, so it opens. + const [oauthOff, setOauthOff] = useState(false); + const [showCode, setShowCode] = useState(false); const load = useCallback(async () => { try { @@ -58,8 +118,35 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA useEffect(() => { if (!canEdit) return; load().then((g) => { if (g) known.current = new Set(g.map((x) => x.guild_id)); }); + // Bots to offer. A 403 (platform admin with no org) just leaves "Automatic". + c2api.getOrgPreferredBots().then(setChoices).catch(() => setChoices(null)); }, [canEdit, load]); + async function connect() { + setConnecting(true); + setActionError(null); + setJustLinked(null); + try { + const { url } = await startDiscordOAuth(startRequest(choice)); + window.location.assign(url); + // Leave the button in its busy state: the page is navigating to Discord. + } catch (err) { + if (err instanceof OAuthNotConfigured) { + setShowCode(true); + if (choice) { + // Only this bot is off: keep Connect and the picker so Automatic can be tried. + setActionError("That bot can't connect servers yet. Try Automatic, or link with a code below."); + } else { + setOauthOff(true); + setActionError("Connecting with Discord isn't set up yet. Link with a code below instead."); + } + } else { + setActionError(parseC2Error(err).message); + } + setConnecting(false); + } + } + const msLeft = code ? new Date(code.expires_at).getTime() - now : 0; const live = !!code && msLeft > 0; @@ -126,14 +213,47 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
A link code is run as /drb link by someone with Manage Server in that Discord server.} + subtitle={<>Add a radio bot to your Discord server. Adding it links the server to this organization.} action={ - + !oauthOff ? ( + + ) : undefined } /> + {canEdit && !oauthOff && ( +
+ + + Discord asks you to pick the server and confirm View Channel, Connect and Speak. +
+ )} + {canEdit && ( +
+ + {showCode && ( +
+ + + Someone with Manage Server runs it as /drb link in that Discord server. + +
+ )} +
+ )}
{(code || justLinked || actionError) && ( @@ -189,7 +309,10 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
{g.guild_name || "Unnamed server"}
guild {shortId(g.guild_id)}
- {fmtDate(g.linked_at)} + + {fmtDate(g.linked_at)} + {g.linked_via === "oauth" &&
by adding a radio bot
} + Linked {canEdit && ( diff --git a/drb-frontend/components/NodeDiscordRadio.tsx b/drb-frontend/components/NodeDiscordRadio.tsx index d1dc128..bdb148e 100644 --- a/drb-frontend/components/NodeDiscordRadio.tsx +++ b/drb-frontend/components/NodeDiscordRadio.tsx @@ -116,8 +116,8 @@ function DiscordPicker({ const channelOnHere = channel?.stream?.node_id === nodeId; const check = guildId ? checks[guildId] : undefined; const checkOn = check?.data?.enabled !== false; - const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => { - const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined; + const verdictOf = (c: DiscordGuildOption["voice_channels"][number], data: VoiceCheck | null | undefined = check?.data) => { + const row = data && data.enabled !== false ? data.channels[c.channel_id] : undefined; const v = channelVerdict(row); // This system already plays here: c2-core counts its bot as busy (it holds // the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW @@ -128,19 +128,26 @@ function DiscordPicker({ }; const chosenVerdict = channel ? verdictOf(channel) : null; const definiteDeny = blocksStart(chosenVerdict); + // "Not a voice channel" can't change, so Start stays off. "Needs fix" can: + // Start re-checks with Discord first (server-26#256), so fixing it in + // Discord and pressing Start again just works — no hunting for Re-check. + const startOff = chosenVerdict?.kind === "not_voice"; + // Not for "invalid": that bot is already in the server; inviting won't help (#257). const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy"; const runCheck = useCallback( - async (gid: string, refresh: boolean) => { + async (gid: string, refresh: boolean): Promise => { setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } })); try { const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh)); setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } })); + return data; } catch (err) { const e = parseC2Error(err); // 404 = route not deployed yet or server not visible: no badges (fail-open). const msg = e.status === 404 ? null : e.message; setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } })); + return null; } }, [nodeId], @@ -166,6 +173,12 @@ function DiscordPicker({ setSending(true); setError(null); try { + if (definiteDeny) { + // Fresh answer from Discord; still refused = stay here, the row shows why. + // A failed check fails open, like everywhere else in the picker. + const fresh = await runCheck(guild.guild_id, true); + if (fresh && blocksStart(verdictOf(channel, fresh))) return; + } await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode }); onStarted(); onClose(); @@ -382,11 +395,17 @@ function DiscordPicker({ +

) : !data ? (

Loading…

) : data.systems.length === 0 ? (

Your organization has no radio systems yet.

) : bots.length === 0 ? (

- Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. To use - your own bot for a system, ask DRB support to add it to your organization. + Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. Adding + your own bot isn't self-serve yet: DRB support adds it for you, so ask whoever set up your DRB account.

) : (
diff --git a/drb-frontend/components/discord/RadioBotsPanel.tsx b/drb-frontend/components/discord/RadioBotsPanel.tsx index 17d4616..5fb2942 100644 --- a/drb-frontend/components/discord/RadioBotsPanel.tsx +++ b/drb-frontend/components/discord/RadioBotsPanel.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import { c2api } from "@/lib/c2api"; import { + DISCORD_UNVERIFIED_GUILD_LIMIT, + POOL_BOT_GUILD_CAP, type DiscordAdminOverview, type RadioBot, type RadioBotAddResult, @@ -13,12 +15,13 @@ import { shortId, } from "@/lib/discord"; import { Button } from "@/components/ui/Button"; +import { clearBotOAuthSecret, setBotOAuthSecret } from "@/lib/discordOAuth"; import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui"; // Radio bots = the bot_tokens pool (routers/tokens.py). Used by the Discord // admin page's "Radio bots" tab and by /tokens. Every action here is a real // platform-admin route: POST /tokens, POST /tokens/{id}/refresh, -// DELETE /tokens/{id}, POST /tokens/flush. +// DELETE /tokens/{id}, POST /tokens/flush, PUT /tokens/{id}/oauth. function Checks({ t }: { t: RadioBot }) { if (t.valid === false) { @@ -46,6 +49,102 @@ function Checks({ t }: { t: RadioBot }) { ); } +/** "N of 100 servers" — Discord caps an unverified bot at 100; Connect stops + * picking a pool bot at the cap (owner ruling Q3), so a full one needs a sibling. */ +function ServerCount({ t, cap }: { t: RadioBot; cap: number }) { + if (t.guild_ids == null) return null; + const n = t.guild_ids.length; + const limit = t.server_cap ?? cap; + const full = t.org_id == null && n >= limit; + return ( +
= DISCORD_UNVERIFIED_GUILD_LIMIT ? "text-sev-moderate" : "text-ink-muted"}`}> + {n} of {DISCORD_UNVERIFIED_GUILD_LIMIT} servers + {full && ` · full at ${limit}: Connect skips it, add another pool bot`} +
+ ); +} + +/** + * The bot app's OAuth client secret, which turns on "Connect a Discord server" + * for it (server-26#225 S6/S7, owner ruling Q1: per-bot OAuth). Write-only: + * c2-core checks it with Discord and stores it server-side; this form never + * reads it back, and the field is cleared as soon as it is sent. + */ +function ConnectSetting({ t, onChanged }: { t: RadioBot; onChanged: () => Promise | void }) { + const [open, setOpen] = useState(false); + const [secret, setSecret] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const on = !!t.oauth_enabled; + + async function save(e: React.FormEvent) { + e.preventDefault(); + const value = secret.trim(); + setSecret(""); + if (!value) return; + setSaving(true); + setError(null); + try { + await setBotOAuthSecret(t.token_id, value); + setOpen(false); + await onChanged(); + } catch (err) { + setError(parseC2Error(err).message); + } finally { + setSaving(false); + } + } + + async function turnOff() { + if (!confirm(`Turn off Connect for ${t.name}? Its stored client secret is deleted. Plain invite links keep working.`)) return; + setSaving(true); + setError(null); + try { + await clearBotOAuthSecret(t.token_id); + await onChanged(); + } catch (err) { + setError(parseC2Error(err).message); + } finally { + setSaving(false); + } + } + + return ( +
+
+ {on ? "Connect on" : "Connect off"} + + {on && !open && ( + + )} +
+ {open && ( +
+ setSecret(e.target.value)} + type="password" + autoComplete="off" + spellCheck={false} + aria-label={`OAuth client secret for ${t.name}`} + placeholder="Client secret" + className={`${INPUT} font-mono flex-1 min-w-0`} + /> + +

+ From the Discord Developer Portal → this bot's app → OAuth2. Checked with Discord, stored server-side, never shown again. +

+
+ )} + {error &&
{error}
} +
+ ); +} + function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOverview | null }) { if (t.guild_ids == null) return unknown — refresh; const known = new Map((overview?.guilds ?? []).map((g) => [g.guild_id, g])); @@ -54,34 +153,38 @@ function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOvervi const missing = (overview?.guilds ?? []).filter( (g) => !members.includes(g.guild_id) && (t.org_id == null || g.acting_org_id === t.org_id), ); + const cap = overview?.limits.pool_bot_guild_cap ?? POOL_BOT_GUILD_CAP; if (!members.length && !missing.length) return no servers; return ( -
- {members.map((gid) => ( - - {known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`} - - ))} - {missing.map((g) => { - const href = inviteForGuild(t.invite_url, g.guild_id); - const label = `${guildLabel(g)} ✕`; - return href ? ( - - {label} - - ) : ( - - {label} +
+ +
+ {members.map((gid) => ( + + {known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`} - ); - })} + ))} + {missing.map((g) => { + const href = inviteForGuild(t.invite_url, g.guild_id); + const label = `${guildLabel(g)} ✕`; + return href ? ( + + {label} + + ) : ( + + {label} + + ); + })} +
); } @@ -286,6 +389,7 @@ export function RadioBotsPanel({ +
diff --git a/drb-frontend/components/discord/StreamsPanel.tsx b/drb-frontend/components/discord/StreamsPanel.tsx index f39a339..4b5a03a 100644 --- a/drb-frontend/components/discord/StreamsPanel.tsx +++ b/drb-frontend/components/discord/StreamsPanel.tsx @@ -20,6 +20,7 @@ import { type ParsedC2Error, type VoiceCheck, type VoiceCheckBot, + type VoiceCheckChannel, } from "@/lib/discordPicker"; import { Button } from "@/components/ui/Button"; import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui"; @@ -70,17 +71,19 @@ function AddChannelForm({ const cid = channelId.trim(); const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : ""; - const runCheck = useCallback(async (key: string, refresh: boolean) => { + const runCheck = useCallback(async (key: string, refresh: boolean): Promise => { const [gid, ch] = key.split(":"); setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false })); try { const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh)); setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c)); + return data; } catch (err) { const e = parseStartError(err); setCheck((c) => c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c, ); + return null; } }, []); // Check the channel once its ID looks complete (debounced while typing). @@ -98,8 +101,8 @@ function AddChannelForm({ const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null; const shownName = channelName.trim() || checked?.name || null; // Per-bot result, by token id (the admin route sends ids) or else by name. - const botCheck = (b: RadioBot): VoiceCheckBot | undefined => - checked?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name)); + const botCheck = (b: RadioBot, row: VoiceCheckChannel | undefined = checked): VoiceCheckBot | undefined => + row?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name)); const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null; const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org); @@ -124,6 +127,13 @@ function AddChannelForm({ setSaving(true); setError(null); try { + if (checkKey && (verdict?.kind === "fix" || chosenBotDenied)) { + // "Needs fix" can change: ask Discord again before refusing (server-26#256). + // Still refused = stay here, the row shows why; a failed check fails open. + const fresh = await runCheck(checkKey, true); + const row = fresh && fresh.enabled !== false ? fresh.channels[cid] : undefined; + if (row && (blocksStart(channelVerdict(row)) || (chosenBot && botCheck(chosenBot, row)?.ok === false))) return; + } const res = await c2api.startDiscordStream({ guild_id: guildId, channel_id: channelId.trim(), @@ -221,7 +231,7 @@ function AddChannelForm({
{verdict && } {chosenBotDenied && ( -
The bot you picked can't stream into this channel — pick another, or fix it in Discord and Re-check.
+
The bot you picked can't stream into this channel — pick another, or fix it in Discord and press Start again.
)} {checkOn && } - {saving ? "Starting…" : "Start radio channel"} + {saving ? (blocksStart(verdict) || chosenBotDenied ? "Checking…" : "Starting…") : "Start radio channel"}
diff --git a/drb-frontend/components/discord/VoiceCheckBits.tsx b/drb-frontend/components/discord/VoiceCheckBits.tsx index e93b8f6..289b57f 100644 --- a/drb-frontend/components/discord/VoiceCheckBits.tsx +++ b/drb-frontend/components/discord/VoiceCheckBits.tsx @@ -29,6 +29,11 @@ const BADGE: RecordThat is not a voice channel. Radio can only play in a voice channel.; } + if (verdict.kind === "invalid") { + return ( + + Discord rejected the radio bot in this server, so it can't play radio. Inviting it again won't help — + DRB support has to replace it. + + ); + } if (verdict.kind !== "fix") return null; return ( diff --git a/drb-frontend/lib/discord.ts b/drb-frontend/lib/discord.ts index dcc42bf..24b8c7a 100644 --- a/drb-frontend/lib/discord.ts +++ b/drb-frontend/lib/discord.ts @@ -27,6 +27,11 @@ export interface RadioBot { in_use?: boolean; assigned_node_id?: string | null; preferred_for_system_id?: string | null; + /** "Connect a Discord server" is on for this bot: its OAuth client secret is + * set server-side (server-26#225 S6). The secret itself is never sent. */ + oauth_enabled?: boolean | null; + /** GET /tokens (S6): the pool cap for a DRB pool bot, null for a BYO bot. */ + server_cap?: number | null; } export interface RadioBotAddResult { @@ -93,9 +98,16 @@ export interface DiscordAdminOverview { node_max_streams: number; unlinked_guild_org_id: string | null; unlinked_guild_org_name: string | null; + /** Connect stops picking a DRB pool bot at this many servers (owner ruling Q3). */ + pool_bot_guild_cap?: number; }; } +/** Discord's hard limit for an unverified bot. */ +export const DISCORD_UNVERIFIED_GUILD_LIMIT = 100; +/** settings.discord_pool_bot_guild_cap's default — used when the overview doesn't send it. */ +export const POOL_BOT_GUILD_CAP = 90; + /** A desired-on channel that has not reached "connected" for this long turns red. */ export const STUCK_AFTER_MS = 2 * 60 * 1000; diff --git a/drb-frontend/lib/discordOAuth.ts b/drb-frontend/lib/discordOAuth.ts new file mode 100644 index 0000000..1ad5402 --- /dev/null +++ b/drb-frontend/lib/discordOAuth.ts @@ -0,0 +1,191 @@ +// "Connect a Discord server" — per-bot OAuth (server-26#225 S6 backend, S7 UI). +// Kept out of c2api.ts on purpose (plan S7): this is the only browser code that +// handles a Discord authorization code, and it should stay small and readable. +// +// Flow: an org owner picks a radio bot → POST /discord/oauth/start returns +// Discord's authorize URL → the owner adds the bot in Discord → Discord sends +// the browser to /discord/connected?code=…&state=… → that page calls +// POST /discord/oauth/complete. The bot's client secret never reaches the +// browser: the code exchange happens in c2-core. + +import { auth } from "@/lib/firebase"; + +const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000"; + +/** Same error format as c2api's request(), so lib/discord.parseC2Error reads it. */ +async function send(method: "POST" | "PUT" | "DELETE", path: string, body?: object): Promise { + const user = auth.currentUser; + const token = user ? await user.getIdToken() : null; + const res = await fetch(`${BASE}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`); + if (res.status === 204) return undefined as T; + return res.json(); +} + +const post = (path: string, body: object) => send("POST", path, body); + +/** Which bot to invite. Empty = c2-core picks (org's own bot, else a DRB bot with room). */ +export interface OAuthStartRequest { + /** One of the org's own (BYO) bots, by token_id. */ + token_id?: string; + /** The bot marked preferred for this system (the org's own system or a shared one), + * including a DRB pool bot a platform admin pinned, whose token_id the org never + * sees. 404 if that bot can't connect servers; c2-core never swaps in another. */ + system_id?: string; + /** Kept for the plan's node-page entry point: the preferred bot of this node's system. */ + node_id?: string; + /** Where the result card's button goes. Relative path only; c2-core re-checks it. */ + return_to?: string; +} + +export interface OAuthStartResult { + url: string; + bot_name: string | null; +} + +/** Status 404 from /oauth/start: no bot that fits has Connect turned on (no client secret set). */ +export class OAuthNotConfigured extends Error {} + +export async function startDiscordOAuth(req: OAuthStartRequest): Promise { + let raw: { url?: string; authorize_url?: string; bot?: { name?: string | null } | null; bot_name?: string | null }; + try { + raw = await post("/discord/oauth/start", req); + } catch (err) { + if (err instanceof Error && /^C2 API error 404:/.test(err.message)) throw new OAuthNotConfigured(err.message); + throw err; + } + const url = raw.url ?? raw.authorize_url ?? ""; + // Only ever send the browser to Discord from here — never to a URL that + // merely came back in a response body without being Discord's. + if (!isDiscordAuthorizeUrl(url)) throw new Error("C2 returned an unexpected sign-in link. Nothing was changed."); + return { url, bot_name: raw.bot?.name ?? raw.bot_name ?? null }; +} + +export function isDiscordAuthorizeUrl(url: string): boolean { + try { + const u = new URL(url); + return u.protocol === "https:" && (u.hostname === "discord.com" || u.hostname === "discordapp.com") && + u.pathname.replace(/\/+$/, "").endsWith("/oauth2/authorize"); + } catch { + return false; + } +} + +export interface OAuthCompleteResult { + guild_id: string | null; + guild_name: string | null; + /** Voice channels at least one radio bot can join right now. null = the check didn't finish in time. */ + channels_ready: number | null; + /** Voice channels where every bot is missing a permission. */ + channels_blocked: number | null; + return_to: string | null; + bot_name: string | null; + /** Streams stopped because this server used to act for another (fallback) org. */ + stopped: number; +} + +function count(v: unknown): number | null { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (Array.isArray(v)) return v.length; + return null; +} + +export async function completeDiscordOAuth(code: string, state: string): Promise { + const r = await post>("/discord/oauth/complete", { code, state }); + const str = (v: unknown) => (typeof v === "string" && v ? v : null); + return { + guild_id: str(r.guild_id), + guild_name: str(r.guild_name), + channels_ready: count(r.channels_ready), + channels_blocked: count(r.channels_blocked), + return_to: safeReturnTo(str(r.return_to)), + bot_name: str(r.bot_name), + // S6 answers streams_stopped (a count). + stopped: count(r.streams_stopped) ?? count(r.stopped) ?? 0, + }; +} + +/** A same-site path like "/nodes/abc" — never "//host", a scheme, or a backslash trick. */ +export function safeReturnTo(p: string | null | undefined): string | null { + if (!p || p.length > 200) return null; + return /^\/(?![/\\])[A-Za-z0-9/_\-.?=&%]*$/.test(p) ? p : null; +} + +// --- the callback's code/state, held across a sign-in detour -------------- + +/** The page Discord redirects to. Registered as the redirect URI on each bot app. */ +export const OAUTH_CALLBACK_PATH = "/discord/connected"; + +const STASH_KEY = "drb_discord_oauth_cb"; +const STASH_TTL_MS = 10 * 60 * 1000; // c2-core's state lives 10 min (discord_oauth_state_ttl_seconds) + +export interface OAuthCallback { + code: string | null; + state: string | null; + /** Discord's error param, e.g. "access_denied" when the user pressed Cancel. */ + error: string | null; + at: number; +} + +/** Read the callback params off the URL (if any), stash them in this tab, and + * strip them from the address bar so they never end up in history or a Referer. */ +export function captureCallback(): OAuthCallback | null { + if (typeof window === "undefined") return null; + const q = new URLSearchParams(window.location.search); + const code = q.get("code"); + const state = q.get("state"); + const error = q.get("error"); + if (code || state || error) { + const cb: OAuthCallback = { code, state, error, at: Date.now() }; + try { + sessionStorage.setItem(STASH_KEY, JSON.stringify(cb)); + } catch { + // Private mode / storage blocked: we still have it in memory for this load. + } + window.history.replaceState(null, "", OAUTH_CALLBACK_PATH); + return cb; + } + try { + const raw = sessionStorage.getItem(STASH_KEY); + if (!raw) return null; + const cb = JSON.parse(raw) as OAuthCallback; + if (typeof cb?.at !== "number" || Date.now() - cb.at > STASH_TTL_MS) { + sessionStorage.removeItem(STASH_KEY); + return null; + } + return cb; + } catch { + return null; + } +} + +export function clearCallback(): void { + try { + sessionStorage.removeItem(STASH_KEY); + } catch { + // nothing to clear + } +} + +// --- platform admin: a bot's OAuth client secret --------------------------- + +/** + * PUT /tokens/{id}/oauth (platform admin). Write-only: c2-core checks the + * secret with Discord (client-credentials grant) and stores it server-side + * in bot_oauth/{token_id}; it is never returned, logged, or shown again. + */ +export function setBotOAuthSecret(tokenId: string, clientSecret: string): Promise<{ ok?: boolean; oauth_enabled?: boolean }> { + return send("PUT", `/tokens/${encodeURIComponent(tokenId)}/oauth`, { client_secret: clientSecret }); +} + +/** DELETE /tokens/{id}/oauth (platform admin): deletes the stored secret; Connect is off for that bot. */ +export function clearBotOAuthSecret(tokenId: string): Promise { + return send("DELETE", `/tokens/${encodeURIComponent(tokenId)}/oauth`); +} diff --git a/drb-frontend/lib/discordPicker.ts b/drb-frontend/lib/discordPicker.ts index d615731..94df9cb 100644 --- a/drb-frontend/lib/discordPicker.ts +++ b/drb-frontend/lib/discordPicker.ts @@ -164,7 +164,7 @@ export function parseC2Error(err: unknown): ParsedC2Error { export type BotAccess = "ok" | "denied" | "unknown" | "not_member" | "invalid" | "bad_channel" | "unchecked"; /** Per channel, as c2-core sums it up over the bots that are free. */ -export type ChannelState = "ready" | "needs_fix" | "unknown" | "busy" | "no_bot" | "bad_channel" | "unchecked"; +export type ChannelState = "ready" | "needs_fix" | "unknown" | "busy" | "no_bot" | "invalid" | "bad_channel" | "unchecked"; export interface VoiceCheckBot { /** Admin route only. */ @@ -206,7 +206,7 @@ export interface VoiceCheck { } const BOT_ACCESS: readonly string[] = ["ok", "denied", "unknown", "not_member", "invalid", "bad_channel", "unchecked"]; -const CHANNEL_STATES: readonly string[] = ["ready", "needs_fix", "unknown", "busy", "no_bot", "bad_channel", "unchecked"]; +const CHANNEL_STATES: readonly string[] = ["ready", "needs_fix", "unknown", "busy", "no_bot", "invalid", "bad_channel", "unchecked"]; function normBot(b: Record): VoiceCheckBot { const status = (typeof b.status === "string" && BOT_ACCESS.includes(b.status) @@ -267,6 +267,8 @@ export type ChannelVerdict = | { kind: "unknown"; limit: number } /** No radio bot is in this server. */ | { kind: "no_bot"; limit: number } + /** A radio bot IS in this server, but Discord rejected its token (server-26#257). No invite: it is already there. */ + | { kind: "invalid"; limit: number } /** The channel exists but is not a voice channel — Start is disabled. */ | { kind: "not_voice"; limit: number }; @@ -283,6 +285,7 @@ export function channelVerdict(c: VoiceCheckChannel | null | undefined): Channel case "busy": return { kind: "busy", limit }; case "unknown": return { kind: "unknown", limit }; case "no_bot": return { kind: "no_bot", limit }; + case "invalid": return { kind: "invalid", limit }; case "bad_channel": return { kind: "not_voice", limit }; case "unchecked": return null; case "needs_fix": return firstDenied ? { kind: "fix", limit, bot: firstDenied } : { kind: "unknown", limit }; @@ -293,6 +296,7 @@ export function channelVerdict(c: VoiceCheckChannel | null | undefined): Channel if (free.some((b) => b.ok === null)) return { kind: "unknown", limit }; if (denied.length) return { kind: "fix", limit, bot: denied[0] }; if (c.bots.some((b) => b.ok === true)) return { kind: "busy", limit }; + if (c.bots.some((b) => b.status === "invalid")) return { kind: "invalid", limit }; return { kind: "unknown", limit }; } @@ -321,9 +325,9 @@ export function deniedSentence(bot: { name: string; missing: string[]; reason: s /** Where in Discord to fix it — c2-core's own sentence when it sent one. */ export function fixSentence(bot: { name: string; missing: string[]; reason: string | null; fix?: string | null }): string { if (bot.fix) return bot.fix; - if (bot.reason && /timed out/i.test(bot.reason)) return `In Discord: remove ${bot.name}'s timeout, then Re-check.`; + if (bot.reason && /timed out/i.test(bot.reason)) return `In Discord: remove ${bot.name}'s timeout, then try again.`; const perms = bot.missing.length ? bot.missing.join(" and ") : "View Channel, Connect and Speak"; - return `In Discord: Edit Channel → Permissions → add ${bot.name} → allow ${perms}, then Re-check.`; + return `In Discord: Edit Channel → Permissions → add ${bot.name} → allow ${perms}, then try again.`; } /** "40s ago" / "3 min ago" for the Re-check footer. */ @@ -355,7 +359,7 @@ export function botLine(b: VoiceCheckBot, channelName?: string | null): { tone: case "not_member": return { tone: "warn", text: `${b.name}: Discord says it is not in this server — invite it again` }; case "invalid": - return { tone: "bad", text: `${b.name}: its token was rejected by Discord — a platform admin has to replace it` }; + return { tone: "bad", text: `${b.name}: Discord rejected its token — DRB support has to replace it` }; case "bad_channel": return { tone: "bad", text: `${b.name}: ${channelRef(channelName)} is not a voice channel` }; case "unchecked": diff --git a/drb-frontend/middleware.ts b/drb-frontend/middleware.ts index 1ea913b..54a1f07 100644 --- a/drb-frontend/middleware.ts +++ b/drb-frontend/middleware.ts @@ -14,7 +14,15 @@ const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/ // before ChromeSwitcher's client-side redirect ever runs. Both pages do // their own client-side auth check (redirect to /login if genuinely signed // out) instead. -const NO_SESSION_COOKIE_GATE = new Set(["/signup", "/onboarding"]); +// +// /discord/connected is Discord's OAuth redirect target (server-26#225 S7). +// Redirecting it to /login would drop the query — the single-use code and +// state — and the cookie is SameSite=Strict, so it is NOT sent on the +// cross-site redirect back from discord.com even for a signed-in user. The +// page stashes code/state, sends a signed-out user to /login?next=…, and +// resumes when they come back. +const OAUTH_CALLBACK_PATH = "/discord/connected"; +const NO_SESSION_COOKIE_GATE = new Set(["/signup", "/onboarding", OAUTH_CALLBACK_PATH]); // NOTE: this is a UX redirect only, not a security boundary — it just checks // a client-set cookie's presence. Real enforcement is server-side, in @@ -23,6 +31,13 @@ export function middleware(request: NextRequest) { const session = request.cookies.get("drb_session"); const { pathname } = request.nextUrl; + if (pathname === OAUTH_CALLBACK_PATH) { + // The URL carries an OAuth code until the page strips it: never leak it in a Referer. + const res = NextResponse.next(); + res.headers.set("Referrer-Policy", "no-referrer"); + return res; + } + if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) { return NextResponse.next(); }