feat(spotify): web OAuth endpoints + thread single shared SpotifyOAuth to web + controllers (Stage 3, Task 6)

Add the /api/spotify {login,callback,status} router behind the SpotifyOAuthLike
seam (DI-tested with supertest, no network). Build ONE process-wide SpotifyOAuth
in index.ts (clientId/redirectUri from config; store via the already-exported
createFileOAuthTokenStore) and thread that same instance into BOTH createWebServer
AND BotManager -> BotInstance -> SpotifyController, so a web login authorizes
playback (C3.1). Reuses the existing file token store (no token-store.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-07-03 00:09:12 +08:00
1 parent 322335dfc5
commit 8998b9623f
8 files changed
+363 -2

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
import { Router } from "express";
import type { Logger } from "pino";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
/** Minimal structural seam over SpotifyOAuth so this router needs no real
* network/crypto in tests. The concrete SpotifyOAuth satisfies it verbatim. */
export interface SpotifyOAuthLike {
buildAuthorizeUrl(): { url: string; state: string };
handleCallback(code: string, state: string): Promise<boolean>;
isAuthorized(): boolean;
}
export interface SpotifyRouterOptions {
oauth: SpotifyOAuthLike;
logger: Logger;
/** Process-wide backend info for /status (single Premium account, Stage 3). */
getBackendInfo: () => { backend: string; deviceName: string };
/** Web UI page to bounce the browser back to after the OAuth callback. */
webUiRedirect?: string;
}
export function createSpotifyRouter(opts: SpotifyRouterOptions): Router {
const { oauth, logger } = opts;
const redirectBase = opts.webUiRedirect ?? "/";
const sep = redirectBase.includes("?") ? "&" : "?";
const router = Router();
// Start the Authorization Code + PKCE flow: hand the WebUI the accounts.spotify.com
// authorize URL (verifier is stashed by state inside SpotifyOAuth). Gated like the
// other platform logins in auth.ts.
router.get("/login", requirePermission("platform.auth"), (_req, res) => {
try {
const { url } = oauth.buildAuthorizeUrl();
res.json({ url });
} catch (err) {
logger.error({ err }, "Spotify authorize URL build failed");
res.status(500).json({ error: (err as Error).message });
}
});
// OAuth redirect target (own-app clientId => redirect_uri points here). This is a
// top-level browser navigation carrying the SameSite=Lax session cookie, so the
// global requireAuth passes; state is the CSRF guard for the flow itself. Always
// redirect (never JSON) so the user lands back in the UI.
router.get("/callback", async (req, res) => {
const code = typeof req.query.code === "string" ? req.query.code : "";
const state = typeof req.query.state === "string" ? req.query.state : "";
if (!code || !state) {
res.redirect(`${redirectBase}${sep}spotify=error`);
return;
}
try {
const ok = await oauth.handleCallback(code, state);
res.redirect(`${redirectBase}${sep}spotify=${ok ? "success" : "error"}`);
} catch (err) {
logger.error({ err }, "Spotify OAuth callback failed");
res.redirect(`${redirectBase}${sep}spotify=error`);
}
});
// Whether the (single, process-wide) account is authorized, plus which backend
// + device name are configured — used by the WebUI to show login-needed state.
router.get("/status", requireNotGuest, (_req, res) => {
const info = opts.getBackendInfo();
res.json({
authorized: oauth.isAuthorized(),
backend: info.backend,
deviceName: info.deviceName,
});
});
return router;
}