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:
parent
5a15f711e8
commit
707da368b1
7 changed files with 59 additions and 169 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ describe("Heimdall Verse witness advertisement", () => {
|
|||
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
|
||||
"repixelizer",
|
||||
"streampixels",
|
||||
"spotiverse",
|
||||
"bifrost",
|
||||
"ghostlight",
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue