Closes StrykerJS survivors on the diff: the cap at exactly 365 days against a
second over, the rotating-exchange failure's typed code (and that a
non-rotating provider is unaffected), and the start refusal's error word.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Delivers a joystick connect through a backend_callback handoff and checks the
posted body has no refreshToken or refresh and empty capabilities, so the
handoff spreading `issued` instead of `delivered` fails.
Co-Authored-By: Claude Opus 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>
Re-signs a joystick connect state as sign_in and link with the app's own key
and shows the callback refuses before the code exchange and writes nothing.
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>
Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves
identity by channel id, and is a streampixels managed-connection provider only:
it is not an identity provider for the profile (ruling signin-waits-for-public-bot).
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 6, F5 and F6. The all-at-once run set every name to one probe
value and died in loadConfig's first lines, so reads gated on a specific
value (Z4) or on two values late in the function (Z5) were missed, and a
copy of process.env taken at module load (Z6) was never the recorder.
Each name now gets each probe value alone, and the probe values include
every string literal in config.ts; the combined runs set every name at once
to values loadConfig accepted for it alone. config.ts is imported afresh
while the recorder is process.env, so a module-scope capture records too.
The remaining limit is stated in the test.
failureOf in the app tests now prints the error with util.inspect as well,
so a non-enumerable cause carrying the body (Z3) shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
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>
F1's second half, tested where it is decided: the Discord token exchange
(a 2xx body that is not JSON, and a 401), the Discord guild lookup and the
YouTube channel lookup each fail with a ProviderResponseError whose code
and status name the failure, and no printed form of the error (message,
stack, JSON) holds a byte of the canary body. Until now only the callers'
fixed answers were pinned, so a body in the message went unseen.
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>
Same class as Soul pass 4's F1: deliverBackendHandoff put the receiving
app's whole response body into its error message. The handoff carries
tokens and an app's error page can quote it, and the message reaches
whatever catches the error. It now names the status alone and discards the
body unread.
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>
Repixelizer and Bifrost now hold app secrets in the test fixture (matching
the client-side header changes fork 1B requires in their own repos) since
entitlement policy on OAuth start and session refresh now requires a caller
regardless of handoff kind or app slug. Adds: a pin that policy without a
caller is 401 app_auth_required for a browser-completion handoff exactly as
for backend_callback (handoff kind is no longer a substitute for identity);
a uniform-gating check across all four built-in slugs; a timing-safe-only
comparator pin. Full suite run: 96 passed, 1 skipped, 1 pre-existing failure
in tests/odin-publication.test.ts (missing cultcache-ts module via
cultmesh-ts, unrelated to this cut -- vendor/CultLib was already dirty when
this branch was cut from origin/main).
Per-app identity with a global fallback made repixelizer and streampixels one
identity in practice. Each app now needs its own
GC_ACCESS_APP_<SLUG>_SHARED_SECRET; operator mints distinct values on
Yggdrasil in the same deploy window. Doc note in service-contract.md corrected
to stop advertising the fallback.
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.
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>