Files
server-26/drb-frontend/lib/discordOAuth.ts
T
Logan CusanoandClaude Opus 5.5 32d223e556 S7: OAuth start/options accept system_id; Discord picker QA fixes (server-26#225, #256 #257 #258)
- discord_oauth: POST /discord/oauth/start and GET /options take an optional
  system_id. The system must be the org's own or shared (another org's or
  unknown = 404). Its preferred bot is strict: one that can't connect is a
  404, never a quiet swap to Automatic. No preference = the usual order.
  The picker's "bot for a system" now always sends system_id.
- #257: _channel_state gives a bot whose token Discord rejected its own
  "invalid" state; the picker shows "Radio bot needs replacing" with no
  invite links, instead of "No radio bot".
- #256: Start no longer stays disabled on "Needs fix". It re-checks with
  Discord first (refresh=1) and starts only if the fresh answer allows it.
  "Not a voice channel" still disables Start.
- #258: own tooltip for "Not a voice channel"; "platform admin" -> "DRB
  support"; PreferredBotsCard says plainly that adding your own bot isn't
  self-serve (no support address exists yet, see #47), and a load failure
  gets a plain message + Try again instead of the raw C2 error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:09:28 -04:00

192 lines
7.5 KiB
TypeScript

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