Soul F5: the provider witness names GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE
and GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE, but the recipe declared
neither, so an Idunn binding could not configure either provider. Both
providers are live code (the provider list, the catalog and the OAuth start
route serve them), so both names are declared as optional.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul F1: the unreadable-file error carried the NAME_FILE value, and Idunn
admits any string for a declared name, so a URL bound into
GC_ACCESS_DATABASE_URL_FILE by mistake was printed in full at startup. Errors
now name only the variable and an error code, and a NAME_FILE that is not an
absolute path is refused naming the variable alone.
Soul F2 and F6: a blank database URL file selected memory storage silently,
and a whitespace one selected postgres with a URL of " ". The reader now
refuses an empty or whitespace-only value from either form, so custody's own
"set but empty" check is redundant and is deleted; a short key still fails its
length check. The legacy service.env holds no blank secret-class value
(checked as name/blank booleans), so the legacy unit is unaffected.
Soul F3: tests pin that an empty NAME_FILE is refused rather than unset (S1),
and that each call site (token key, provider secret, app shared secret,
database URL, patron secret) refuses both forms, so no site can read the
plaintext name ahead of the reader (S6, S7, S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StrykerJS on the reader and its call sites left two findings about the
tests. Every trim fixture had its newline only at the end, so stripping the
first newline anywhere survived: a two-line value now pins "one trailing
newline". No fixture reached the memory branch of storage selection, so
forcing postgres, dropping the backend check or inverting it survived: the
memory default and an explicit backend of either kind are now pinned.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GC_ACCESS_SIGNING_PRIVATE_KEY_PEM put the signing key's bytes into the
process environment, and so into any binding, unit or plan that carries
environment. The recipe never declared it and GC_ACCESS_SIGNING_PRIVATE_KEY_PATH
already covers the key as a file, which a binding binds through
[workload.secret_files]. The signing key is now always a file.
Idunn secret-files cut, Cut 1 (delete first).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idunn no longer infers how a target proves readiness. Heimdall is routed
but never answers a route challenge; it publishes its presence to Odin, so
its recipe declares the odin.verse-rendezvous dependency and is admitted as
Odin-correlated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Discord callback has issued a random completion code distinct from the
attempt id since the redemption-code fix, but the browser adapter still
required heimdall_attempt_id == heimdall_completion_code and returned
"malformed authentication completion witness" for every real return, so no
app that vendors the adapter could finish a browser sign-in. The test
fixture defaulted the code to the attempt id, restating the old contract.
The adapter now completes with the attempt id, the handle the app backend
redeems over the private command plane, and never reads the completion
code. The fixture issues a distinct code as the server does; returning the
code as the handle fails the tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deletes state/scratch-compaction-d44a230f-4396-40bc-bade-8540321fc091.md, a
one-shot "imminent compaction" memory dump written before a StreamPixels
auth-migration session. Its content is fully superseded: it names dead
E:\Projects\Heimdall / E:\Projects\StreamPixels paths (this host is
F:\Projects), and its "next action" (Heimdall commit f22db66 "Add
StreamPixels managed auth seams", plus the paired StreamPixels-side commit)
already landed.
Kill attempt: rg -n "scratch-compaction-d44a230f" across all of F:/Projects
excluding target/node_modules/.git: only this file itself, the census docs,
and gamecult-site's generated interactive repo-doc-tree
(GameCult/static/interactive/gamecult-compound/repo-doc-tree/heimdall.json),
which is a static catalog snapshot linking to GitHub blob URLs for every
tracked file in every repo, not a functional reader — it will show one more
stale link next time it's regenerated, same as it would for any other file
deleted from any repo.
No collision check needed: single checkout on main, no other worktrees.
REC-114
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cultnet-ts removed a fragment buffer only on successful reassembly, so a
set that lost one fragment was stranded for the life of the session and
the map grew without bound.
That path is live here. Ghostlight sends this service sealed envelopes
over CultMesh RUDP with max_fragment_bytes = 2048
(Ghostlight/crates/ghostlight-dungeon/src/heimdall.rs:697), and the
private command plane reassembles them through exactly that map, so every
lost fragment on that link left a buffer nothing would collect on a
long-running auth service.
The pin also carries the media stream contracts, but those are cultnet-rs
additions this service does not build -- the recipe builds cultcache-ts,
cultnet-ts and cultmesh-ts only. The functional change for Heimdall is the
32-line eviction in cultnet-ts/src/rudp.ts.
Co-Authored-By: Claude Opus 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>
Adds tests/caller-identity-attack-soul-r21.test.ts. Plain it = holds at
5373e33; it.fails = open hole. One open hole: GET /v1/apps/:appSlug has no
slug enum and getAppProfile is a bare object index, so /v1/apps/constructor
answers 200 {} (inherited from main's resolveAppProfile). A live-postgres
block runs only under SOUL_PG_URL; verified once against postgres:17-alpine:
23505 names auth_completions_attempt_unconsumed_unique_idx, registered_apps
is dropped, ensureSchema applies twice on a pre-branch store.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tsc has been red since 9ebf38b: builtInAppProfiles.repixelizer is
Partial-typed, so passing it to isAllowedReturnOrigin and spreading it
into an AppProfile literal both fail under exactOptionalPropertyTypes.
Clean at 827cdee, red at 9ebf38b and after -- the earlier "pre-existing"
reading was taken against a commit inside the same pass rather than
against the last green one.
Narrowed with a throw rather than a cast: a missing built-in should fail
the suite loudly, not be asserted out of existence.
tsc --noEmit -p tsconfig.json -> no output, exit 0
vitest run -> Tests 121 passed | 1 skipped (122)
The one failing file remains tests/odin-publication.test.ts
(Cannot find module 'cultcache-ts'), red on origin/main as well.
Co-Authored-By: Claude Opus 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>
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>
Both caller-identity-soul.test.ts holes are now closed and promoted
from it.fails to a plain it (both green): the caller-chosen
completionCode is never redeemable, and an arbitrary returnTo origin
is refused. The AppCaller-literal tripwire's expected forger list moves
from ["private-command-plane.ts"] to [] now that the private plane
constructs its caller through callerFromOpenedEnvelope instead of the
literal; the "refuses to mint on refresh" test's wrongApp caller is
built the same way, since a raw { appSlug } literal no longer
satisfies the now-branded AppCaller type.
access-plugin.test.ts's ghostlight completion-code assertion is
updated: `completion.code` is minted independently of the attempt
handle now, so the test documents that the code differs from the
handle instead of asserting they match.
app-registry.test.ts drops validateAppRegistration coverage (deleted)
and seeds registered-app fixtures through InMemoryStore.seedRegisteredApp
instead of the deleted store.registerApp.
docs/service-contract.md documents the new 400 return_to_not_allowed
failure mode. state/evidence.jsonl records this pass's decisions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AppCaller used to be a plain { appSlug } interface, so any module could
construct one structurally — only grep discipline stopped an importer
from forging a caller. It is now branded with an unexported Symbol;
the only two constructors are resolveAppCaller (HTTP, unchanged) and
the new callerFromOpenedEnvelope (private command plane). The plane's
two `{ appSlug }` literals in refreshAuth/beginAuth are gone.
completeAuth also stops forging an HTTP self-call
(app.inject(".../auth-completions/redeem", { completionCode: handle }))
that only worked because handle used to equal the completion code. It
now redeems directly through store.consumeAuthCompletionByAttempt and
reproduces the audit event / attempt-consumed transition the HTTP route
does, since it is no longer routing through that HTTP handler at all.
The two ghostlight-only branches (`appSlug === "ghostlight"` requiring
a discord_role_access policy) are replaced by
requireEntitlementPolicyIfProfileDemandsIt, which reads
AppProfile.requiredEntitlementPolicyKind through resolveAppProfile —
the same profile-resolution path the rest of the app uses, so a future
app with the same requirement declares it in its profile instead of
adding another branch here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AppProfile gains allowedReturnOrigins (repixelizer/streampixels/bifrost
-> <slug>.gamecult.org, ghostlight -> yggdrasil.gamecult.org per
docs/app-profiles and the existing test fixtures) and an optional
requiredEntitlementPolicyKind, replacing the private command plane's
`appSlug === "ghostlight"` branches with data a future app can declare
for itself. A registered (non-built-in) app's allowedReturnOrigins is
derived from its stored redirectUris' origins — the same shape the
deleted registration validator used to check, now put to use instead of
only being validated at intake.
app-registry.ts also sheds the registration-only surface:
validateAppRegistration, AppRegistrationRequest, RegistrationProblem,
and AppRegistrationError deleted (the previous commit removed their
only writer, HeimdallStore.registerApp). resolveAppProfile/
listAppProfiles/isBuiltInApp are unchanged in shape.
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>
The completion code's `code?` input let a caller choose it; app.ts used
that to make handoff.attemptId become the code verbatim, which is the
account-takeover primitive Soul's probes proved. The store now always
mints the code itself. attemptId survives only as a separate
correlation field (StoredAuthCompletion.attemptId /
CreateAuthCompletionInput.attemptId), and a new
consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets a caller
that already knows the attempt handle (the private command plane)
redeem the completion it created without ever touching the code.
Also deletes HeimdallStore.registerApp: the runtime app-registration
HTTP surface that minted it is already gone from this branch, and
nothing else in the codebase writes to registered_apps (it sits empty
on the live host). findRegisteredApp/listRegisteredApps stay: AppSlug
is a plain string (contracts.ts), so registered_apps remains the real
extension point for a slug Heimdall was never rebuilt to know about; a
row is now provisioned by direct SQL, or InMemoryStore.seedRegisteredApp
in tests, not by any app-facing writer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plain cases pin the invariants the cut does enforce: null/mismatched
AppCaller cannot pass policy through startOAuthFlow or refreshAppSession,
the header and secret table have exactly one reader each, secretMatches is
the only comparator and stays timing-safe, and the private plane refuses a
correctly sealed envelope from an unconfigured sourceRuntimeId before any
attempt is created.
Two it.fails cases document the unauthenticated mint path the cut did not
close: a client-chosen browser_completion attemptId is the completion code
verbatim and auth-completions/redeem takes no app secret, so an
unauthenticated starter can redeem the victim's signed access and refresh
tokens; and returnTo is only validated as a URI, not bound to an app-owned
origin. They flip to real failures when the hole is closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The doc still described the pre-cut world (Repixelizer named as the one app
allowed to send policy). Documents the actual failure mode: 401
app_auth_required from resolveAppCaller for any app, any handoff kind.
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).
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.
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.
This test called openProviderHealthIdentity to prove the descriptor is taken
despite a foreign LISTEN_PID, which meant it did readFileSync on fd 3 — a live
handle inside the vitest worker. On Windows that blocks rather than throwing,
so the worker hung and took the whole suite with it: `vitest run` never
terminated on this repo, and the cause was read as "vitest is flaky on
Windows" for most of a night.
The decision under test is which descriptor gets chosen. Reading a real one
was never part of it, so this asserts passedDescriptor directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Heimdall runs behind Yggdrasil-hosted nginx on Linux with Idunn owning the
deployment transaction, but nothing an agent loads in this repo said so.
Record the target and what a Windows workstation build is evidence for.
The dirty vendor/CultLib submodule is left untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving the CultLib pin forward to pick up the presence contract also brought
ten unrelated commits, one of which republished the TypeScript packages under
the @gamecult scope. Two things broke on that rename.
The recipe selected build workspaces by package name, so `--workspace=
cultcache-ts` no longer matched anything. Selecting by path instead says what
is actually meant and survives the next rename.
cultnet-ts depends on cultcache-ts by registry range, not by workspace
protocol, so the vendored copy is only used because Heimdall overrides that
name to the vendored path. The override still named the old one, and pnpm went
looking for @gamecult/cultcache-ts on a registry that does not have it.
Heimdall now uses the package's real name in its dependency, its override and
its imports.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Heimdall published `idunn.signed_daemon_health.v1`, the previous generation's
contract. Odin admits a document as runtime presence only when it carries
`gamecult.runtime_presence_health.v2` and its record key equals the signed
target; everything else it stores as an ordinary document. So Heimdall's
health arrived, was filed away, and no topology correlation for it was ever
produced -- which is what held every deployment at Warming.
The statement is dual-proved, so the process must also prove it is the
incarnation Idunn started, using the activation key passed as a descriptor
beside the presence identity. Every field describing the launch is read from
the runtime bundle rather than configuration.
Two things had to be fixed for that to be possible at all:
The descriptor lookup compared LISTEN_PID to our own pid. Under PrivatePIDs
systemd sets it to a pid in the outer namespace, so it never matched, and
because the lookup answers "undefined" rather than throwing, Heimdall quietly
self-enrolled a key nothing trusts -- exactly the failure its own comment
warns about. The name in LISTEN_FDNAMES identifies the descriptor.
The publisher sequence was an in-process counter starting at zero every boot.
Idunn treats a repeat or regression as a replay, so a restart would have made
every later statement refusable; it now lives beside the target's state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Distilled per AGENTS.md: app slugs stopped being compile-time, capability rules
became data, and registration is gated and closed by default. The motivating
fact is the one worth keeping — a creator self-hosting Erycina alongside
Heimdall would otherwise maintain a patched auth server to add one slug.
state/map.yaml is untouched: the tracked next action is the Ghostlight canary
work, which this pass did not advance and should not overwrite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Every app profile carried an evaluateSharedCapabilities function beside a
sharedRule string describing what that function did. Two representations of one
rule, free to drift, and the reason a profile could never arrive at runtime: you
cannot accept a function from a stranger and run it inside the auth authority.
The rules were never more than a disjunction. Every sharedRule across all four
built-in profiles is "a || b || c" over a closed vocabulary, and one of them is
a bare reference to another capability. So the executable form is a list, not an
expression language, and anyOf is now the only representation. The readable form
is derived from it rather than stored beside it.
Evaluation runs to a fixed point because capabilities may reference each other:
repixelizer's queue_submit derives from app_access, and a single pass grants it
only when the rules happen to be declared in dependency order. It terminates
because the granted set only grows and is bounded by the rule count.
validateCapabilityRules is the gate that makes runtime registration acceptable.
A term must name a fact Heimdall itself produces or another capability in the
same profile. A registered app selects from what Heimdall has already decided is
true about an account; it cannot invent a fact to grant itself something, and it
cannot reach into another app's capabilities. An unknown term is refused rather
than treated as false, because a rule that silently never fires becomes a
support ticket instead of an error.
The four built-in profiles are now seed data rather than special cases: a
registered app produces the same shape and runs through the same evaluator.
Behaviour is unchanged. 74 tests pass, 18 of them new, covering derived
capabilities in both declaration orders, hybrid capabilities never being granted
from shared rules, and every rejection path in the validator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first release that got far enough to report its own failure said
EADDRINUSE 127.0.0.1:4101: the CultNet private command plane. I gave the
HTTP listener a candidate bind and never noticed Heimdall has a second
listener on a fixed port, so the candidate collided with the incumbent and
died before it could warm.
A fixed private port makes coexistence impossible, which is the premise the
whole warm-then-cutover rests on. The private plane belongs to the same
generation as the HTTP listener, so under Idunn its port is derived from
the assigned candidate port with a fixed offset. Each generation gets its
own pair, and the published command-boundary endpoint stays truthful
because it reads the same config value. Outside Idunn the configured port
is unchanged.
The offset is large enough that a derived port cannot land inside the
route's candidate range and collide with another generation's HTTP
listener.
57 tests, 56 pass and the machine-id-bound one skipped on Windows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first sealed-release launch exited 1 with no stdout, no stderr and
nothing in the journal. The catch around startup logged through app.log,
which is pino: it buffers, and setting process.exitCode lets the process
exit before the buffer flushes, so the error was written nowhere.
Under a supervisor that is the worst shape a failure can take. Idunn
reported only "systemd unit ... is not running", the journal held two
systemd lines and nothing else, and the actual cause -- whatever it is --
was destroyed by the logger meant to record it.
Startup failures now also go to stderr directly, which systemd captures
synchronously. Every earlier failure in this deployment printed a stack
trace precisely because it happened before the logger existed; the ones
after it were silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Idunn compares the running process's executable inode against the sealed
artifact. A shebang script can never satisfy that: the kernel execs the
interpreter, so the process's executable is /usr/bin/node and the check
correctly reports an executable inode outside the sealed release.
That is an integrity property worth keeping, not a check to relax, and it
explains why every target with a recipe so far has been Rust. The fix that
preserves it is to make the interpreter part of the release: a build step
copies the runner image's own node into dist/, it ships as the executable
artifact, and the bundle becomes its first argument. The executed inode is
then sealed like everything else, and the node version is pinned to the
runner image digest rather than being whatever the host happens to have.
Costs a ~110 MB binary per release against retain_releases = 3. Cheaper
than Node's single-executable tooling and it needs no postject.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first workload launch reached Heimdall.s own config validation and
failed on GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64, which the incumbent
supplies from service.env and the binding did not. Rather than discover
one variable per deployment, the recipe now declares the whole set the
incumbent sets -- 32 names -- so the binding may supply them and admit()
stops rejecting each in turn.
Deliberately excluded: the Spotiverse app secrets (that profile is
retired), GC_ACCESS_CULTCACHE_PATH and GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH.
Idunn owns both now -- state location arrives as --state-root, and the
signing identity arrives as an inherited descriptor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I claimed this morning that cultcache-ts could not read a cultcache-rs
store and that the CultLib forks had diverged. That was false, and it was
false in the usual way: generalised from one file.
cultcache-ts reads Idunn's live control store on yggdrasil directly -- 36
records, no complaint. Ordinary CultCache stores use the same framing in
both runtimes, and that covers the control store, the runtime bundle's
expected.cc and activation.cc, and the process write lease. The exception
is narrow and deliberate: service identity private stores are written by
cultnet-rs's atomic_create_private_store as a single bare positional
envelope, because they are a private-key container rather than a record
store.
The consequence was a live bug. Both readers I wrote decoded the
private-store framing, so the write lease and expected-incarnation readers
would have failed on the first real deployment -- with green tests, because
I had built the fixtures the same wrong way. process-write-lease.ts now
uses the CultCache client, provider-health-identity.ts keeps the bare
reader for the fd-passed identity where it belongs, and the lease fixtures
are written with the client so a test can disagree with me.
Also corrected the write-up in Idunn/docs/migration.md and the memory that
recorded the false finding. Three places had it wrong; leaving any of them
would have taught the next session the same mistake.
54 pass, 1 skipped, 12 files; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Idunn refuses to seal a release containing a symlink with parent traversal,
and pnpm's node_modules is exactly that -- a forest of relative links into
.pnpm. Negotiating with that layout failed twice: --node-linker=hoisted was
silently ignored (staging trees still had .pnpm), and pnpm 10's
--config.node-linker=hoisted did not resolve it either.
Bundling removes the question. `pnpm bundle` runs esbuild over src/index.ts
into a single 2.5 MB dist/heimdall with a shebang and a createRequire shim
for the CJS dependencies, pg-native left external as the optional native
binding it is. node_modules is now a build input rather than an artifact.
That also fixes a defect the seal had not reached yet: launch_command does
`ensure!(executable.is_file())` and execs the artifact directly, so the old
recipe -- executable_artifact pointing at the dist *directory*, with
"dist/src/index.js" as the first argument -- would have failed there too.
The service is now one file that is genuinely executable, and its only
arguments are the state root Idunn assigns.
Shipping a dependency tree to run a service was the larger liability
anyway; this deletes it rather than making it sealable.
Recipe validates; 54 pass, 1 skipped, tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pnpm 10 takes rc settings as --config.<name>, so --node-linker=hoisted was
being ignored: staging trees written after that change still contained
node_modules/.pnpm. --config.node-linker=hoisted does not resolve it
either -- the seal still fails with "sealed release symlink has a parent
traversal", and fails faster than a fresh install would take, so something
earlier in the chain is being reused.
Recording the attempt rather than leaving the flag looking correct. The
next move is probably to stop shipping node_modules as an artifact at all
and bundle the service instead, which removes the question rather than
negotiating with pnpm.s layout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pnpm defaults to a symlinked node_modules -- a forest of relative links
into .pnpm -- and Idunn rejects sealing a release containing parent
traversal: "sealed release symlink has a parent traversal". That is not a
configuration slip, it is pnpm.s default layout being structurally
unsealable.
--node-linker=hoisted produces a flat npm-style tree with no symlinks, at
the cost of some disk. The unit runs node dist/src/index.js and resolves
imports at runtime, so the release genuinely needs real modules rather
than a bundle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pinned node image provides npm but not pnpm, and the image entrypoint
execs node for an unrecognised first argument, so a pnpm step failed with
"Cannot find module /workspace/pnpm". Heimdall declares
packageManager: pnpm@10.33.0, and corepack ships with node and honours
that declaration, so it is the toolchain the image already has rather than
a second thing to install.
corepack needs a writable home; COREPACK_HOME joins the step environment
alongside NPM_CONFIG_CACHE and HOME, pointing into the bind-mounted cache.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Docker runner starts the container with --read-only; only /workspace
and a noexec /tmp are writable. Without a cache_root there is nowhere for
npm to cache, and the partial writes surface as "tarball data ... seems to
be corrupted" across most packages, then exit 254. Isolating it proved the
network was fine: the same image on the same network installs
playwright-core cleanly outside the runner sandbox.
I caused this by omitting cache_root when writing the binding, reasoning
that fewer paths meant less to debug. It meant more.
Build and test steps now declare NPM_CONFIG_CACHE and HOME; the binding
supplies both pointing at /cache, where Idunn bind-mounts the cache root
read-write when the build-cache affordance is present. Lowercase env names
are rejected by the recipe validator, which is why these are uppercase.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cultcache-ts and cultcache-rs write different single-file store layouts,
and the TypeScript client cannot read the Rust one at all -- its schema
rejects it with "expected object, received array". Verified against the
real enrolled identity on yggdrasil, not inferred.
cultcache-rs [ [key, type, payload, stored_at, schema_id], ... ]
cultcache-ts [ formatVersion, catalog, records ] with named envelopes
Both readers I wrote for Idunn-written files assumed the named-object
shape. They were wrong, and their tests passed only because the fixtures
were built to match the assumption rather than the writer -- the same
failure the census keeps recording, where the check agrees with the guess.
src/idunn-store.ts now owns that decode, consumed by the write-lease reader
and the presence-identity reader, and says plainly that it exists because
the two CultLib forks disagree. If they are reconciled, it should be
deleted in favour of the client.
The write-lease fixture is rebuilt as a positional envelope, and the
descriptor test now wraps a genuine machine-bound payload the way
cultcache-rs writes it instead of enrolling through the TypeScript client
and reading it back.
55 pass, 1 skipped, 12 files; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Idunn passes the runtime presence identity as a parent-only file descriptor
-- fd 4, named gamecult-runtime-presence-identity via LISTEN_FDNAMES --
not as a path or a LoadCredential. The driver explicitly filters it out of
the credential set. That is deliberate: the workload runs under DynamicUser
and cannot open the 0600 root-owned store, and a descriptor cannot be
re-read by anything that did not inherit it.
Heimdall was calling openOrEnrollProviderHealthIdentity, which enrolls a
fresh key when the path is empty. Under Idunn that path is inside the
target's own writable state root, so Heimdall would have quietly minted its
own key and signed health with it. Idunn's trust store names the operator-
enrolled signer, so that health would be refused, warming presence would
never be observed, and the deployment would hang at promotion with a
service that looks healthy from every other angle -- no error naming the
cause.
openProviderHealthIdentity now prefers the passed descriptor, honouring
LISTEN_PID so descriptors addressed to another process are not consumed,
and falls back to the self-enrolling path when systemd passed nothing, so
developer runs and the current plain unit are unchanged.
Verified the two runtimes actually interoperate before relying on it:
cultnet-rs and this module use the same schema
(gamecult.provider_health_identity.private.v1) and the same
linux_file_mode_machine_id_binding protector, so an idunn-provision
enrolment is readable here. It also means the identity must be enrolled on
the host it runs on, which is where it was.
The descriptor test only runs where a machine-id exists, so it is skipped
on Windows -- and that skip hid a defect in the test itself: it assumed
open() returns fd 3. It now pads LISTEN_FDNAMES so the name index maps onto
whatever descriptor was actually returned.
55 pass, 1 skipped, 12 files; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writing the operator binding surfaced two recipe defects that only the
real admit() could catch.
The recipe declared transport = "rudp", copied from Odin's. Heimdall's
routed surface is HTTP -- the CultNet private command plane is separate and
Idunn does not route it -- and the transport decides the scheme used to
derive the candidate endpoint, so this would have produced a nonsense bind
address. Now http.
Second, a recipe with state slots is a stateful workload, and admit()
requires the state root to arrive as a launch argument rather than an
environment variable: "stateful workload has no state_root argument
binding". That is the better ownership anyway. Idunn owns where a target's
state lives, the recipe declares slots relative to it, and the binding
supplies the absolute path. Heimdall now takes --state-root, requires it to
be absolute, and prefers it over GC_ACCESS_DATA_ROOT, which survives for
runs outside Idunn. GC_ACCESS_DATA_ROOT is correspondingly gone from the
binding's environment, so there is one owner for that path instead of two
spellings of it.
Verified against a real Linux Idunn rather than assumed: `idunn validate`
on Windows cannot check a Linux binding at all, because Path::is_absolute()
wants a drive prefix and /srv/build/Heimdall fails as "checkout must be
absolute". Built Idunn on yggdrasil in a rust:1.95-bookworm container --
the cached rust:1.89 image is too old, redb 4.2 requires 1.90 -- and
validated there: recipe ok, binding admits.
52 tests across 11 files; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>