callbackMint now takes the connection: custody_only is a connect for a
managed-connection-only provider bound to connection.kind creator with a
targetId. Anything else is refused at start with creator_connection_required
before any redirect, and at the callback from the signed state before the
code exchange. The streampixels profile note says what Heimdall guarantees
and records the S1 rule that SP's redeem treats a non-creator Joystick
completion as an error, never a sign-in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The start gate only refused early. The callback ignored the signed state's
mode, so a Joystick connect for StreamPixels redeemed into a viewer_access
session. callbackMint(profile, provider, mode) now decides at the callback:
identity providers keep a viewer session (Bifrost discord connect unchanged);
a connect for a managed-connection-only provider stores custody and mints a
claim with no facts, no capabilities and no refresh token (the refresh route
would recompute viewer_access from the account); anything else is refused.
StreamPixels' redeem reads only account, provider, mode, connection and
claimSet.identities, so it accepts the capability-free claim unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first gate refused connect for a provider outside managedConnectionProviders,
which broke Bifrost's existing Discord connect. sign_in and link still take
identityProviders only, so a managed-only provider (Joystick) can never sign
anyone in or link; connect takes either list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sign_in and link now take only identityProviders; connect takes only
managedConnectionProviders. supportsProvider(), which unioned the role lists
with entitlementSources and never looked at the mode, is deleted: a Joystick
sign_in for streampixels no longer starts, so a Deru sign-in cannot rewrite
his managed credential row. No built-in profile had a provider reachable
only through the union.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recipe optional_environment gains the Joystick client id and secret file;
service-contract documents the row lock, rotation fail-closed and the 410/502
answers; the StreamPixels profile names Heimdall as sole holder of provider
refresh tokens (F5).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul S4: the Bifrost handoff notes, the Bifrost profile and the service
contract still named plaintext secret variables; each now names the _FILE
credential path. .env.example and the Spotiverse profile taught
GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET_FILE, which nothing reads: spotiverse
is not in appSlugs. Both lines are removed rather than adding the slug.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul F4: the architecture configuration list, the Spotiverse restore steps and
.env.example still taught plaintext secret variables. The architecture list now
names only what src/config.ts reads, plain settings apart from secret files,
and drops the names nothing reads (GC_ACCESS_SESSION_SECRET, GC_ACCESS_ENABLED,
GC_ACCESS_INTERNAL_URL, the Discord bot token, the Patreon campaign id and
two TTLs). .env.example comments out the _FILE lines, because an empty
NAME_FILE= is refused at startup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/app-profiles/streampixels.md carried no public origin, so the
allowlist entry in src/app-profiles.ts (https://streampixels.gamecult.org)
had only ops config as a corroborating source, not a doc owner. Add the
line, corroborated by gamecult-ops/nginx/streampixels.gamecult.org.conf:14
and StreamPixels' own .env example, so all four allowlist entries have
a doc owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>