Add Spotiverse Spotify credential custody

This commit is contained in:
Meta Vens 2026-06-02 16:25:38 +01:00
commit e2a2f20693
15 changed files with 373 additions and 12 deletions

View file

@ -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=

View file

@ -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`

View file

@ -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.

View file

@ -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`

View file

@ -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": "<provider-access-token>",
"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"]
}
```

View file

@ -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:

View file

@ -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<AppSlug, AppProfile> = {
repixelizer: repixelizerProfile,
streampixels: streampixelsProfile,
spotiverse: spotiverseProfile,
};
export function getAppProfile(appSlug: AppSlug): AppProfile {

View file

@ -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;
}

View file

@ -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];

View file

@ -487,6 +487,108 @@ async function refreshYouTubeAccessToken(options: {
return tokenSet;
}
async function exchangeSpotifyAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet> {
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<string, unknown>,
};
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<OAuthTokenSet> {
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<string, unknown>,
};
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<ResolvedIdentity> {
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<ResolvedIdentity> {
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<string, unknown>,
};
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,
};
}

View file

@ -69,6 +69,19 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
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: {

View file

@ -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."}

View file

@ -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.

View file

@ -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({

View file

@ -29,6 +29,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
github: {},
twitch: {},
youtube: {},
spotify: {},
},
...overrides,
};