Files
server-26/drb-frontend/lib/discordOAuth.ts
T
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#286: request() now throws C2ApiError — message is FastAPI's `detail` or a
plain sentence for the status (401/403/404/429/5xx), with toString() so the
many String(e) call sites read cleanly. status/body stay on the error; the
Discord parsers rebuild the legacy "C2 API error N: body" text via
legacyErrorText(), so their invite/code parsing is unchanged.

#284: Resolve, Regenerate summary, alert-rule toggle/delete/load,
Acknowledge, trip deletes, system delete and Restart OP25 now surface
failures (ErrorBanner where the page has one, alert() where the page
already uses it). Rules tab no longer says "No alert rules configured"
when the load failed. Confirms added for Resolve (no reopen path exists),
rule delete and trip delete. Reopen deliberately not built — manual
resolve doesn't set resolved_at, so reopening is a lifecycle change.

Frontend unverified: no Node on the authoring box (#207).

Closes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:38:13 -04:00

193 lines
7.6 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";
import { C2ApiError } from "@/lib/c2error";
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 C2ApiError(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 C2ApiError && err.status === 404) 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`);
}