Commit graph Heimdall/src
Author SHA1 Message Date
Meta Vens
9ac1482184 Close the three one-line holes Soul left as follow-ups
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>
2026-09-08 15:08:38 +02:00
Meta Vens
e21659cd31 R21.3: delete the unreachable registered-app resolution path
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>
2026-09-08 14:51:23 +02:00
Meta Vens
3427bdda1e R21.2: enforce the AppCaller brand at runtime with a module-private WeakSet
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>
2026-09-08 14:44:42 +02:00
Meta Vens
ea804ddbbe R21.1: bind the browser_completion attempt handle to the private plane
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>
2026-09-08 14:43:33 +02:00
Meta Vens
06a4026d4f Brand AppCaller; private command plane gets its own constructor
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>
2026-09-08 14:22:48 +02:00
Meta Vens
29571204da App profiles: returnTo allowlist and per-app policy requirement as data
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>
2026-09-08 14:22:37 +02:00
Meta Vens
e5c9228be2 app.ts: never trust a caller-chosen completion code or returnTo origin
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>
2026-09-08 14:22:26 +02:00
Meta Vens
5a4da2ae30 Store: mint auth-completion codes server-side; drop the registration writer
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>
2026-09-08 14:22:12 +02:00
Meta Vens
52be750b48 Add the single caller-identity organ; delete the five ad hoc predicates it replaces
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).
2026-09-08 13:53:09 +02:00
Meta Vens
776565effd Delete the runtime app registration surface (R7, fork 3)
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.
2026-09-08 13:53:09 +02:00
Meta Vens
326f613ffe Delete the global GC_ACCESS_APP_SHARED_SECRET fallback (R6, fork 2)
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.
2026-09-08 13:53:09 +02:00
Meta Vens
c6d0ecb68b Delete hard-coded Repixelizer callback trust (deletion_line 2)
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.
2026-09-08 13:53:09 +02:00
Meta Vens
a999d48b6d Delete claims/issue: zero callers, arbitrary-facts oracle bypassing entitlement evaluation
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.
2026-09-08 13:53:09 +02:00
Meta Vens
6a923d525e Follow CultLib's package rename and select workspaces by path
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>
2026-09-07 22:26:24 +02:00
Meta Vens
97ea976954 Publish runtime presence in the contract Odin admits
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>
2026-09-07 22:19:51 +02:00
Meta Vens
053cb0a17e Register apps at runtime instead of at build time
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>
2026-09-07 00:11:41 +02:00
Meta Vens
1d221ab7b6 Make app capability rules data instead of code
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>
2026-09-06 23:58:58 +02:00
Meta Vens
5443ae7daf Move the private command port with the candidate
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>
2026-09-06 22:15:34 +02:00
Meta Vens
ac9ad01f5d Make startup failures visible
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>
2026-09-06 22:11:09 +02:00
Meta Vens
8086d5155e Retract the CultLib divergence claim; read each .cc with the right client
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>
2026-09-06 19:11:19 +02:00
Meta Vens
8c1aa9f9e8 Read Idunn stores in the layout Idunn actually writes
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>
2026-09-06 16:07:47 +02:00
Meta Vens
0d4b9a6c30 Take the signing identity Idunn passes, instead of enrolling a rival one
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>
2026-09-06 16:01:13 +02:00
Meta Vens
0610683295 Let Idunn assign the state root, and make the routed transport http
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>
2026-09-06 15:16:18 +02:00
Meta Vens
726dd56e7d Honour the Idunn process write lease, and stop claiming active while warming
During a deployment two Heimdall processes exist at once under distinct
UIDs and namespaces, and both are configured with the same CultCache store
path. Exactly one may write it. Idunn decides which by publishing a lease
record naming an incarnation; nothing in Heimdall read it, so both
generations would have written heimdall.service.cc.

src/process-write-lease.ts answers one question -- may this process write
state right now -- by reading the lease and the runtime bundle's
expected.cc, and comparing incarnation identity. It grants nothing and
throws nothing outward: every failure is a decision of mayWrite:false with
a reason, because for a warming candidate "not yet my turn" is the
expected condition and crashing would turn it into a failed deployment.
Records are decoded as the canonical positional MessagePack Idunn writes
(14 fields for the lease, schema-checked), and the tests build the same
envelope bytes rather than convenient objects.

publishHeimdallVerseState is the single writer, so that is the only place
gated. Outside Idunn no lease path is configured and writes proceed
unconditionally, which keeps developer runs and the current plain unit
working.

Second defect found while wiring it: health publication hardcoded
state "active". A candidate would have claimed active from its first
pulse, which is both untrue and structurally broken -- Idunn must observe
`warming` presence before it fences the incumbent, so a candidate that
never reports warming cannot be promoted. State is now derived from the
lease: warming until held, active after, with the reason carried in the
detail string.

49 tests across 11 files; tsc clean under exactOptionalPropertyTypes.

The recipe's status banner now records the contract as implemented, with
the honest caveat that none of it has been through a live `idunn up` --
the daemon on yggdrasil is still the previous generation, so the first
deployment remains the real verification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:25:38 +02:00
Meta Vens
817d4970b9 Take the Idunn candidate socket, and keep it out of the advertised URL
Heimdall bound config.port unconditionally, so it could not warm alongside
itself: Idunn runs candidate and incumbent at once under distinct UIDs and
namespaces, and two generations on 4100 is EADDRINUSE. src/config.ts now
prefers GAMECULT_IDUNN_CANDIDATE_BIND, requiring loopback and a usable
port, and falls back to HOST/PORT so developer runs and the current plain
unit are unaffected.

The part worth the care: publicBaseUrl defaulted to `http://${host}:${port}`,
and it builds the OAuth callback URL registered with each provider. Left
alone, a candidate would have advertised its ephemeral port as that
callback -- a URL that stops resolving the moment the candidate is
promoted or discarded, breaking logins in a way that would look like a
provider problem. Listening address and advertised address are separate
authorities, so GC_ACCESS_BASE_URL is now mandatory whenever a candidate
bind is present, and startup fails loudly rather than advertising a
disappearing port.

Six tests cover it, including that regression by name and rejection of
non-loopback, zero-port and malformed values. 41 pass across 10 files.

Recipe corrected while here: required_environment named
HEIMDALL_DATABASE_URL, which does not exist. The real name is
GC_ACCESS_DATABASE_URL, confirmed in src/config.ts and in
/srv/heimdall/env/service.env; that would have failed at first deploy.
HOST and PORT are dropped, since the candidate bind supersedes them under
Idunn, and GC_ACCESS_BASE_URL added as required.

The recipe's status banner now lists what is done and what is pending
rather than saying the contract is unimplemented. The write lease is the
remaining blocker: the state slot is process-bound-single-writer and the
service does not read the lease, so two generations could both open
heimdall.service.cc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:16:41 +02:00
Meta Vens
707da368b1 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>
2026-09-06 01:47:10 +02:00
Meta Vens
f2eec21c57 Bind auth completions to their attempt handles 2026-08-22 12:01:56 +02:00
Meta Vens
1086aee011 Give Heimdall Odin documents unique catalog keys 2026-08-22 03:47:02 +02:00
Meta Vens
1ba75b729a Serialize Heimdall Odin catalog publication 2026-08-22 03:40:55 +02:00
Meta Vens
eb1c3f539c Publish Heimdall access contracts through Odin 2026-08-22 03:30:56 +02:00
Meta Vens
37f7aab34e Label signed health with its schema 2026-08-22 02:50:40 +02:00
Meta Vens
960b15f0d4 Complete Idunn RUDP health handshake 2026-08-22 02:48:39 +02:00
Meta Vens
36488a5e58 Expose provider health public identity 2026-08-22 02:35:20 +02:00
Meta Vens
9065dfa43b Bind Ghostlight logout to exact session custody 2026-08-22 01:53:21 +02:00
Meta Vens
5d777245ea Add private Ghostlight session refresh 2026-08-22 01:27:28 +02:00
Meta Vens
19e5109cf4 Add Eve access plugin and private auth plane 2026-08-22 00:44:57 +02:00
Meta Vens
2a905354b9 Run signed health as a sequential daemon loop 2026-08-20 20:15:41 +02:00
Meta Vens
c94c498ec7 Keep signed health pulse authoritative for daemon liveness 2026-08-20 20:12:42 +02:00
Meta Vens
eac852511b Decouple signed health from Verse snapshots 2026-08-20 20:08:29 +02:00
Meta Vens
3c50cc6b26 Authenticate Heimdall health publications 2026-08-20 19:56:35 +02:00
Meta Vens
210e16220f Add Ghostlight Dungeon Heimdall profile 2026-08-19 00:39:09 +02:00
Meta Vens
758892db87 Publish Heimdall daemon boundary state for Idunn 2026-06-16 18:01:07 +01:00
Meta Vens
207d3b9c87 Add Bifrost Patreon support sync 2026-06-09 20:22:58 +01:00
Meta Vens
18e2bf812e Add Bifrost Heimdall app profile 2026-06-08 21:04:51 +01:00
Meta Vens
38ed95667a Advertise Heimdall semantic Verse addresses 2026-06-04 23:58:56 +01:00
Meta Vens
5a06957a0d Add read-only Heimdall Eve witness advertisement 2026-06-03 03:21:31 +01:00
Meta Vens
f0d40e80a5 Allow configured Spotiverse portal callbacks 2026-06-02 19:25:14 +01:00
Meta Vens
e2a2f20693 Add Spotiverse Spotify credential custody 2026-06-02 16:25:38 +01:00
Meta Vens
ee8c012c85 Refresh Repixelizer app claims through Heimdall 2026-05-20 13:43:53 +01:00
Meta Vens
f3e1f9c292 Refresh managed provider credentials on resolve 2026-04-29 17:24:44 +01:00