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
17 changed files with 846 additions and 65 deletions
@@ -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"
+28 -7
View File
@@ -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)}
+52
View File
@@ -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"
+20 -1
View File
@@ -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)):
@@ -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;
}
+158
View File
@@ -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<View>({ kind: "working" });
const cb = useRef<OAuthCallback | null | undefined>(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 (
<div className="max-w-lg mx-auto pt-10">
<Card padding="lg" className="space-y-4">
<h1 className="text-ink text-lg font-semibold">Connect a Discord server</h1>
<Body view={view} />
</Card>
</div>
);
}
function Body({ view }: { view: View }) {
switch (view.kind) {
case "working":
return <p className="text-sm text-ink-muted">Linking the server and checking the radio bot&apos;s permissions…</p>;
case "none":
return (
<>
<p className="text-sm text-ink-2">There is nothing to finish here. Start from <b className="text-ink">Connect a Discord server</b> in your organization settings.</p>
<LinkButton href={SETTINGS} size="sm">Organization settings</LinkButton>
</>
);
case "denied":
return (
<>
<p className="text-sm text-ink-2"><b className="text-ink">Nothing changed.</b> The bot was not added, and no server was linked.</p>
<LinkButton href={SETTINGS} size="sm" variant="secondary">Back to organization settings</LinkButton>
</>
);
case "error":
return (
<>
<p className="text-sm text-sev-major">{view.message}</p>
<p className="text-xs text-ink-muted">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.</p>
<LinkButton href={SETTINGS} size="sm" variant="secondary">Back to organization settings</LinkButton>
</>
);
case "done":
return <Done r={view.result} />;
}
}
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 (
<>
<p className="text-sm text-ink">
Linked <b>{name}</b>{r.bot_name ? <> and added <b>{r.bot_name}</b></> : null}.
</p>
{checked ? (
<p className="text-sm text-ink-2">
{r.channels_ready ?? 0} voice channel{r.channels_ready === 1 ? "" : "s"} ready
{blocked > 0 && <>, <span className="text-sev-moderate">{blocked} need{blocked === 1 ? "s" : ""} a permission fix</span></>}.
</p>
) : (
<p className="text-sm text-ink-muted">The bot&apos;s channel permissions couldn&apos;t be checked yet. The node&apos;s Discord picker shows them.</p>
)}
{blocked > 0 && (
<p className="text-xs text-ink-muted">The node&apos;s Discord picker names the missing permission on each channel and how to allow it.</p>
)}
{r.stopped > 0 && (
<p className="text-xs text-ink-muted">
{r.stopped} radio channel{r.stopped === 1 ? " was" : "s were"} stopped: this server used to act for the default organization.
</p>
)}
<LinkButton href={next} size="sm">{nextStepLabel(next)}</LinkButton>
</>
);
}
+13 -1
View File
@@ -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=<path>. 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) {
+129 -6
View File
@@ -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:<id>" = one of
// the org's own bots, "sys:<id>" = 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 (
<>
<option value="">Automatic: your own bot, else a DRB radio bot</option>
{systems.length > 0 && (
<optgroup label="The bot for a system">
{systems.map((s) => (
<option key={s.system_id} value={`sys:${s.system_id}`}>
{s.name}: {s.preferred_name ?? "DRB radio bot"}
</option>
))}
</optgroup>
)}
{bots.length > 0 && (
<optgroup label="Your organization's bots">
{bots.map((b) => (
<option key={b.token_id} value={`tok:${b.token_id}`} disabled={b.valid === false}>
{b.name}{b.valid === false ? " (token rejected)" : ""}
</option>
))}
</optgroup>
)}
</>
);
}
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<Set<string> | null>(null);
const [choices, setChoices] = useState<OrgPreferredBots | null>(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
<div className="p-5 pb-0">
<CardHeader
title="Linked Discord servers"
subtitle={<>A link code is run as <span className="font-mono">/drb link</span> 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={
<Button size="sm" onClick={generate} disabled={!canEdit || busy}
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
{busy ? "Generating…" : live ? "New code" : "Generate link code"}
</Button>
!oauthOff ? (
<Button size="sm" onClick={connect} disabled={!canEdit || connecting}
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
{connecting ? "Opening Discord…" : "Connect a Discord server"}
</Button>
) : undefined
}
/>
{canEdit && !oauthOff && (
<div className="flex flex-wrap items-center gap-2 mb-4 text-xs text-ink-muted">
<label htmlFor="drb-connect-bot">Radio bot</label>
<select id="drb-connect-bot" value={choice} onChange={(e) => setChoice(e.target.value)} disabled={connecting} className={SELECT}>
<BotChoices data={choices} />
</select>
<span>Discord asks you to pick the server and confirm View Channel, Connect and Speak.</span>
</div>
)}
{canEdit && (
<div className="mb-4">
<button
type="button"
onClick={() => setShowCode((v) => !v)}
aria-expanded={showCode}
className="text-xs text-ink-muted hover:text-ink"
>
{showCode ? "▾" : "▸"} Or link with a code (<span className="font-mono">/drb link</span>, needs the main DRB bot in the server)
</button>
{showCode && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button size="sm" variant={oauthOff ? "primary" : "secondary"} onClick={generate} disabled={busy}>
{busy ? "Generating…" : live ? "New code" : "Generate link code"}
</Button>
<span className="text-xs text-ink-muted">
Someone with Manage Server runs it as <span className="font-mono">/drb link</span> in that Discord server.
</span>
</div>
)}
</div>
)}
</div>
{(code || justLinked || actionError) && (
@@ -189,7 +309,10 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
<div className="text-ink font-medium">{g.guild_name || "Unnamed server"}</div>
<div className="text-xs text-ink-muted font-mono" title={g.guild_id}>guild {shortId(g.guild_id)}</div>
</td>
<td className="px-5 py-3 text-xs text-ink-2 hidden sm:table-cell">{fmtDate(g.linked_at)}</td>
<td className="px-5 py-3 text-xs text-ink-2 hidden sm:table-cell">
{fmtDate(g.linked_at)}
{g.linked_via === "oauth" && <div className="text-ink-muted">by adding a radio bot</div>}
</td>
<td className="px-5 py-3"><Badge tone="success">Linked</Badge></td>
<td className="px-5 py-3 text-right">
{canEdit && (
+25 -6
View File
@@ -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<VoiceCheck | null> => {
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({
<button
type="button"
onClick={start}
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny}
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || startOff}
title={
startOff
? "That is not a voice channel. Pick a voice channel."
: definiteDeny
? "Fixed the permission in Discord? Start checks again first."
: undefined
}
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
>
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}
{sending ? (definiteDeny ? "Checking…" : "Starting…") : channelOnHere ? "Update" : "Start radio"}
</button>
<button
type="button"
@@ -33,7 +33,8 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
} catch (err) {
const e = parseC2Error(err);
if (e.status === 403) setHidden(true);
else setLoadError(e.message);
// A server error's raw body ("C2 API error 500: ...") means nothing to an owner.
else setLoadError(e.status !== null && e.status >= 500 ? "DRB couldn't load them just now." : e.message);
}
}, []);
@@ -67,15 +68,20 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
subtitle="Which of your radio bots plays each system in Discord. DRB tries it first for every new radio channel of that system; if it can't (busy in that server, missing a permission, or not invited), another free bot is used."
/>
{loadError ? (
<p role="alert" className="text-sm text-sev-major">Couldn&apos;t load radio bots: {loadError}</p>
<p role="alert" className="text-sm text-sev-major">
Couldn&apos;t load radio bots: {loadError}{" "}
<button type="button" onClick={() => load()} className="text-accent hover:underline">
Try again
</button>
</p>
) : !data ? (
<p className="text-sm text-ink-muted">Loading…</p>
) : data.systems.length === 0 ? (
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
) : bots.length === 0 ? (
<p className="text-sm text-ink-muted">
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&apos;t self-serve yet: DRB support adds it for you, so ask whoever set up your DRB account.
</p>
) : (
<div className="divide-y divide-line">
@@ -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 (
<div className={`text-xs mb-1 ${full || n >= 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`}
</div>
);
}
/**
* 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> | void }) {
const [open, setOpen] = useState(false);
const [secret, setSecret] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="mt-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Status health={on ? "ok" : "off"}>{on ? "Connect on" : "Connect off"}</Status>
<button type="button" className="text-xs text-accent hover:underline" onClick={() => { setOpen((v) => !v); setError(null); }}>
{open ? "Cancel" : on ? "Replace secret" : "Set client secret"}
</button>
{on && !open && (
<button type="button" disabled={saving} className="text-xs text-sev-major hover:underline disabled:opacity-40" onClick={turnOff}>
Turn off
</button>
)}
</div>
{open && (
<form onSubmit={save} className="mt-1.5 flex flex-wrap items-center gap-1.5 max-w-[18rem]">
<input
value={secret}
onChange={(e) => 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`}
/>
<Button type="submit" size="sm" disabled={saving || !secret.trim()}>{saving ? "Checking…" : "Save"}</Button>
<p className={`${DIM} w-full`}>
From the Discord Developer Portal → this bot&apos;s app → OAuth2. Checked with Discord, stored server-side, never shown again.
</p>
</form>
)}
{error && <div className="text-xs text-sev-major mt-1 max-w-[18rem]">{error}</div>}
</div>
);
}
function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOverview | null }) {
if (t.guild_ids == null) return <span className={DIM}>unknown — refresh</span>;
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 <span className={DIM}>no servers</span>;
return (
<div className="flex flex-wrap gap-1 max-w-[22rem]">
{members.map((gid) => (
<span key={gid} className="text-[11px] rounded px-1.5 py-px bg-raised border border-line text-ink-2">
{known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`}
</span>
))}
{missing.map((g) => {
const href = inviteForGuild(t.invite_url, g.guild_id);
const label = `${guildLabel(g)} ✕`;
return href ? (
<a
key={g.guild_id}
href={href}
target="_blank"
rel="noopener noreferrer"
title={`Not in ${guildLabel(g)} — invite it`}
className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate hover:underline"
>
{label}
</a>
) : (
<span key={g.guild_id} className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate">
{label}
<div className="max-w-[22rem]">
<ServerCount t={t} cap={cap} />
<div className="flex flex-wrap gap-1">
{members.map((gid) => (
<span key={gid} className="text-[11px] rounded px-1.5 py-px bg-raised border border-line text-ink-2">
{known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`}
</span>
);
})}
))}
{missing.map((g) => {
const href = inviteForGuild(t.invite_url, g.guild_id);
const label = `${guildLabel(g)} ✕`;
return href ? (
<a
key={g.guild_id}
href={href}
target="_blank"
rel="noopener noreferrer"
title={`Not in ${guildLabel(g)} — invite it`}
className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate hover:underline"
>
{label}
</a>
) : (
<span key={g.guild_id} className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate">
{label}
</span>
);
})}
</div>
</div>
);
}
@@ -286,6 +389,7 @@ export function RadioBotsPanel({
</td>
<td className={TD}>
<Checks t={t} />
<ConnectSetting t={t} onChanged={onChanged} />
</td>
<td className={`${TD} text-right whitespace-nowrap`}>
<div className="flex flex-col items-end gap-1">
@@ -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<VoiceCheck | null> => {
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({
</div>
{verdict && <FixText verdict={verdict} channelName={shownName} />}
{chosenBotDenied && (
<div className="text-xs text-sev-major">The bot you picked can&apos;t stream into this channel — pick another, or fix it in Discord and Re-check.</div>
<div className="text-xs text-sev-major">The bot you picked can&apos;t stream into this channel — pick another, or fix it in Discord and press Start again.</div>
)}
{checkOn && <BotDetails channel={checked} channelName={shownName} />}
<CheckFooter
@@ -252,10 +262,16 @@ function AddChannelForm({
<Button
type="submit"
size="sm"
disabled={saving || !systemId || blocksStart(verdict) || chosenBotDenied}
title={blocksStart(verdict) ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
disabled={saving || !systemId || verdict?.kind === "not_voice"}
title={
verdict?.kind === "not_voice"
? "That is not a voice channel. Pick a voice channel."
: blocksStart(verdict) || chosenBotDenied
? "Fixed the permission in Discord? Start checks again first."
: undefined
}
>
{saving ? "Starting…" : "Start radio channel"}
{saving ? (blocksStart(verdict) || chosenBotDenied ? "Checking…" : "Starting…") : "Start radio channel"}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
@@ -29,6 +29,11 @@ const BADGE: Record<ChannelVerdict["kind"], { label: string; cls: string; title?
title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.",
},
no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." },
invalid: {
label: "Radio bot needs replacing",
cls: "border-sev-major/50 text-sev-major",
title: "A radio bot is in this server, but Discord rejected its token. Inviting it again won't help — DRB support has to replace it.",
},
unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." },
};
@@ -59,11 +64,19 @@ export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | n
);
}
/** The sentence under a "Needs fix" / "Not a voice channel" channel. */
/** The sentence under a "Needs fix" / "Not a voice channel" / "Radio bot needs replacing" channel. */
export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) {
if (verdict.kind === "not_voice") {
return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>;
}
if (verdict.kind === "invalid") {
return (
<span className="block text-xs text-sev-major mt-1">
Discord rejected the radio bot in this server, so it can&apos;t play radio. Inviting it again won&apos;t help —
DRB support has to replace it.
</span>
);
}
if (verdict.kind !== "fix") return null;
return (
<span className="block text-xs text-sev-major mt-1">
+12
View File
@@ -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;
+191
View File
@@ -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<T>(method: "POST" | "PUT" | "DELETE", path: string, body?: object): Promise<T> {
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 = <T,>(path: string, body: object) => send<T>("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<OAuthStartResult> {
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<OAuthCompleteResult> {
const r = await post<Record<string, unknown>>("/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<unknown> {
return send("DELETE", `/tokens/${encodeURIComponent(tokenId)}/oauth`);
}
+9 -5
View File
@@ -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<string, unknown>): 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":
+16 -1
View File
@@ -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();
}