Retire the Spotiverse profile; keep the spotify provider intact for restore

Spotiverse was lost with the E: drive and has no repository in the swarm,
but /v1/oauth/spotify/start stayed publicly reachable and
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID/_SECRET stayed configured on
yggdrasil. That path would have run a real OAuth handshake against a real
secret and taken custody of a user's Spotify refresh token for a consumer
that could not use it.

The seam is supportsProvider(profile, provider): a provider with no app
behind it is unreachable. So the registry entry is what had to go, not the
provider.

Removed: spotiverseProfile and its registry entry, the spotiverse slug
from appSlugs, the hardcoded callback arm in app.ts, and three
spotiverse-specific tests. Removed from the host: the two spotify env
vars, backed up to /root/heimdall-service.env.bak-20260906-spotify. Nine
other provider vars and the file's 0600 heimdall:heimdall ownership are
untouched.

Kept deliberately: the entire spotify provider -- the providers.ts catalog
entry, spotifyRuntime and its token/profile exchange in oauth.ts, and
"spotify" in the providers union. It is intact and unreferenced, which is
what makes restoring a registry change rather than protocol work. The
capability is wanted back; a Persona managing the queue during a stream is
the motivating case.

docs/app-profiles/spotiverse.md is now the restore spec rather than an
archive: what was removed, what was kept, the base commit 5a15f71 to
recover the profile verbatim, and the daemon's callback URL
(http://127.0.0.1:8796/auth/heimdall/callback), which existed only in a
hardcoded allowlist arm and would otherwise have been lost with it.

Before deleting the three tests I checked their routes keep coverage:
managed-credential resolve is exercised twice via streampixels, and
backend_callback handoff at three other sites. The generic
untrusted_backend_callback negative test was retargeted to bifrost rather
than deleted. 35 pass, 9 files; tsc clean.

Verified the deployed build still boots without the vars --
readProviderConfig returns an empty config rather than throwing, so the
running release fails that route at request time instead of at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-06 01:47:10 +02:00
commit 707da368b1
7 changed files with 59 additions and 169 deletions

View file

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

View file

@ -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<AppSlug, AppProfile> = {
repixelizer: repixelizerProfile,
streampixels: streampixelsProfile,
spotiverse: spotiverseProfile,
bifrost: bifrostProfile,
ghostlight: ghostlightProfile,
};

View file

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

View file

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

View file

@ -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",
},
},
});

View file

@ -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",

View file

@ -35,7 +35,6 @@ describe("Heimdall Verse witness advertisement", () => {
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
"repixelizer",
"streampixels",
"spotiverse",
"bifrost",
"ghostlight",
]);