Commit graph

214 commits

Author SHA1 Message Date
Meta Vens
827cdee761 Tests/docs: promote Soul's two it.fails, update the tripwire and fixtures
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>
2026-09-08 14:23:04 +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
4e8ad3d348 Add Soul falsification probes for the caller-identity cut
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>
2026-09-08 13:53:09 +02:00
Meta Vens
a7d1d2abbf Correct service-contract.md: entitlement policy trust is uniform, not per-slug
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.
2026-09-08 13:53:09 +02:00
Meta Vens
4aaf640f60 Update tests for uniform caller-identity gating (R5, fork 1B)
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).
2026-09-08 13:53:09 +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
8e6bc83ce6 Assert descriptor selection instead of reading a live descriptor
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>
2026-09-08 13:52:47 +02:00
Meta Vens
f01c58cc2a Name the Linux deploy target for the auth authority
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>
2026-09-08 12:23:44 +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
edef7f1352 Record the runtime app registration cut in the evidence ledger
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>
2026-09-07 00:12:22 +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
7c17902b1b Ship the interpreter inside the sealed release
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>
2026-09-06 22:07:47 +02:00
Meta Vens
549bbdb6a1 Declare the environment Heimdall actually needs
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>
2026-09-06 19:57:04 +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
c931775ad1 Bundle the service; ship one executable instead of a dependency tree
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>
2026-09-06 16:51:59 +02:00
Meta Vens
1eca89f6e5 Try the pnpm 10 config-flag form for the hoisted linker (still unresolved)
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>
2026-09-06 16:48:35 +02:00
Meta Vens
ac98df4af9 Install with a hoisted node_modules so the release can be sealed
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>
2026-09-06 16:44:17 +02:00
Meta Vens
9b727abe4d Run pnpm through corepack in the build runner
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>
2026-09-06 16:43:10 +02:00
Meta Vens
ab18e8428d Give the build runner a writable cache
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>
2026-09-06 16:30:21 +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
5a15f711e8 Propose a public surface boundary; record the orphaned spotify provider
Deployment topology already says same-host apps reach Heimdall over
localhost or private routing and verify claims locally. The vhost never
implemented it: heimdall.gamecult.org.conf is one
`location / { proxy_pass http://heimdall_app; }`, so every route Fastify
registers is on the open web. The boundary existed in this document and
nowhere in the running system.

Proposed: public is the browser-driven OAuth pair plus JWKS; discovery,
/v1/apps, claim issuance and /healthz go internal. The private command
plane is unaffected -- it is a CultNet operation server and never touches
nginx.

JWKS stays public deliberately rather than by omission. No provider
fetches it (every adapter in src/oauth.ts uses client_secret, not
private_key_jwt) and every relying party today is mesh-reachable, so
nothing external needs it. But publishing verification keys is harmless,
and hiding it fails asymmetrically: the first externally-hosted relying
party gets integrated with a standard OIDC library, and a jwks_uri that
resolves only on the mesh fails confusingly at integration time instead of
obviously at design time.

Discovery and /v1/apps move together, since trimming discovery alone
leaves /v1/apps serving the same catalog. That catalog is a product
inventory: per app it emits identityProviders, entitlementSources,
managedConnectionProviders and capabilities, and with the command plane's
discord_role_access and patreon_membership_access record kinds the
monetization model is legible to anyone who fetches it.

Separately recorded: the spotify provider outlived its consumer.
Spotiverse was lost with the E: drive, it is the only profile naming
spotify, and GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID/_SECRET are still
configured in /srv/heimdall/env/service.env while /v1/oauth/spotify/start
stays publicly reachable. That path would run a real OAuth handshake with
a real secret and take custody of a user's refresh token for a consumer
that cannot use it. Not a compromise; a live credential and a
token-custody path kept alive for nothing. Retiring it is its own change
and should not ride along with the boundary work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:34:53 +02:00
Meta Vens
96512ddabd Make AGENTS.md paths repo-relative so a drive move stops rotting it
Every path in Important Paths, Useful Commands, and External Context named the
lost E: drive. CLAUDE.md is a bare @AGENTS.md import, so those dead paths were
loaded into every session in this repo.

Swapping E: for F: would only reset the timer, so the paths are now
repo-relative and gamecult-ops is named as a sibling repo rather than an
absolute location. All ten referenced files were confirmed present after the
move; none were lost with the drive.

The interpreter guidance was worse than stale. It pointed at a venv under the
dead drive, and the documented fallback did not work either: this workstation
had no Python at all, and both `python` and `python3` resolved to Microsoft
Store aliases that print an install prompt and exit 0. Python 3.13.15 is now
installed to the user scope and ahead of WindowsApps on PATH, both tools were
run under it, and the section documents that failure mode so the next agent
installs an interpreter instead of hard-coding a path to someone else's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:13:19 +02:00
Meta Vens
62a1560f2a Add Heimdall's Idunn recipe; it declares a contract the service must still implement
The recipe validates. It encodes the build sequence that previously lived
only in the host's shell manifest -- npm ci in vendor/CultLib, then build
cultcache-ts, cultnet-ts and cultmesh-ts, then pnpm install, test, build --
so the source seal now covers how Heimdall is built, not just what from.

Artifacts are dist/ plus the production node_modules, because the unit runs
dist/src/index.js and Node resolves imports at runtime; the release is not
the compiled output alone.

Marked NOT YET DEPLOYABLE in the file, because validation only proves the
declaration is well-formed, not that the service honours it. src/index.ts
binds config.port (4100, fixed), and `grep -r GAMECULT_IDUNN src/` returns
nothing. Warm-then-cutover needs a candidate to bind where Idunn tells it
via GAMECULT_IDUNN_CANDIDATE_BIND, because candidate and incumbent run at
once under distinct UIDs and namespaces -- a service pinned to one port
cannot warm alongside itself. The process write lease and runtime bundle
are declared and unread too.

Odin, Ghostlight and CodexConnector all read these variables; every target
with a recipe implements the contract. Heimdall publishes Idunn health
already via src/idunn-rudp-health.ts and stops short of the rest. That
implementation is the remaining work, and it is app code rather than
deployment configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 00:44:34 +02:00
Meta Vens
214ccba85c Vendor CultLib as a gitlink so Heimdall's source can be sealed
Heimdall took CultLib as file:../CultLib/packages/*, a sibling checkout
outside this repository. Nothing pinned which CultLib a given Heimdall
commit was built against, and a sealed source could not reach it. The
deploy manifest worked around this by cloning CultLib separately to
/srv/build/CultLib with cultlib_commit=5cefa0db hardcoded in shell -- the
pin existed, it just lived in an untyped place where no build could see it.

vendor/CultLib is now a submodule pinned at exactly 5cefa0db, the commit
the manifest already used, so this change is purely structural: the
dependency does not move. Bumping to a current CultLib is a separate
change, and should be, or a failure could not be attributed.

This also uncovered that the build was never reproducible from a clean
checkout. The sibling directory had dist/ present because someone had
built it by hand at some point; a fresh CultLib clone has none, and the
packages' main/types point straight at dist/. Building Heimdall against a
fresh CultLib fails with thirteen TS2307s. The manifest knew -- it runs
npm ci then npm run build for cultcache-ts, cultnet-ts and cultmesh-ts
before touching Heimdall -- but that knowledge lived only in the shell
script. It belongs in the recipe's steps, which is where it goes next.

Verified end to end on a fresh submodule clone: the three CultLib packages
build, pnpm install resolves, tsc exits 0, dist/src/index.js is produced
(the exact path heimdall.service runs), and vitest passes 38 tests in 9
files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 00:42:45 +02:00
Meta Vens
56a0a2a0ce Drop the Codex framing from the doctrine import
The import was added on 2026-09-03 as "shared with Codex". The estate has
since left Codex as a supplier and the global doctrine no longer maintains
a Codex twin, so that half of the sentence describes a reader that is not
coming. The mechanism is unchanged and still correct: AGENTS.md holds this
repo's doctrine, CLAUDE.md imports it, and neither is duplicated.

Renaming AGENTS.md across the swarm is a separate sweep and not worth
doing incidentally here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 00:39:45 +02:00
Meta Vens
bdedae88e5 Merge branch 'codex/heimdall-eve-access' 2026-09-06 00:39:36 +02:00
Meta Vens
73f4107ab3 Add MIT license
The repository was public but carried no license, so default copyright applied
and all rights were reserved. Readers could look at the code and had no right to
run, copy, modify, or self-host it. GitHub's terms grant forking within GitHub
and nothing past that.

This matters now because Erycina is being prepared for founding creators to
self-host, and its intended one-click deployment installs Idunn to bring up
Odin, Heimdall, and Erycina together. Erycina is MIT, and Heimdall alone owns
identity, grants, revocation, and signed entitlement claims: without a license
here a creator could take payment and have nothing entitled to mint the claim
that unlocks the media they sold.

MIT to match CultLib, VoidBot, Eve, and Odin, and the stated intent for GameCult
projects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:54:09 +02:00
Meta Vens
d1d28d6787 Wire AGENTS.md into Claude Code memory
Claude Code reads CLAUDE.md, not AGENTS.md, so this repo's doctrine was
invisible to it. This one-line import makes the existing AGENTS.md load
without duplicating or renaming it. Codex is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:23:00 +02:00
Meta Vens
f2eec21c57 Bind auth completions to their attempt handles 2026-08-22 12:01:56 +02:00
Meta Vens
b9ab8c0022 Resume Heimdall auth from browser return witness 2026-08-22 11:42:28 +02:00
Meta Vens
e9635038c5 Record Odin-discovered access deployment 2026-08-22 04:08:36 +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