Merge pull request 'Discord: Connect a Discord server — OAuth UI (#225 S7) — merge after S6' (#259) from feat/225-s7-oauth-ui into main
Build & Deploy / Build & push images (push) Successful in 4m11s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 30s
Build & Deploy / Deploy to VM (push) Successful in 1m34s
Build & Deploy / Report a failed deploy (push) Skipped

This commit was merged in pull request #259.
This commit is contained in:
2026-10-05 03:11:23 -04:00
17 changed files with 846 additions and 65 deletions
+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":