diff --git a/.env.example b/.env.example index 20d3020..2ae1e9e 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,7 @@ GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID= GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET= GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID= GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET= +GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID= +GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET= + +GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET= diff --git a/AGENTS.md b/AGENTS.md index bc6416c..7af0af4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,8 @@ The planned non-shared-by-default layer is: - Treat `docs/implementation-plan.md` as the forward plan. - Treat `docs/app-profiles/repixelizer.md` and `docs/app-profiles/streampixels.md` as thin app bindings, not the whole - shared architecture. + shared architecture. Add new app-profile notes when a new app binding needs + durable context. ## Important Paths @@ -45,6 +46,7 @@ The planned non-shared-by-default layer is: - Implementation plan: `E:\Projects\Heimdall\docs\implementation-plan.md` - Repixelizer profile: `E:\Projects\Heimdall\docs\app-profiles\repixelizer.md` - StreamPixels profile: `E:\Projects\Heimdall\docs\app-profiles\streampixels.md` +- Spotiverse profile: `E:\Projects\Heimdall\docs\app-profiles\spotiverse.md` - Handoff summary: `E:\Projects\Heimdall\notes\fresh-workspace-handoff.md` - State CLI: `E:\Projects\Heimdall\tools\heimdall_state.py` - Pre-compaction helper: `E:\Projects\Heimdall\tools\heimdall_prepare_compaction.py` diff --git a/docs/app-profiles/spotiverse.md b/docs/app-profiles/spotiverse.md new file mode 100644 index 0000000..62ce4db --- /dev/null +++ b/docs/app-profiles/spotiverse.md @@ -0,0 +1,37 @@ +# Spotiverse App Profile + +Spotiverse consumes Heimdall as a managed Spotify credential authority. + +## Boundary + +- Heimdall owns Spotify OAuth, linked identity custody, refresh-token custody, + and managed access-token projection. +- Spotiverse owns Spotify playback polling, queue command execution, command + receipts, and its CultMesh/Eve provider surface. +- Odin, Eve, VoidBot Faces, and renderers consume the surface and command + boundary; they do not receive Spotify tokens. + +## Providers + +- identity providers: `spotify` +- entitlement sources: none +- managed connection providers: `spotify` + +## Capabilities + +- `spotify_player_read`: shared capability after a linked Spotify identity. +- `spotify_queue_add`: shared capability after a linked Spotify identity; + Spotiverse still owns URI validation and command receipts. + +## Local Configuration + +Heimdall needs: + +```text +GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID=... +GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=... +GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET=... +``` + +Spotiverse needs only the Heimdall base URL, matching app secret, and local +callback/return URLs. diff --git a/docs/architecture.md b/docs/architecture.md index 4db61e4..0e5b10f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -475,6 +475,8 @@ Suggested generic env surface: - `GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET=...` - `GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID=...` - `GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET=...` +- `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID=...` +- `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=...` - `GC_ACCESS_ENTITLEMENT_CACHE_TTL_SECONDS=900` - `GC_ACCESS_PROVIDER_FAILURE_GRACE_SECONDS=3600` diff --git a/docs/service-contract.md b/docs/service-contract.md index 700cb65..afa3bc1 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -118,6 +118,7 @@ Supported providers in the skeleton: - `github` - `twitch` - `youtube` +- `spotify` Request body: @@ -445,8 +446,8 @@ Request body: ```json { - "accountId": "acct_streampixels_001", - "provider": "youtube" + "accountId": "acct_spotiverse_001", + "provider": "spotify" } ``` @@ -454,12 +455,12 @@ Response: ```json { - "accountId": "acct_streampixels_001", - "provider": "youtube", - "providerUserId": "channel-123", + "accountId": "acct_spotiverse_001", + "provider": "spotify", + "providerUserId": "spotify-user-123", "accessToken": "", - "tokenExpiresAt": "2026-04-26T13:00:00.000Z", - "scopes": ["openid", "profile", "email"] + "tokenExpiresAt": "2026-06-02T13:00:00.000Z", + "scopes": ["user-read-playback-state", "user-modify-playback-state"] } ``` diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 1a5ebd8..8c918ae 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -83,6 +83,11 @@ Do not trust this file for the exact live HEAD. Always check git. Heimdall, redeems Heimdall completion codes server-side, writes its own viewer sessions and connector bindings, and resolves current provider access tokens from Heimdall without storing refresh tokens locally +- Spotiverse now delegates Spotify OAuth to Heimdall. Heimdall has a + `spotiverse` app profile and `spotify` provider runtime; Spotiverse stores a + local Heimdall account binding and resolves current Spotify access through + `/v1/apps/spotiverse/managed-credentials/resolve` without storing provider + refresh tokens. - Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels migration slice; Twitch callback handling has been patched so provider token `scope` can be either a space-delimited string or an array @@ -109,6 +114,14 @@ Do not trust this file for the exact live HEAD. Always check git. Do not continue implementation automatically from a rehydrate-only request. +If the user asks to continue on Spotiverse, configure Heimdall with +`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID`, +`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET`, and +`GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET`, configure Spotiverse with the matching +`HEIMDALL_APP_SECRET`, then run a real browser `/auth/login` and verify +managed credential resolution. Spotiverse should not regain local Spotify +OAuth token storage. + If the user asks to continue on StreamPixels, the current next move is real browser verification: diff --git a/src/app-profiles.ts b/src/app-profiles.ts index a547f7e..4251ead 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -141,9 +141,40 @@ const streampixelsProfile: AppProfile = { }, }; +const spotiverseProfile: AppProfile = { + slug: "spotiverse", + displayName: "Spotiverse", + profileVersion: "2026-06-02", + identityProviders: ["spotify"], + entitlementSources: [], + managedConnectionProviders: ["spotify"], + capabilities: [ + { + key: "spotify_player_read", + mode: "shared", + summary: "May read the linked Spotify account playback, queue, and device state.", + sharedRule: identityFacts.authenticated, + }, + { + key: "spotify_queue_add", + mode: "shared", + summary: "May request add-to-queue through the Spotiverse command boundary.", + sharedRule: identityFacts.authenticated, + }, + ], + evaluateSharedCapabilities(context) { + if (!context.facts.has(identityFacts.authenticated)) { + return []; + } + + return ["spotify_player_read", "spotify_queue_add"]; + }, +}; + export const appProfiles: Record = { repixelizer: repixelizerProfile, streampixels: streampixelsProfile, + spotiverse: spotiverseProfile, }; export function getAppProfile(appSlug: AppSlug): AppProfile { diff --git a/src/app.ts b/src/app.ts index 4aac881..40befdc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -467,6 +467,10 @@ function acceptsBackendPolicy(appSlug: AppSlug, handoff: OAuthHandoff): boolean return handoff.callbackUrl === "https://repixelizer.gamecult.org/api/auth/heimdall/callback"; } + if (appSlug === "spotiverse") { + return handoff.callbackUrl === "http://127.0.0.1:8796/auth/heimdall/callback"; + } + return false; } diff --git a/src/contracts.ts b/src/contracts.ts index c707d28..9f34c33 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,8 +1,8 @@ -export const providers = ["discord", "patreon", "github", "twitch", "youtube"] as const; +export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify"] as const; export type Provider = (typeof providers)[number]; -export const appSlugs = ["repixelizer", "streampixels"] as const; +export const appSlugs = ["repixelizer", "streampixels", "spotiverse"] as const; export type AppSlug = (typeof appSlugs)[number]; diff --git a/src/oauth.ts b/src/oauth.ts index d3d72b9..e81ca47 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -487,6 +487,108 @@ async function refreshYouTubeAccessToken(options: { return tokenSet; } +async function exchangeSpotifyAuthorizationCode(options: { + config: HeimdallConfig; + code: string; + redirectUri: string; +}): Promise { + const providerConfig = options.config.providers.spotify; + if (!providerConfig.clientId || !providerConfig.clientSecret) { + throw new Error("Spotify OAuth is not fully configured."); + } + + const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); + const tokenResponse = await fetchJson<{ + access_token: string; + refresh_token?: string; + token_type: string; + expires_in?: number; + scope?: string | string[]; + }>( + "https://accounts.spotify.com/api/token", + { + method: "POST", + headers: { + Authorization: `Basic ${basicAuth}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options.code, + redirect_uri: options.redirectUri, + }), + }, + "Spotify token exchange failed" + ); + + const tokenSet: OAuthTokenSet = { + accessToken: tokenResponse.access_token, + tokenType: tokenResponse.token_type, + scope: normalizeScopes(tokenResponse.scope), + raw: tokenResponse as unknown as Record, + }; + + if (tokenResponse.refresh_token) { + tokenSet.refreshToken = tokenResponse.refresh_token; + } + + if (tokenResponse.expires_in) { + tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); + } + + return tokenSet; +} + +async function refreshSpotifyAccessToken(options: { + config: HeimdallConfig; + refreshToken: string; +}): Promise { + const providerConfig = options.config.providers.spotify; + if (!providerConfig.clientId || !providerConfig.clientSecret) { + throw new Error("Spotify OAuth is not fully configured."); + } + + const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); + const tokenResponse = await fetchJson<{ + access_token: string; + refresh_token?: string; + token_type: string; + expires_in?: number; + scope?: string | string[]; + }>( + "https://accounts.spotify.com/api/token", + { + method: "POST", + headers: { + Authorization: `Basic ${basicAuth}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: options.refreshToken, + }), + }, + "Spotify token refresh failed" + ); + + const tokenSet: OAuthTokenSet = { + accessToken: tokenResponse.access_token, + tokenType: tokenResponse.token_type, + scope: normalizeScopes(tokenResponse.scope), + raw: tokenResponse as unknown as Record, + }; + + if (tokenResponse.refresh_token) { + tokenSet.refreshToken = tokenResponse.refresh_token; + } + + if (tokenResponse.expires_in) { + tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); + } + + return tokenSet; +} + async function resolveDiscordIdentity(options: { accessToken: string }): Promise { const user = await fetchJson<{ id: string; @@ -740,6 +842,47 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise return identity; } +async function resolveSpotifyIdentity(options: { accessToken: string }): Promise { + const profile = await fetchJson<{ + id?: string; + display_name?: string; + email?: string; + images?: Array<{ url?: string }>; + uri?: string; + }>( + "https://api.spotify.com/v1/me", + { + headers: { + Authorization: `Bearer ${options.accessToken}`, + }, + }, + "Spotify identity lookup failed" + ); + + if (!profile.id) { + throw new Error("Spotify identity response did not include a user id."); + } + + const identity: ResolvedIdentity = { + provider: "spotify", + providerUserId: profile.id, + username: profile.id, + displayName: profile.display_name ?? profile.id, + profile: profile as unknown as Record, + }; + + if (profile.email) { + identity.primaryEmail = profile.email; + } + + const avatarUrl = profile.images?.find((image) => image.url)?.url; + if (avatarUrl) { + identity.avatarUrl = avatarUrl; + } + + return identity; +} + async function evaluateDiscordEntitlements(options: { config: HeimdallConfig; callback: OAuthCallbackContext; @@ -991,6 +1134,15 @@ const youtubeRuntime: OAuthProviderRuntime = { }, }; +const spotifyRuntime: OAuthProviderRuntime = { + exchangeAuthorizationCode: exchangeSpotifyAuthorizationCode, + refreshAccessToken: refreshSpotifyAccessToken, + resolveIdentity: resolveSpotifyIdentity, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, +}; + const notImplementedRuntime: OAuthProviderRuntime = { async exchangeAuthorizationCode() { throw new Error("Provider callback exchange is not implemented yet."); @@ -1012,5 +1164,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry { github: notImplementedRuntime, twitch: twitchRuntime, youtube: youtubeRuntime, + spotify: spotifyRuntime, }; } diff --git a/src/providers.ts b/src/providers.ts index f2f74a6..e5c3775 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -69,6 +69,19 @@ export const providerCatalog: Record = { prompt: "consent", }, }, + spotify: { + key: "spotify", + displayName: "Spotify", + authorizationEndpoint: "https://accounts.spotify.com/authorize", + defaultScopes: [ + "user-read-currently-playing", + "user-read-playback-state", + "user-modify-playback-state", + ], + roles: ["identity", "managed_credential"], + clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET", + }, }; export function buildAuthorizationUrl(options: { diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 19e283b..054ed37 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -23,3 +23,4 @@ {"ts": "2026-04-29T16:24:26+00:00", "type": "implementation", "status": "accepted", "note": "Heimdall managed-credential resolution now refreshes Twitch and YouTube access tokens when they are expired or within the refresh window, preserving refresh-token custody in Heimdall while returning only access tokens to app runtimes."} {"ts": "2026-05-20T12:43:02+00:00", "type": "implementation", "status": "accepted", "note": "Heimdall now issues signed app refresh tokens, exposes /v1/apps/{appSlug}/sessions/refresh, refreshes Discord/Patreon provider tokens before Repixelizer entitlement re-evaluation, and reissues short-lived app access claims without restarting provider OAuth."} {"ts": "2026-05-20T17:56:35+00:00", "type": "deployment", "status": "ok", "note": "Deployed Heimdall refresh-token session support to Yggdrasil and verified heimdall.service active, health/JWKS/discovery checks passing, and /v1/apps/repixelizer/sessions/refresh live with validation."} +{"ts": "2026-06-02T15:25:08+00:00", "type": "implementation", "status": "accepted", "note": "Spotiverse now consumes Spotify as a Heimdall-managed credential: Heimdall owns Spotify OAuth/token custody, and Spotiverse resolves current access via /v1/apps/spotiverse/managed-credentials/resolve without storing provider refresh tokens."} diff --git a/state/map.yaml b/state/map.yaml index 1206a7e..021a2546 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -31,6 +31,7 @@ invariants: - App-domain data stays with the host app unless there is a deliberate, source-grounded reason to share it. - Host apps verify signed claims locally and keep resource ownership checks local. - StreamPixels can reuse shared auth authority without centralizing streamer-audience data. + - Spotiverse consumes Spotify as a Heimdall-managed credential; Spotiverse does not own Spotify refresh-token custody. - Persistent state cruft is the same failure class as architecture cruft. data_flow: @@ -91,6 +92,7 @@ landed_baseline: - Repixelizer's hosted web layer now creates Heimdall auth attempts, receives direct backend callbacks, verifies Heimdall Ed25519 access tokens locally, and adopts httpOnly local sessions. - Heimdall now has generic Twitch and YouTube OAuth callback runtimes plus an app-authenticated managed-credential resolve endpoint for app-owned connector runtimes. - StreamPixels now starts viewer and creator Twitch/YouTube OAuth through Heimdall, redeems Heimdall completion codes server-side, writes its own viewer sessions and creator connector bindings, and leaves provider refresh-token custody in Heimdall. + - Spotiverse now has a Heimdall app profile and Spotify provider runtime for managed credential custody. rejected_paths: - name: per_app_auth_copy_paste @@ -101,5 +103,5 @@ rejected_paths: reason: reusing auth mechanics does not justify centralizing app-domain audience or product data current_status: - summary: Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels auth migration slice. Heimdall has Twitch/YouTube provider credentials, the shared StreamPixels app secret, generic Twitch/YouTube identity and managed-credential custody runtimes, and a live fix for Twitch token responses that return scopes as arrays. Live StreamPixels Twitch sign-in through Heimdall has succeeded and redeemed a completion code. StreamPixels delegates Twitch/YouTube OAuth to Heimdall, redeems one-time completion codes through its service, writes its own httpOnly viewer session, stores local profile/creator connector binding state, and asks Heimdall for current provider access tokens when runtime code needs them. Repixelizer access now has a signed Heimdall refresh-token path: apps can exchange the refresh token for a fresh short-lived access claim, while Heimdall refreshes Discord/Patreon provider tokens and re-evaluates app-owned entitlement policies without restarting provider OAuth. The key boundary still holds: Heimdall owns provider OAuth and credential custody; host apps own local sessions, route/resource checks, and app-domain data. - next_action: Complete the remaining real browser verification for StreamPixels: YouTube sign-in/link, creator Twitch/YouTube connector attach from an admin creator session, and local StreamPixels persistence checks. If a provider callback fails, inspect Heimdall audit events and StreamPixels service logs first; do not move provider-specific app state into Heimdall while debugging. + summary: Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels auth migration slice. Heimdall has Twitch/YouTube provider credentials, the shared StreamPixels app secret, generic Twitch/YouTube identity and managed-credential custody runtimes, and a live fix for Twitch token responses that return scopes as arrays. Live StreamPixels Twitch sign-in through Heimdall has succeeded and redeemed a completion code. StreamPixels delegates Twitch/YouTube OAuth to Heimdall, redeems one-time completion codes server-side, writes its own httpOnly viewer session, stores local profile/creator connector binding state, and asks Heimdall for current provider access tokens when runtime code needs them. Spotiverse is now added as a Heimdall app profile with Spotify as a managed credential provider; Spotiverse should resolve access tokens from Heimdall and must not own Spotify refresh-token custody. Repixelizer access has a signed Heimdall refresh-token path. The key boundary still holds: Heimdall owns provider OAuth and credential custody; host apps own local sessions, route/resource checks, runtime behavior, and app-domain data. + next_action: Configure Heimdall with Spotify OAuth credentials and `GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET`, then run a real browser Spotiverse `/auth/login` flow and verify `/v1/apps/spotiverse/managed-credentials/resolve` returns only an access-token projection. After that, return to the remaining StreamPixels browser verification. diff --git a/tests/app.test.ts b/tests/app.test.ts index d489e3a..950881f 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -23,6 +23,7 @@ function createTestConfig(): HeimdallConfig { tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), appSharedSecrets: { streampixels: "streampixels-secret", + spotiverse: "spotiverse-secret", }, storage: { backend: "memory", @@ -34,6 +35,7 @@ function createTestConfig(): HeimdallConfig { github: { clientId: "github-client", clientSecret: "github-secret" }, twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" }, youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" }, + spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" }, }, }; } @@ -682,6 +684,101 @@ describe("Heimdall service", () => { ); }); + it("exposes Spotiverse as a Spotify managed-credential app", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const profileResponse = await app.inject({ + method: "GET", + url: "/v1/apps/spotiverse", + }); + + expect(profileResponse.statusCode).toBe(200); + expect(profileResponse.json()).toEqual( + expect.objectContaining({ + slug: "spotiverse", + identityProviders: ["spotify"], + managedConnectionProviders: ["spotify"], + }) + ); + }); + + it("resolves Spotiverse Spotify credentials without exposing refresh custody", async () => { + const store = new InMemoryStore(); + const app = await buildApp({ + config: createTestConfig(), + store, + oauthRuntimes: { + spotify: { + async exchangeAuthorizationCode() { + return { + accessToken: "spotify-access-token", + refreshToken: "spotify-refresh-token", + tokenType: "Bearer", + scope: ["user-read-playback-state", "user-modify-playback-state"], + expiresAt: "2026-06-02T13:00:00.000Z", + raw: { source: "test" }, + }; + }, + async resolveIdentity() { + return { + provider: "spotify", + providerUserId: "spotify-user-123", + username: "spotify-user-123", + displayName: "Spotiverse Operator", + profile: { id: "spotify-user-123" }, + }; + }, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, + }, + }, + }); + apps.push(app); + + const startResponse = await app.inject({ + method: "POST", + url: "/v1/oauth/spotify/start", + payload: { + appSlug: "spotiverse", + mode: "connect", + returnTo: "http://127.0.0.1:8796/auth/complete", + }, + }); + const stateToken = startResponse.json().stateToken as string; + + const callbackResponse = await app.inject({ + method: "GET", + url: `/v1/oauth/spotify/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + }); + const accountId = callbackResponse.json().account.id as string; + + const credentialResponse = await app.inject({ + method: "POST", + url: "/v1/apps/spotiverse/managed-credentials/resolve", + headers: { + "x-heimdall-app-secret": "spotiverse-secret", + }, + payload: { + accountId, + provider: "spotify", + }, + }); + + expect(credentialResponse.statusCode).toBe(200); + expect(credentialResponse.json()).toEqual( + expect.objectContaining({ + accountId, + provider: "spotify", + providerUserId: "spotify-user-123", + accessToken: "spotify-access-token", + scopes: ["user-read-playback-state", "user-modify-playback-state"], + }) + ); + expect(credentialResponse.body).not.toContain("spotify-refresh-token"); + }); + it("resolves app-managed provider credentials without exposing refresh custody", async () => { const store = new InMemoryStore(); const app = await buildApp({ diff --git a/tests/security.test.ts b/tests/security.test.ts index 4bd1046..df7c8be 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -29,6 +29,7 @@ function createTestConfig(overrides: Partial = {}): HeimdallConf github: {}, twitch: {}, youtube: {}, + spotify: {}, }, ...overrides, };