diff --git a/src/music/spotify/spotify-oauth.test.ts b/src/music/spotify/spotify-oauth.test.ts index 9b6b7ff..e4e59e6 100644 --- a/src/music/spotify/spotify-oauth.test.ts +++ b/src/music/spotify/spotify-oauth.test.ts @@ -409,6 +409,53 @@ describe("SpotifyOAuth PKCE verifier TTL + cap (S4.3)", () => { }); }); +// Whole-branch I2: a UI-entered Client ID (saved in Settings) must reach the +// single live SpotifyOAuth WITHOUT a process restart. configure() re-arms the +// runtime credentials; an empty clientId re-disables OAuth. +describe("SpotifyOAuth.configure (runtime credentials, whole-branch I2)", () => { + it("applies a UI-entered clientId + redirectUri so OAuth arms without a restart", () => { + // Fresh install: boot-time config had no clientId, so OAuth is disabled. + const store = memStore({ + accessToken: "a", + refreshToken: "r", + expiresAt: Date.now() + 60_000, + scope: "s", + }); + const oauth = new SpotifyOAuth({ clientId: "", store }); + expect(oauth.isAuthorized()).toBe(false); + expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i); + + // Operator enters creds in Settings -> POST /settings calls configure(). + const REDIRECT = "http://127.0.0.1:3000/api/spotify/callback"; + oauth.configure("cid", REDIRECT); + expect(oauth.getClientId()).toBe("cid"); + expect(oauth.getRedirectUri()).toBe(REDIRECT); + // Now authorize + isAuthorized work against the newly-supplied app. + expect(oauth.isAuthorized()).toBe(true); + const { url } = oauth.buildAuthorizeUrl(); + const p = new URL(url).searchParams; + expect(p.get("client_id")).toBe("cid"); + expect(p.get("redirect_uri")).toBe(REDIRECT); + }); + + it("trims the clientId and re-disables OAuth when cleared", () => { + const oauth = new SpotifyOAuth({ + clientId: "old", + redirectUri: "http://old", + store: memStore(), + }); + oauth.configure(" cid ", "http://127.0.0.1:3000/api/spotify/callback"); + expect(oauth.getClientId()).toBe("cid"); // trimmed + + // Empty clientId disables OAuth again (gate on buildAuthorizeUrl/isAuthorized). + oauth.configure(""); + expect(oauth.getClientId()).toBe(""); + expect(oauth.getRedirectUri()).toBe(""); + expect(oauth.isAuthorized()).toBe(false); + expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i); + }); +}); + describe("createFileOAuthTokenStore", () => { it("round-trips save/load and clear() removes it", () => { const dir = mkdtempSync(join(tmpdir(), "sp-oauth-")); diff --git a/src/music/spotify/spotify-oauth.ts b/src/music/spotify/spotify-oauth.ts index b574a8d..3ac79ca 100644 --- a/src/music/spotify/spotify-oauth.ts +++ b/src/music/spotify/spotify-oauth.ts @@ -148,6 +148,14 @@ export class SpotifyOAuth { } } + /** Update the operator's app credentials at runtime (from Settings save) so + * a UI-entered Client ID takes effect without a process restart. Empty + * clientId disables OAuth (isAuthorized()/buildAuthorizeUrl() gate on it). */ + configure(clientId?: string, redirectUri?: string): void { + this.clientId = clientId?.trim() || ""; + this.redirectUri = redirectUri || ""; + } + getClientId(): string { return this.clientId; } diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index b11d133..b05a420 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -297,6 +297,84 @@ describe("bot router /settings", () => { }); }); +// Whole-branch I2: saving a Client ID in Settings must re-configure the single +// live SpotifyOAuth so the operator can Connect without a process restart. +describe("bot router /settings applies spotify creds to the live OAuth (I2)", () => { + let botDb: BotDatabase; + let app: express.Express; + let cookie: string; + let config: BotConfig; + let configPath: string; + let tmpDir: string; + let configureCalls: Array<[string | undefined, string | undefined]>; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const alice = await users.createUser("alice", "pw-alice", "admin"); + cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; + + tmpDir = mkdtempSync(join(tmpdir(), "botsettings-oauth-")); + configPath = join(tmpDir, "config.json"); + config = getDefaultConfig(); // webPort defaults to 3000 + + const fakeManager = { getAllBots: () => [] } as unknown as BotManager; + const avatarStore = createAvatarStore(tmpDir); + + // Fake OAuth recording every configure(clientId, redirectUri) call. + configureCalls = []; + const fakeOAuth = { + configure(clientId?: string, redirectUri?: string) { + configureCalls.push([clientId, redirectUri]); + }, + }; + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode)); + app.use( + "/api/bot", + createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore, undefined, fakeOAuth), + ); + }); + + afterEach(() => { + botDb.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("configures the live OAuth once with the derived callback redirectUri when a Client ID is saved", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ spotify: { clientId: "cid", enabled: true } }); + expect(res.status).toBe(200); + expect(configureCalls).toEqual([ + ["cid", `http://127.0.0.1:${config.webPort}/api/spotify/callback`], + ]); + }); + + it("does NOT touch the OAuth when the request has no spotify block", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ autoPauseOnEmpty: false }); + expect(res.status).toBe(200); + expect(configureCalls).toEqual([]); + }); + + it("configures with ('', undefined) when a spotify block clears the Client ID (disables OAuth)", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ spotify: { clientId: "" } }); + expect(res.status).toBe(200); + expect(configureCalls).toEqual([["", undefined]]); + }); +}); + describe("bot router /settings guest-mode gating + persistence", () => { let tmpDir: string; let configPath: string; diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index a66bcbe..9f239e0 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -17,6 +17,9 @@ export function createBotRouter( botDb: BotDatabase, avatarStore: AvatarStore, onGuestPolicyChanged?: (cfg: GuestModeConfig) => void, + // I2: the single process-wide OAuth, so a UI-entered Client ID reaches the + // live instance on save (no restart). Structural type = SpotifyOAuth.configure. + spotifyOAuth?: { configure(clientId?: string, redirectUri?: string): void }, ): Router { const router = Router(); @@ -124,6 +127,16 @@ export function createBotRouter( saveConfig(configPath, config); + // I2: only when the spotify block was present, push the (possibly UI-entered) + // Client ID into the live process-wide OAuth so Connect works without a + // restart. Empty clientId => undefined redirect => configure() disables OAuth. + if (sp && typeof sp === "object") { + const redirectUri = config.spotify.clientId + ? `http://127.0.0.1:${config.webPort}/api/spotify/callback` + : undefined; + spotifyOAuth?.configure(config.spotify.clientId, redirectUri); + } + // Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a // disabled or narrowed scope takes effect immediately (matches requireAuth's // "disabling immediately invalidates in-flight guest sessions" invariant). diff --git a/src/web/server.ts b/src/web/server.ts index 42c9f66..d491158 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -132,6 +132,9 @@ export function createWebServer(options: WebServerOptions): WebServer { options.database, options.avatarStore, (cfg) => onGuestPolicyChanged(cfg), + // I2: so saving a Client ID in Settings re-configures the live OAuth + // (no restart needed for the UI-entered-creds -> Connect flow). + options.spotifyOAuth, ) ); app.use(