Connection targets ride only on connect and link starts, which carry
x-heimdall-app-secret. A sign_in start is open to any browser; with a
connection it now returns 400 connection_not_allowed_in_sign_in.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Link start needs an authenticated app and an initiatingAccountId, carried in
the signed state. The callback refuses an identity held by another account
(link_initiator_mismatch) and attaches an unlinked one to the initiator.
auth_completions stores target_id; redeem compares it and answers
completion_target_mismatch without consuming the code. Link completions are
redeemed only by the authenticated app.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
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>
refreshLinkedIdentityCredentialIfNeeded is deleted. refreshManagedCredential
owns the policy; HeimdallStore.updateLinkedIdentityCredential owns the row
(SELECT ... FOR UPDATE in Postgres, a per-id chain in memory), so a refresh
token is presented once across concurrent requests and processes. invalid_grant
clears the tokens (410 managed_credential_revoked); any other failure keeps
them (502 managed_credential_refresh_failed). The "?? refreshToken" keep-old
fallback is gone; keeping it is an explicit branch for non-rotating providers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 5, S5-3: auth_completion_created stored completion.code, which
the unauthenticated redeem route turns into the session until it expires
(300 s by default), so the audit trail could redeem a live sign-in. The
two auth_completion_redeemed events (the HTTP redeem route and the private
command plane's redeem by attempt) stored the spent code as well.
All three payloads drop it. Nothing reads audit payloads back: the store
has no audit read, and no repository indexed by VoidBot consumes these
events. The event's sessionId already names the completion (one per
callback), so no replacement key is added. This matches Heimdall's own
witness contract, which says codes are redacted from audit exports.
Tests drive a sign-in through the HTTP redeem route and through the plane,
pin each payload exactly, and check the code appears in no audit event.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 4, F2: the route stored Bifrost's whole response body in its
audit event and returned it to the caller, as `detail` on a refusal and as
`bifrostResponse` on success. Another service's body can quote the signed
fact or its own configuration.
Both audit events now carry bifrostStatus and no body; a refusal answers
502 with fixed text and Bifrost's status; success answers bifrostStatus in
place of the body, which no caller reads (Bifrost's docs name only the
route). The body is discarded unread.
Tests drive the route against a Bifrost that answers 200 and 403 with the
canary in its body, and pin the reply and the audit event exactly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 4, F1: a provider's 2xx refresh answer that was not JSON made
JSON.parse quote the text around the fault, a refresh-token fragment among
it, and the plane returned that message in the reply's diagnostics. Those
travel outside the sealed envelope and Ghostlight logs them.
The plane's diagnostic is now fixed text plus the error's class and code,
through errorIdentity, which the failed-callback audit event now shares.
Every provider body Heimdall parses goes through readProviderJson: a
non-2xx answer or a body that is not JSON becomes a ProviderResponseError
(status and code, provider_status or provider_body_not_json) and no
parser message survives. This replaces ProviderHttpError and the separate
parses in the Discord guild lookup and the YouTube channel lookup.
Soul's canary probe is committed inverted: a refresh against a malformed
2xx body and against a 400 whose body holds the canary; no byte of it may
appear in the reply or its decoded failure payload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Self's ruling: the failed-callback audit event lost the provider's status
when it stopped storing the message. A provider's non-2xx answer is now a
ProviderHttpError carrying the status, and its message no longer holds the
response body, which nothing needs and which can quote the request. The
audit event adds providerStatus: an integer in 100-599, or null.
Tests drive the real Discord token exchange against a 401 whose body holds
the canary, and throw a status of 9999.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typecheck under exactOptionalPropertyTypes refused the relay commit: a
provider error that filters down to nothing left errorDescription
explicitly undefined in the page and handoff payloads. The field is now
added only when a relayed value remains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Self's ruling: the audit event stored error.message, and pg or resolver
text can carry fragments of a database URL. It now stores the error's
class name and its code, each checked against a narrow pattern. A test
puts the canary in the host of an ENOTFOUND error and checks that no
audit event holds it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Self's ruling: the provider_error relay stays, because it is the protocol,
but its error and error_description are query parameters anyone can write.
Only the characters RFC 6749 4.1.2.1 allows in them survive (%x20-21,
%x23-5B, %x5D-7E), and each is cut to a bound (64 and 256). The JSON
answer, the browser page and the backend handoff all take the relayed
values.
The page has no template engine. Every value in it goes through
serializeForScript, which escapes `<`, apart from the fallback URL in the
link's href, which the URL serializer percent-encodes. A test puts
`<script>` in error_description and checks the page holds only its own
script tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mutant that put the message back into the non-validation 4xx answer
survived: Fastify 5.8.5's own 4xx errors (invalid or empty JSON body, 415,
413) carry fixed messages, so the JSON-parse and media-type cases could not
tell. The branch protects against any error thrown with a 4xx status, whose
message is whatever its thrower wrote. A test route now throws one with the
canary, and the answer must be the code alone.
The handler's comment and the earlier commit's message said JSON.parse and
the 415 quote input; in this Fastify they do not. The comment is corrected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 3 (R1): with no error handler, Fastify answered a 5xx with the
error's message. A store failure under a misbound database URL reached an
anonymous caller as `getaddrinfo ENOTFOUND <host>` or `database "<db>" does
not exist`, and a misbound URL puts part of the password in either. The same
default quoted the request body in a JSON parse error (Node's JSON.parse
message carries the input), the Content-Type header in a 415, and the path
in a 404.
buildApp now sets both handlers. A 5xx answers `{ error: "internal_error" }`
and writes one stderr line naming the method, the route pattern and the
error code. A schema failure answers the failed rules by schema path and
Ajv message, which come from the route's schema. Any other 4xx answers
`invalid_request` with the error code. An unknown route answers `not_found`.
The app's own answers had two more echoes: `unknown_app` quoted the slug,
and a failed provider callback put the caught error's text in the browser
page and the backend handoff (the JSON answer was already fixed). Both are
fixed text now; the full callback error stays in the audit event.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getAppProfile indexed the profile record directly, so on the one route
without a slug enum -- GET /v1/apps/:appSlug -- every Object.prototype
key answered 200 with an empty profile body. Object.hasOwn guards it.
brandCaller minted a mutable object, so a legitimately held caller for
one app could be re-slugged into another and keep its brand. Frozen at
mint. Soul's prototype-chain forgery now has to be built with
defineProperty, because shadowing a non-writable inherited property by
assignment throws first -- that construction is dead on arrival.
The OAuth callback's 502 echoed the store's own error text as `detail`,
which on Postgres would hand the caller a unique-constraint name. Fixed
string now; the full text stays in the audit event.
Four of Soul's tests asserted these holes and are flipped to assert the
closures, not deleted.
tsc --noEmit -p tsconfig.json -> exit 0
vitest run -> Tests 147 passed | 2 skipped (149)
tests/odin-publication.test.ts still fails on Cannot find module
'cultcache-ts', red on origin/main too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runtime app-registration writer (POST /v1/apps) was deleted in an
earlier pass because it minted a client_secret nobody verified. This
pass kept resolveAppProfile's registered-app fallback and
listAppProfiles on the theory that AppSlug is an open string and
dynamic registration was a live extension point. Soul falsified that:
every HTTP route enumerates the four built-in slugs in its own JSON
schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body
field), so a registered row could never be resolved from a request.
With the writer already gone, this was unreachable code behind a dead
writer -- delete it rather than keep authenticating a door nobody can
open.
Deleted: src/app-registry.ts entirely (resolveAppProfile,
listAppProfiles, isBuiltInApp, registeredAppToProfile,
returnOriginsFromRedirectUris); StoredRegisteredApp and the
findRegisteredApp/listRegisteredApps HeimdallStore methods
(store/types.ts); their InMemoryStore and PostgresStore
implementations, including seedRegisteredApp and mapRegisteredApp; the
registered_apps table (store/schema.ts now drops it on any store built
before this schema); tests/app-registry.test.ts.
Every call site (app.ts x5, claims.ts, private-command-plane.ts) now
calls the existing built-in-only getAppProfile (app-profiles.ts)
directly -- sync, no store needed. If dynamic app registration is
wanted later it returns as a designed, authenticated feature, not by
resurrecting this vestige.
Net -305 lines against the prior commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The symbol brand on AppCaller stopped a TypeScript importer from
writing the `{ appSlug }` literal, but nothing enforced it at
runtime: no handler inspected the branded field, so a cast
(`as unknown as AppCaller`) reached refreshAppSession/startOAuthFlow's
caller gate with a forged value carrying the right appSlug. A brand
that claims an authority it does not enforce is worse than none --
this closes that gap by making the brand real instead of documenting
what it does not do.
brandCaller (app-caller.ts) now registers every AppCaller it mints in
a module-private WeakSet; isAppCaller is the only way a consumer may
trust one. startOAuthFlow and refreshAppSession call isAppCaller(caller)
instead of trusting the AppCaller | null type alone at their two gate
checks.
Flips Soul's forged-caller it.fails to a passing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The public /v1/oauth/:provider/start route read handoff.attemptId
straight out of the request body and folded it into the signed state
token unconditionally. Since the private command plane's beginAuth
mints an attempt handle server-side and later redeems by that handle
(consumeAuthCompletionByAttempt), anyone who learned a pending handle
could carry it through their own OAuth flow and bind their own
identity to it -- the app's completeAuth(H) would then authenticate
the victim's session as the attacker. Same family as the takeover
chain closed in the prior pass: a caller-chosen identifier trusted as
authority.
normalizeOAuthHandoff now always strips attemptId from a
browser_completion handoff; a public caller has no attempt to
correlate and gets its completion code directly. The only path an
attempt handle can reach the state token is startOAuthFlow's new
trustedBrowserAttemptId option, which is a TypeScript function
argument set by beginAuth from the handle it just minted -- never a
value parsed out of a request body.
Also adds the uniqueness constraint the by-attempt redemption needed:
a partial unique index on (app_slug, attempt_id) WHERE attempt_id IS
NOT NULL AND consumed_at IS NULL, mirrored in InMemoryStore. Without
it, consumeAuthCompletionByAttempt's UPDATE could match more than one
row and silently discard all but the one it returned.
Flips Soul's attempt-handle-binding it.fails to a passing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createAuthCompletion no longer takes `code: handoff.attemptId` — the
store mints it (previous commit); attemptId is passed through only as
the new `attemptId` correlation field. The redeem route's
updateAuthAttempt call used request.body.completionCode as if it were
the attempt handle, which only ever worked by the coincidence that the
private plane's own flow had code === handle; it now uses
completion.attemptId, the real correlation.
startOAuthFlow also refuses any returnTo whose origin is not in the
target app's AppProfile.allowedReturnOrigins (400
return_to_not_allowed). Before this, returnTo was validated only as
`format: uri`, and the browser handoff page postMessages the
completion payload to new URL(returnTo).origin — an unlisted origin
was an exfiltration primitive for whatever the browser flow hands
back, completion code included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
registerApp minted a client_secret whose hash nothing ever verified (six
routes gate on the shared-secret caller-identity check, not on registration);
registered_apps sits empty on the live host. Deleted: POST /v1/apps,
getRegistrationSecret, config.appRegistrationSecret, registerApp/
hashClientSecret/verifyClientSecret, the client_secret_hash column (dropped
idempotently on startup, same as it is created), and
tests/app-registration-endpoint.test.ts. registered_apps and resolveAppProfile
stay as a profile store; app-registry.test.ts fixtures that used registerApp
purely to seed a profile now write through store.registerApp directly, the
same primitive the built-in-shadow test already used.
acceptsBackendCallback is now a pure per-app env allow-list lookup;
Repixelizer's callback must be configured via
GC_ACCESS_APP_REPIXELIZER_BACKEND_CALLBACK_URLS like every other app (R8:
operator sets this on the running legacy body before the new build starts).
Test config adds the allow-list entry so existing Repixelizer flows keep
passing, and a new negative test pins that the slug name alone buys nothing.
R1: no GC service calls this route. Its shape (caller-supplied facts handed
straight to issueAccessClaim) is a second entitlement authority even when
gated, so authenticating it would preserve the oracle behind a password.
Deletion-only commit: route, discovery advertisement, mapper, contract type,
positive test, and doc mentions in architecture.md/service-contract.md/
fresh-workspace-handoff.md.
App slugs were a compile-time union of four names. That was fine while every app
was ours and it blocks what Heimdall is for: an app it has never heard of,
running on someone else's machine, registering against a self-hosted instance
without forking and rebuilding the auth authority. A creator self-hosting
Erycina alongside Heimdall would otherwise maintain a patched auth server
forever.
AppSlug is now a string, which it always was on the wire; the union was a
narrowing the type system could enforce only because the set was closed.
Membership moved from appSlugs.includes to the registry, and where no store is
reachable — the offline token verifier is the case that matters — it is a shape
check, isAppSlug, rather than a false claim to know what exists. A host app
verifying its own tokens should compare aud against its own slug, which it knows
and Heimdall's built-in list does not.
Registration is data end to end. Storage carries the profile and a hash of the
client secret rather than the secret. Built-in profiles resolve first and their
slugs are refused at registration, in both places, so a registration cannot
shadow a shipped app and inherit its audience.
POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library
can read it, returning client_secret exactly once. It is gated on an operator
secret and closed entirely until one is configured: an open registration
endpoint on an OAuth provider lets anyone create a client that starts Discord
and Patreon flows under the instance's identity, which is a phishing surface
wearing the operator's name. A fresh instance is not accidentally open.
Redirect URIs are validated rather than stored as given. They are where an
authorization code is delivered, so a permissive one is a token-theft primitive:
absolute, no fragment, https unless loopback.
Six call sites assumed an app profile always exists. They now answer 404 for an
app nobody registered, and claim issuance refuses outright rather than signing a
token whose audience no verifier can resolve.
108 tests pass, 34 of them new. Zero type errors.
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>