Commit graph Heimdall/tests
Author SHA1 Message Date
Meta Vens
7de12e84be Relayed provider errors are printable, bounded and inert in the page
Self's ruling: the provider_error relay stays, because it is the protocol,
but its error and error_description are query parameters anyone can write.
Only the characters RFC 6749 4.1.2.1 allows in them survive (%x20-21,
%x23-5B, %x5D-7E), and each is cut to a bound (64 and 256). The JSON
answer, the browser page and the backend handoff all take the relayed
values.

The page has no template engine. Every value in it goes through
serializeForScript, which escapes `<`, apart from the fallback URL in the
link's href, which the URL serializer percent-encodes. A test puts
`<script>` in error_description and checks the page holds only its own
script tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:49:30 +02:00
Meta Vens
cad12165a9 A start that does not apply the schema proves the schema is there
Self's ruling on the open apply=0 item: connecting proves the URL, not the
schema, so a good URL to an empty database started and then answered every
request with a 500 (42P01).

PostgresStore.checkSchema replaces checkConnection. One query finds every
table and index CREATE_SCHEMA_SQL creates and every column it adds, and
anything missing fails with SCHEMA_MISSING, which startup reports through
the same fixed message. The required names are read from CREATE_SCHEMA_SQL
itself, so a new table or migration is required as soon as it is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:49:23 +02:00
Meta Vens
869802126c Pin the 4xx branch of the error handler with a thrown error
A mutant that put the message back into the non-validation 4xx answer
survived: Fastify 5.8.5's own 4xx errors (invalid or empty JSON body, 415,
413) carry fixed messages, so the JSON-parse and media-type cases could not
tell. The branch protects against any error thrown with a 4xx status, whose
message is whatever its thrower wrote. A test route now throws one with the
canary, and the answer must be the code alone.

The handler's comment and the earlier commit's message said JSON.parse and
the 415 quote input; in this Fastify they do not. The comment is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:37:11 +02:00
Meta Vens
ca2e78f00c Recipe test reads its secret names from the source
Soul pass 3 (R3): the recipe test's list of secret names was written by
hand, and its secret-shaped pattern missed TOKEN, API_KEY, PRIVATE_KEY,
SALT, CREDENTIAL and DSN. A new secret could be read in the source and
declared in plaintext in the recipe without a failure.

The test now takes the names from every readSecretInput call under src/: a
literal as written, the two computed names over the lists that compute
them, and a failure for any argument it cannot expand. Every one of those
must be declared as its _FILE and never by its plaintext name. The pattern
is wider, with two named exemptions: the signing key's path and its
bootstrap flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:30:18 +02:00
Meta Vens
c626a86d55 A lost idle Postgres connection no longer kills Heimdall
Soul pass 3 (R2): the pool had no 'error' listener. pg emits an idle
client's connection loss there (a database restart, a terminated backend),
so the event went unheard, the process died, and Node printed the pg Client
with its connection parameters.

createPostgresStore now listens and writes one line naming the error code.
The pool has already dropped the dead client and connects again on the
next query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:30:15 +02:00
Meta Vens
0f5ffd9e69 Every start connects to Postgres; pin the signing key's read refusal
Soul pass 3 (R1, R4). With GC_ACCESS_APPLY_SCHEMA_ON_STARTUP=0, startup
never opened a connection, so a misbound database URL surfaced only in a
request's error answer. createStore now connects on every Postgres start:
it applies the schema when asked and otherwise runs `SELECT 1`, and either
failure is the same fixed message naming GC_ACCESS_DATABASE_URL_FILE and a
code. The entrypoint harness gains that case with the canary in the host.

The harness also pins the signing key's read-failure branch with a key file
of mode 000. Root reads such a file, so the case runs the entrypoint as a
user a mode can refuse: the test process itself when it is not root, or
else the checkout's owner. It is skipped, with the reason in its title, on
Windows or as root in a root-owned checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:30:10 +02:00
Meta Vens
33afcbf364 HTTP error answers carry fixed text, never an error's message
Soul pass 3 (R1): with no error handler, Fastify answered a 5xx with the
error's message. A store failure under a misbound database URL reached an
anonymous caller as `getaddrinfo ENOTFOUND <host>` or `database "<db>" does
not exist`, and a misbound URL puts part of the password in either. The same
default quoted the request body in a JSON parse error (Node's JSON.parse
message carries the input), the Content-Type header in a 415, and the path
in a 404.

buildApp now sets both handlers. A 5xx answers `{ error: "internal_error" }`
and writes one stderr line naming the method, the route pattern and the
error code. A schema failure answers the failed rules by schema path and
Ajv message, which come from the route's schema. Any other 4xx answers
`invalid_request` with the error code. An unknown route answers `not_found`.

The app's own answers had two more echoes: `unknown_app` quoted the slug,
and a failed provider callback put the caught error's text in the browser
page and the backend handoff (the JSON answer was already fixed). Both are
fixed text now; the full callback error stays in the audit event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:30:06 +02:00
Meta Vens
376dfbd2c2 Startup database failure names the input and a code, not the URL's parts
The entrypoint harness showed pg printing a piece of the database URL file
on a failed startup: `getaddrinfo ENOTFOUND <host>` for an unresolvable host.
A password with an unescaped delimiter moves part of itself into the host or
the database name, and the resolver or server error then prints it. The only
thing keeping a malformed URL out of stderr was pg dropping Node's `input`
property (Soul S5).

createStore now turns a schema-preparation failure into one error naming
GC_ACCESS_DATABASE_URL_FILE and the error code, with no cause. The harness
cases expect that message, and a new case puts the canary in the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:46:22 +02:00
Meta Vens
2800d88d10 Pin the recipe's secret declarations, and declare every app secret file
Soul S2: no test read deployment/idunn/recipe.toml, so a plaintext secret
name could be declared again (mutant ME) or a published _FILE name dropped
(mutant MF) with the suite green.

tests/recipe-secrets.test.ts reads the service's declared names and asserts
that no secret-shaped name is declared except as _FILE, that none of the
secrets Heimdall reads is declared under its plaintext name, and that every
provider client-secret file the witness publishes, every published app
profile's shared-secret file, and the database URL, token key and patron
secret files are declared.

The app-profile rule found two gaps: the witness publishes the repixelizer
and streampixels profiles and Heimdall reads their shared-secret files, but
the recipe declared neither, so an Idunn binding could not configure them.
Both are declared as optional, as GitHub and Spotify were for Soul F5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:40:21 +02:00
Meta Vens
d73a26dfd1 Pin no-leak at the terminal: run the real entrypoint with canaries
Soul S1 and S5: the secret tests checked error.message, but Node prints an
error's cause and properties too, and nothing tested src/index.ts. Soul's
mutants MA (the read error given `{ cause: error }`) and MI (the startup
catch printing the config) both leaked a canary to stderr with the suite
green.

tests/entrypoint-secrets.test.ts spawns `tsx src/index.ts` with only the
given environment and fails on any byte of the canary on stdout or stderr.
Each case must also stop by itself, fail, and say its expected refusal, so
none can pass by never reaching its path. Cases: a URL in an absolute _FILE
slot, a _FILE under a regular file, a relative _FILE, both forms set, a
blank file, a short token key, four malformed or refused database URLs in a
real file (pinning today's pg, which drops Node's `input` property), five
signing-key failures including the bootstrap mkdir, and a listen failure with
every secret configured, which is the entrypoint catch that holds the config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:40:21 +02:00
Meta Vens
e35441e378 Pin the token-key length refusal custody now relies on
With custody's "set but empty" check deleted, the 32-byte length check is the
only refusal of a malformed token key, and StrykerJS showed nothing tested it
(forcing it off survived). A 16-, 31- and 33-byte key are each refused, and
the refusal does not carry the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:03:24 +02:00
Meta Vens
0178d071c1 readSecretInput: never print the path, refuse relative paths and blank secrets
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>
2026-09-30 13:50:53 +02:00
Meta Vens
9f0cd73299 Pin the newline and storage-selection rules StrykerJS found undefended
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>
2026-09-30 12:49:23 +02:00
Meta Vens
ac86352972 Read every secret input from a credential file named by NAME_FILE
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>
2026-09-30 12:30:57 +02:00
Meta Vens
e8e2832230 Resume browser sign-in by attempt id, not the completion code
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>
2026-09-23 20:50:28 +02:00
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
832a8eec68 Soul third-pass probes: R21.1 handle reachability, R21.2 brand at every consumer, partial unique index on both stores, R21.3 prototype-key profile lookup
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>
2026-09-08 15:04:49 +02:00
Meta Vens
5373e339af Narrow the built-in profile lookup so the branch typechecks
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>
2026-09-08 14:54:17 +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
9ebf38bead Soul attack probes: takeover chain, returnTo origin matrix, brand runtime, attempt-handle binding
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:35:57 +02:00
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
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
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
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
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
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
b9ab8c0022 Resume Heimdall auth from browser return witness 2026-08-22 11:42:28 +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
9065dfa43b Bind Ghostlight logout to exact session custody 2026-08-22 01:53:21 +02:00