diff --git a/docs/app-profiles/spotiverse.md b/docs/app-profiles/spotiverse.md index 9a9276a..ed52874 100644 --- a/docs/app-profiles/spotiverse.md +++ b/docs/app-profiles/spotiverse.md @@ -1,5 +1,56 @@ # Spotiverse App Profile +> **RETIRED from the live registry 2026-09-06. This document is the restore +> spec — it is deliberately kept current, not archived.** +> +> Spotiverse was a small daemon exposing the Spotify API to agents. It was lost +> with the `E:` drive and has no repository in the swarm. The capability is +> wanted back — letting a Persona manage the queue during a stream is the +> motivating case — so this profile was removed in a way that is cheap to undo. +> +> **What was removed:** the `spotiverseProfile` object and its registry entry in +> `src/app-profiles.ts`, the `spotiverse` slug from `appSlugs` in +> `src/contracts.ts`, the hardcoded callback arm in `src/app.ts`, and three +> spotiverse-specific tests in `tests/app.test.ts`. +> +> **What was deliberately kept:** the whole `spotify` OAuth provider — the +> catalog entry in `src/providers.ts`, `spotifyRuntime` and its token/profile +> exchange in `src/oauth.ts`, and `"spotify"` in the `providers` union in +> `src/contracts.ts`. The provider is intact and unreferenced. Runtime health +> still reports `providers=2/6`, which counts it. +> +> **Why it was removed anyway:** `/v1/oauth/spotify/start` is gated by +> `supportsProvider(profile, provider)`, so a provider with no app behind it is +> unreachable. While the profile existed, that path was publicly reachable and +> would have run a real OAuth handshake against a real client secret, taking +> custody of a user's Spotify refresh token on behalf of a consumer that could +> not use it. Removing the profile closes the path; keeping the provider keeps +> the capability. + +## Restoring + +Everything below this line is still accurate. To bring Spotiverse back: + +1. Re-add `spotiverseProfile` and its registry entry to `src/app-profiles.ts`, + and `"spotiverse"` to `appSlugs` in `src/contracts.ts`. Both are recoverable + verbatim from commit `5a15f71`, the last commit before removal. +2. Re-add the backend callback arm in `src/app.ts`. The daemon ran on + **`http://127.0.0.1:8796/auth/heimdall/callback`**; that exact URL was the + allowlisted value. +3. Configure `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and + `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET` in the host service environment. + They were removed from `/srv/heimdall/env/service.env` on retirement, so a + fresh Spotify application registration may be needed. +4. Restore the three tests from `5a15f71` if the capabilities they covered + (managed-credential resolve for spotify, portal backend callbacks) are not + otherwise exercised. At retirement both were covered independently by the + `streampixels` managed-credential tests and the generic + `untrusted_backend_callback` test, so no coverage was lost by removing them. + +No provider or protocol work is required. The retirement was a registry change. + +--- + Spotiverse consumes Heimdall as a managed Spotify credential authority. ## Boundary diff --git a/src/app-profiles.ts b/src/app-profiles.ts index e4b1554..d0e8d71 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -141,36 +141,6 @@ 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"]; - }, -}; - const bifrostProfile: AppProfile = { slug: "bifrost", displayName: "Bifrost", @@ -230,7 +200,6 @@ const ghostlightProfile: AppProfile = { export const appProfiles: Record = { repixelizer: repixelizerProfile, streampixels: streampixelsProfile, - spotiverse: spotiverseProfile, bifrost: bifrostProfile, ghostlight: ghostlightProfile, }; diff --git a/src/app.ts b/src/app.ts index da73fc5..01b1459 100644 --- a/src/app.ts +++ b/src/app.ts @@ -500,10 +500,6 @@ function acceptsBackendCallback(config: HeimdallConfig, appSlug: AppSlug, handof 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 0866eaf..adf4f9e 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -2,7 +2,7 @@ export const providers = ["discord", "patreon", "github", "twitch", "youtube", " export type Provider = (typeof providers)[number]; -export const appSlugs = ["repixelizer", "streampixels", "spotiverse", "bifrost", "ghostlight"] as const; +export const appSlugs = ["repixelizer", "streampixels", "bifrost", "ghostlight"] as const; export type AppSlug = (typeof appSlugs)[number]; diff --git a/tests/app.test.ts b/tests/app.test.ts index 04743e6..87e3ee3 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -30,11 +30,9 @@ function createTestConfig(): HeimdallConfig { tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), appSharedSecrets: { streampixels: "streampixels-secret", - spotiverse: "spotiverse-secret", }, appBackendCallbacks: { bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"], - spotiverse: ["https://spotiverse-portal.gamecult.org/auth/heimdall/callback"], }, storage: { backend: "memory", @@ -901,144 +899,21 @@ 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("accepts configured Spotiverse portal backend callbacks", async () => { - const app = await buildApp({ config: createTestConfig() }); - apps.push(app); - - const response = await app.inject({ - method: "POST", - url: "/v1/oauth/spotify/start", - payload: { - appSlug: "spotiverse", - mode: "connect", - returnTo: "https://spotiverse-portal.gamecult.org/auth/complete", - handoff: { - kind: "backend_callback", - attemptId: "spotiverse-portal-attempt", - callbackUrl: "https://spotiverse-portal.gamecult.org/auth/heimdall/callback", - }, - }, - }); - - expect(response.statusCode).toBe(201); - expect(response.json()).toEqual( - expect.objectContaining({ - provider: "spotify", - appSlug: "spotiverse", - }) - ); - }); - it("rejects unconfigured backend callback URLs", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); const response = await app.inject({ method: "POST", - url: "/v1/oauth/spotify/start", + url: "/v1/oauth/discord/start", payload: { - appSlug: "spotiverse", + appSlug: "bifrost", mode: "connect", - returnTo: "https://spotiverse-portal.gamecult.org/auth/complete", + returnTo: "https://bifrost.gamecult.org/auth/complete", handoff: { kind: "backend_callback", - attemptId: "spotiverse-bad-attempt", - callbackUrl: "https://not-spotiverse.example/auth/heimdall/callback", + attemptId: "bifrost-bad-attempt", + callbackUrl: "https://not-bifrost.example/auth/heimdall/callback", }, }, }); diff --git a/tests/verse-runtime.test.ts b/tests/verse-runtime.test.ts index f7855af..85182dc 100644 --- a/tests/verse-runtime.test.ts +++ b/tests/verse-runtime.test.ts @@ -37,7 +37,7 @@ describe("Heimdall verse runtime publication", () => { runtime: { storageBackend: "postgres", configuredProviders: ["discord", "twitch"], - appProfileCount: 5, + appProfileCount: 4, }, }); expect(bySchema.get("heimdall.command_boundary.v1")).toMatchObject({ @@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => { expect(bySchema.get("idunn.daemon_health")).toEqual([ "yggdrasil-heimdall", "active", - "Heimdall auth runtime active; storage=postgres; providers=2/6; apps=5; healthTransport=CultNet/RUDP", + "Heimdall auth runtime active; storage=postgres; providers=2/6; apps=4; healthTransport=CultNet/RUDP", "2026-06-16T17:10:00.000Z", "heimdall.cultnet-rudp-provider-health", "daemon-published", diff --git a/tests/verse-witness.test.ts b/tests/verse-witness.test.ts index e3d9c31..acd9b89 100644 --- a/tests/verse-witness.test.ts +++ b/tests/verse-witness.test.ts @@ -35,7 +35,6 @@ describe("Heimdall Verse witness advertisement", () => { expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([ "repixelizer", "streampixels", - "spotiverse", "bifrost", "ghostlight", ]);