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
@@ -84,6 +84,8 @@ def _channel_state(rows: list[dict], enabled: bool) -> str:
return "needs_fix" # a start is refused with missing_permissions return "needs_fix" # a start is refused with missing_permissions
if any(r["ok"] for r in rows): if any(r["ok"] for r in rows):
return "busy" 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" 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). eligible bot has an OAuth secret (PUT /tokens/{id}/oauth).
Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the 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 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 ``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. 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 from __future__ import annotations
import asyncio import asyncio
from typing import Optional from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse 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, 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`` | """(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` |
``pool``. Raises HTTPException 404 when nothing fits.""" ``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")} 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.") "right now.")
return tok, "chosen" 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: if node_id:
node = await fstore.doc_get("nodes", node_id) node = await fstore.doc_get("nodes", node_id)
if not node or node.get("org_id") != org_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), async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID),
token_id: Optional[str] = Query(None, pattern=_ID), token_id: Optional[str] = Query(None, pattern=_ID),
org_id: Optional[str] = None, 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.""" """Whether Connect is offered here, and which bot it would invite."""
_require_enabled() _require_enabled()
scope = resolve_org_scope(decoded, org_id) 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)} return {"available": True, "bot": _bot_summary(tok, why)}
class StartBody(BaseModel): class StartBody(BaseModel):
node_id: Optional[str] = Field(None, pattern=_ID) node_id: Optional[str] = Field(None, pattern=_ID)
token_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 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) scope = resolve_org_scope(decoded, org_id)
uid, email = describe_actor(decoded) uid, email = describe_actor(decoded)
start_limiter.check(f"{uid}:discord_oauth_start") 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) 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"], 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) node_id=body.node_id, return_to=return_to)
await write_audit(uid, email, "discord.oauth.start", details={ 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), return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce),
"expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)} "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 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): 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) out = await router.oauth_options(node_id=None, token_id="pool-1", org_id=None, decoded=OWNER_A)
assert out["bot"]["reason"] == "chosen" 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_unlinked_guild_org_id", "")
monkeypatch.setattr(settings, "discord_perm_check_enabled", True) 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_mode = {}
fake.snap_calls = [] fake.snap_calls = []
@@ -99,6 +99,8 @@ def fs(monkeypatch):
return _snap(tid, status=discord_rest.UNKNOWN) return _snap(tid, status=discord_rest.UNKNOWN)
if mode == "not_member": if mode == "not_member":
return _snap(tid, status=discord_rest.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")) return _snap(tid, muted=(mode == "muted"))
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot) 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 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): def test_voice_check_is_org_scoped(fs):
_token(fs, "t1") _token(fs, "t1")
for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)): 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 { useAuth } from "@/components/AuthProvider";
import { describeAuthError } from "@/lib/authErrors"; 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() { export default function LoginPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -31,7 +43,7 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (authLoading) return; if (authLoading) return;
if (!user) return; if (!user) return;
router.replace(orgId ? "/" : "/onboarding"); router.replace(orgId ? resumePath() ?? "/" : "/onboarding");
}, [authLoading, user, orgId, router]); }, [authLoading, user, orgId, router]);
async function handleSubmit(e: React.FormEvent) { 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 // Matches design-canvas/discord-system-mockup.html's Servers panel, minus the
// plan / incident-forum / roles / limits columns: none of those exist // plan / incident-forum / roles / limits columns: none of those exist
// server-side yet, and a column with nothing behind it is a dead surface. // 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 { useCallback, useEffect, useRef, useState } from "react";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { Card, CardHeader } from "@/components/ui/Card"; import { Card, CardHeader } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; 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 }; 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 }; type Code = { code: string; expires_at: string; command: string };
const POLL_MS = 5000; const POLL_MS = 5000;
@@ -41,6 +93,14 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const known = useRef<Set<string> | null>(null); 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 () => { const load = useCallback(async () => {
try { try {
@@ -58,8 +118,35 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
useEffect(() => { useEffect(() => {
if (!canEdit) return; if (!canEdit) return;
load().then((g) => { if (g) known.current = new Set(g.map((x) => x.guild_id)); }); 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]); }, [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 msLeft = code ? new Date(code.expires_at).getTime() - now : 0;
const live = !!code && msLeft > 0; const live = !!code && msLeft > 0;
@@ -126,14 +213,47 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
<div className="p-5 pb-0"> <div className="p-5 pb-0">
<CardHeader <CardHeader
title="Linked Discord servers" 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={ action={
<Button size="sm" onClick={generate} disabled={!canEdit || busy} !oauthOff ? (
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}> <Button size="sm" onClick={connect} disabled={!canEdit || connecting}
{busy ? "Generating…" : live ? "New code" : "Generate link code"} title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
</Button> {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> </div>
{(code || justLinked || actionError) && ( {(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-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> <div className="text-xs text-ink-muted font-mono" title={g.guild_id}>guild {shortId(g.guild_id)}</div>
</td> </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"><Badge tone="success">Linked</Badge></td>
<td className="px-5 py-3 text-right"> <td className="px-5 py-3 text-right">
{canEdit && ( {canEdit && (
+25 -6
View File
@@ -116,8 +116,8 @@ function DiscordPicker({
const channelOnHere = channel?.stream?.node_id === nodeId; const channelOnHere = channel?.stream?.node_id === nodeId;
const check = guildId ? checks[guildId] : undefined; const check = guildId ? checks[guildId] : undefined;
const checkOn = check?.data?.enabled !== false; const checkOn = check?.data?.enabled !== false;
const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => { const verdictOf = (c: DiscordGuildOption["voice_channels"][number], data: VoiceCheck | null | undefined = check?.data) => {
const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined; const row = data && data.enabled !== false ? data.channels[c.channel_id] : undefined;
const v = channelVerdict(row); const v = channelVerdict(row);
// This system already plays here: c2-core counts its bot as busy (it holds // 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 // 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 chosenVerdict = channel ? verdictOf(channel) : null;
const definiteDeny = blocksStart(chosenVerdict); 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 showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
const runCheck = useCallback( 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 } })); setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } }));
try { try {
const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh)); const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } })); setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
return data;
} catch (err) { } catch (err) {
const e = parseC2Error(err); const e = parseC2Error(err);
// 404 = route not deployed yet or server not visible: no badges (fail-open). // 404 = route not deployed yet or server not visible: no badges (fail-open).
const msg = e.status === 404 ? null : e.message; const msg = e.status === 404 ? null : e.message;
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } })); setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
return null;
} }
}, },
[nodeId], [nodeId],
@@ -166,6 +173,12 @@ function DiscordPicker({
setSending(true); setSending(true);
setError(null); setError(null);
try { 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 }); await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode });
onStarted(); onStarted();
onClose(); onClose();
@@ -382,11 +395,17 @@ function DiscordPicker({
<button <button
type="button" type="button"
onClick={start} onClick={start}
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny} disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || startOff}
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined} 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" 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>
<button <button
type="button" type="button"
@@ -33,7 +33,8 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
} catch (err) { } catch (err) {
const e = parseC2Error(err); const e = parseC2Error(err);
if (e.status === 403) setHidden(true); 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." 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 ? ( {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 ? ( ) : !data ? (
<p className="text-sm text-ink-muted">Loading…</p> <p className="text-sm text-ink-muted">Loading…</p>
) : data.systems.length === 0 ? ( ) : data.systems.length === 0 ? (
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p> <p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
) : bots.length === 0 ? ( ) : bots.length === 0 ? (
<p className="text-sm text-ink-muted"> <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 organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. Adding
your own bot for a system, ask DRB support to add it to your organization. your own bot isn&apos;t self-serve yet: DRB support adds it for you, so ask whoever set up your DRB account.
</p> </p>
) : ( ) : (
<div className="divide-y divide-line"> <div className="divide-y divide-line">
@@ -3,6 +3,8 @@
import { useState } from "react"; import { useState } from "react";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { import {
DISCORD_UNVERIFIED_GUILD_LIMIT,
POOL_BOT_GUILD_CAP,
type DiscordAdminOverview, type DiscordAdminOverview,
type RadioBot, type RadioBot,
type RadioBotAddResult, type RadioBotAddResult,
@@ -13,12 +15,13 @@ import {
shortId, shortId,
} from "@/lib/discord"; } from "@/lib/discord";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { clearBotOAuthSecret, setBotOAuthSecret } from "@/lib/discordOAuth";
import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui"; import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
// Radio bots = the bot_tokens pool (routers/tokens.py). Used by the Discord // 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 // admin page's "Radio bots" tab and by /tokens. Every action here is a real
// platform-admin route: POST /tokens, POST /tokens/{id}/refresh, // 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 }) { function Checks({ t }: { t: RadioBot }) {
if (t.valid === false) { 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 }) { function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOverview | null }) {
if (t.guild_ids == null) return <span className={DIM}>unknown — refresh</span>; if (t.guild_ids == null) return <span className={DIM}>unknown — refresh</span>;
const known = new Map((overview?.guilds ?? []).map((g) => [g.guild_id, g])); 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( const missing = (overview?.guilds ?? []).filter(
(g) => !members.includes(g.guild_id) && (t.org_id == null || g.acting_org_id === t.org_id), (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>; if (!members.length && !missing.length) return <span className={DIM}>no servers</span>;
return ( return (
<div className="flex flex-wrap gap-1 max-w-[22rem]"> <div className="max-w-[22rem]">
{members.map((gid) => ( <ServerCount t={t} cap={cap} />
<span key={gid} className="text-[11px] rounded px-1.5 py-px bg-raised border border-line text-ink-2"> <div className="flex flex-wrap gap-1">
{known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`} {members.map((gid) => (
</span> <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)}`}
{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> </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> </div>
); );
} }
@@ -286,6 +389,7 @@ export function RadioBotsPanel({
</td> </td>
<td className={TD}> <td className={TD}>
<Checks t={t} /> <Checks t={t} />
<ConnectSetting t={t} onChanged={onChanged} />
</td> </td>
<td className={`${TD} text-right whitespace-nowrap`}> <td className={`${TD} text-right whitespace-nowrap`}>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
@@ -20,6 +20,7 @@ import {
type ParsedC2Error, type ParsedC2Error,
type VoiceCheck, type VoiceCheck,
type VoiceCheckBot, type VoiceCheckBot,
type VoiceCheckChannel,
} from "@/lib/discordPicker"; } from "@/lib/discordPicker";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui"; import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
@@ -70,17 +71,19 @@ function AddChannelForm({
const cid = channelId.trim(); const cid = channelId.trim();
const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : ""; 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(":"); const [gid, ch] = key.split(":");
setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false })); setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false }));
try { try {
const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh)); 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)); setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c));
return data;
} catch (err) { } catch (err) {
const e = parseStartError(err); const e = parseStartError(err);
setCheck((c) => setCheck((c) =>
c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : 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). // 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 verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null;
const shownName = channelName.trim() || checked?.name || null; const shownName = channelName.trim() || checked?.name || null;
// Per-bot result, by token id (the admin route sends ids) or else by name. // Per-bot result, by token id (the admin route sends ids) or else by name.
const botCheck = (b: RadioBot): VoiceCheckBot | undefined => const botCheck = (b: RadioBot, row: VoiceCheckChannel | undefined = checked): VoiceCheckBot | undefined =>
checked?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name)); 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 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); const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
@@ -124,6 +127,13 @@ function AddChannelForm({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { 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({ const res = await c2api.startDiscordStream({
guild_id: guildId, guild_id: guildId,
channel_id: channelId.trim(), channel_id: channelId.trim(),
@@ -221,7 +231,7 @@ function AddChannelForm({
</div> </div>
{verdict && <FixText verdict={verdict} channelName={shownName} />} {verdict && <FixText verdict={verdict} channelName={shownName} />}
{chosenBotDenied && ( {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} />} {checkOn && <BotDetails channel={checked} channelName={shownName} />}
<CheckFooter <CheckFooter
@@ -252,10 +262,16 @@ function AddChannelForm({
<Button <Button
type="submit" type="submit"
size="sm" size="sm"
disabled={saving || !systemId || blocksStart(verdict) || chosenBotDenied} disabled={saving || !systemId || verdict?.kind === "not_voice"}
title={blocksStart(verdict) ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined} 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>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button> <Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div> </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.", 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." }, 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." }, 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 }) { export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) {
if (verdict.kind === "not_voice") { 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>; 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; if (verdict.kind !== "fix") return null;
return ( return (
<span className="block text-xs text-sev-major mt-1"> <span className="block text-xs text-sev-major mt-1">
+12
View File
@@ -27,6 +27,11 @@ export interface RadioBot {
in_use?: boolean; in_use?: boolean;
assigned_node_id?: string | null; assigned_node_id?: string | null;
preferred_for_system_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 { export interface RadioBotAddResult {
@@ -93,9 +98,16 @@ export interface DiscordAdminOverview {
node_max_streams: number; node_max_streams: number;
unlinked_guild_org_id: string | null; unlinked_guild_org_id: string | null;
unlinked_guild_org_name: 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. */ /** A desired-on channel that has not reached "connected" for this long turns red. */
export const STUCK_AFTER_MS = 2 * 60 * 1000; 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"; 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. */ /** 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 { export interface VoiceCheckBot {
/** Admin route only. */ /** 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 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 { function normBot(b: Record<string, unknown>): VoiceCheckBot {
const status = (typeof b.status === "string" && BOT_ACCESS.includes(b.status) const status = (typeof b.status === "string" && BOT_ACCESS.includes(b.status)
@@ -267,6 +267,8 @@ export type ChannelVerdict =
| { kind: "unknown"; limit: number } | { kind: "unknown"; limit: number }
/** No radio bot is in this server. */ /** No radio bot is in this server. */
| { kind: "no_bot"; limit: number } | { 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. */ /** The channel exists but is not a voice channel — Start is disabled. */
| { kind: "not_voice"; limit: number }; | { kind: "not_voice"; limit: number };
@@ -283,6 +285,7 @@ export function channelVerdict(c: VoiceCheckChannel | null | undefined): Channel
case "busy": return { kind: "busy", limit }; case "busy": return { kind: "busy", limit };
case "unknown": return { kind: "unknown", limit }; case "unknown": return { kind: "unknown", limit };
case "no_bot": return { kind: "no_bot", limit }; case "no_bot": return { kind: "no_bot", limit };
case "invalid": return { kind: "invalid", limit };
case "bad_channel": return { kind: "not_voice", limit }; case "bad_channel": return { kind: "not_voice", limit };
case "unchecked": return null; case "unchecked": return null;
case "needs_fix": return firstDenied ? { kind: "fix", limit, bot: firstDenied } : { kind: "unknown", limit }; 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 (free.some((b) => b.ok === null)) return { kind: "unknown", limit };
if (denied.length) return { kind: "fix", limit, bot: denied[0] }; 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.ok === true)) return { kind: "busy", limit };
if (c.bots.some((b) => b.status === "invalid")) return { kind: "invalid", limit };
return { kind: "unknown", 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. */ /** 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 { export function fixSentence(bot: { name: string; missing: string[]; reason: string | null; fix?: string | null }): string {
if (bot.fix) return bot.fix; 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"; 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. */ /** "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": case "not_member":
return { tone: "warn", text: `${b.name}: Discord says it is not in this server — invite it again` }; return { tone: "warn", text: `${b.name}: Discord says it is not in this server — invite it again` };
case "invalid": 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": case "bad_channel":
return { tone: "bad", text: `${b.name}: ${channelRef(channelName)} is not a voice channel` }; return { tone: "bad", text: `${b.name}: ${channelRef(channelName)} is not a voice channel` };
case "unchecked": 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 // before ChromeSwitcher's client-side redirect ever runs. Both pages do
// their own client-side auth check (redirect to /login if genuinely signed // their own client-side auth check (redirect to /login if genuinely signed
// out) instead. // 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 // 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 // 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 session = request.cookies.get("drb_session");
const { pathname } = request.nextUrl; 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)) { if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) {
return NextResponse.next(); return NextResponse.next();
} }