// "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`); }