Endpoint, client-auth method (RFC 7591 names) and rotation now live on
ProviderDescriptor; requestProviderTokens is the only code that talks to a
token endpoint. Failure labels are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 7, S7-3 and S7-4, P7-3 and P7-5.
Columns (S7-3): every declared column must be under a deterministic
collation. A case-insensitive provider_user_id whose key index compares
under "C" passed, and the store's own lookups then matched other users'
ids.
The check becomes checkSchemaRequirements(pool, requirements), with
Heimdall's store calling it with HEIMDALL_SCHEMA_REQUIREMENTS (its tables,
columns, keys and privileges). That seam lets a live test check a
predicate Postgres prints differently from how it is written (a literal
gains its cast): the check must still pass, so comparing the raw text
instead of the printed form (P7-3's Y1) now fails.
A role that may not create temporary tables was refused with 42501 and
told to check GC_ACCESS_DATABASE_URL_FILE (P7-5). The predicate printer now
refuses it as SCHEMA_TEMP_PRIVILEGE, and startup says to grant the role
TEMPORARY on its database, in fixed text.
New fixtures (S7-4): a case-insensitive collation on a key's first column
(Y3), NOT NULL added to a nullable column of linked_identities (Y5), and
every privilege on auth_completions except on attempt_id (Y8) are refused;
a key index that INCLUDEs an undeclared case-insensitive column starts
(Y4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 7, S7-2. The out-of-scope list named CHECK constraints, triggers
and row-level security. It now also names unique indexes beyond or
stricter than the required keys (including leftovers of an interrupted
concurrent build, which are enforced once ready), NOT NULL columns the
schema does not declare and that have no default, and column-level SELECT
under SELECT * or RETURNING *, which needs every column of the table.
Documentation only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mutations dropping indisvalid or indisready each survived alone,
because a failed CREATE INDEX CONCURRENTLY clears both. Postgres never
marks an index valid before it is ready (DROP INDEX CONCURRENTLY clears
validity first), so indisready adds nothing and is deleted. A fixture now
holds an index that is ready but not valid, the state an interrupted DROP
INDEX CONCURRENTLY leaves, set directly in pg_index by the admin role.
Comparing the raw declared predicate instead of Postgres's printing of it
survived too: the schema's one predicate splits into the same conditions
either way. A live test now pins printedDeclaredPredicates to the printed
predicate of the applied attempt index, so the printing itself is
checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store's coverage test lists every method on PostgresStore as an
operation the workload must call, and the private predicate printer
counted as one. It is a helper of checkSchema, not a store operation, so
it becomes a function of the pool.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`collation` is reserved in Postgres, so the key check's query failed with
42601 on every database and every start refused. The live block showed it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule mutation that stopped checking a named unique index by its name
survived, and rightly: an index of the same table, key columns, predicate,
uniqueness and immediacy keeps R21.1 whatever it is called, and the named
index's existence is already required as a relation. The name condition
and REQUIRED_KEYS' index field are deleted.
The live block failed 5 of 10 repeated runs with unhandled 57P01 errors:
a test's own pool, already ended, still had connections closing when the
forced drop terminated them, and nothing listened. Every pool the tests
open now goes through fixturePool, which ignores those errors; the store's
own pool keeps its listener and is not a fixture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 5, S5-3: auth_completion_created stored completion.code, which
the unauthenticated redeem route turns into the session until it expires
(300 s by default), so the audit trail could redeem a live sign-in. The
two auth_completion_redeemed events (the HTTP redeem route and the private
command plane's redeem by attempt) stored the spent code as well.
All three payloads drop it. Nothing reads audit payloads back: the store
has no audit read, and no repository indexed by VoidBot consumes these
events. The event's sessionId already names the completion (one per
callback), so no replacement key is added. This matches Heimdall's own
witness contract, which says codes are redacted from audit exports.
Tests drive a sign-in through the HTTP redeem route and through the plane,
pin each payload exactly, and check the code appears in no audit event.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The startup check's refusal left the store's pool open: the process exits
on it in production, but the connection outlived the refusal. createStore
now closes a store it refuses.
Two live cases the first run showed undefended (mutants D6 and D9): a
unique key replaced by a plain index on the same columns, and by a unique
index on other columns of the same count. The read-only-role case also
checks that no session of the role outlives the refusal. The fixture's
owner pool ignores the connection ends its own forced drop causes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store's ON CONFLICT clauses and the foreign keys need a unique,
non-partial index on exactly the key's columns; whether it is the primary
key changes nothing a request does. Requiring the primary flag added a
refusal no failing request motivated, so it is dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 4, F2: the route stored Bifrost's whole response body in its
audit event and returned it to the caller, as `detail` on a refusal and as
`bifrostResponse` on success. Another service's body can quote the signed
fact or its own configuration.
Both audit events now carry bifrostStatus and no body; a refusal answers
502 with fixed text and Bifrost's status; success answers bifrostStatus in
place of the body, which no caller reads (Bifrost's docs name only the
route). The body is discarded unread.
Tests drive the route against a Bifrost that answers 200 and 403 with the
canary in its body, and pin the reply and the audit event exactly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same class as Soul pass 4's F1: deliverBackendHandoff put the receiving
app's whole response body into its error message. The handoff carries
tokens and an app's error page can quote it, and the message reaches
whatever catches the error. It now names the status alone and discards the
body unread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 4, F1: a provider's 2xx refresh answer that was not JSON made
JSON.parse quote the text around the fault, a refresh-token fragment among
it, and the plane returned that message in the reply's diagnostics. Those
travel outside the sealed envelope and Ghostlight logs them.
The plane's diagnostic is now fixed text plus the error's class and code,
through errorIdentity, which the failed-callback audit event now shares.
Every provider body Heimdall parses goes through readProviderJson: a
non-2xx answer or a body that is not JSON becomes a ProviderResponseError
(status and code, provider_status or provider_body_not_json) and no
parser message survives. This replaces ProviderHttpError and the separate
parses in the Discord guild lookup and the YouTube channel lookup.
Soul's canary probe is committed inverted: a refresh against a malformed
2xx body and against a 400 whose body holds the canary; no byte of it may
appear in the reply or its decoded failure payload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Self's ruling: the failed-callback audit event lost the provider's status
when it stopped storing the message. A provider's non-2xx answer is now a
ProviderHttpError carrying the status, and its message no longer holds the
response body, which nothing needs and which can quote the request. The
audit event adds providerStatus: an integer in 100-599, or null.
Tests drive the real Discord token exchange against a 401 whose body holds
the canary, and throw a status of 9999.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typecheck under exactOptionalPropertyTypes refused the relay commit: a
provider error that filters down to nothing left errorDescription
explicitly undefined in the page and handoff payloads. The field is now
added only when a relayed value remains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Self's ruling: the audit event stored error.message, and pg or resolver
text can carry fragments of a database URL. It now stores the error's
class name and its code, each checked against a narrow pattern. A test
puts the canary in the host of an ENOTFOUND error and checks that no
audit event holds it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Self's ruling: the provider_error relay stays, because it is the protocol,
but its error and error_description are query parameters anyone can write.
Only the characters RFC 6749 4.1.2.1 allows in them survive (%x20-21,
%x23-5B, %x5D-7E), and each is cut to a bound (64 and 256). The JSON
answer, the browser page and the backend handoff all take the relayed
values.
The page has no template engine. Every value in it goes through
serializeForScript, which escapes `<`, apart from the fallback URL in the
link's href, which the URL serializer percent-encodes. A test puts
`<script>` in error_description and checks the page holds only its own
script tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
A mutant that put the message back into the non-validation 4xx answer
survived: Fastify 5.8.5's own 4xx errors (invalid or empty JSON body, 415,
413) carry fixed messages, so the JSON-parse and media-type cases could not
tell. The branch protects against any error thrown with a 4xx status, whose
message is whatever its thrower wrote. A test route now throws one with the
canary, and the answer must be the code alone.
The handler's comment and the earlier commit's message said JSON.parse and
the 415 quote input; in this Fastify they do not. The comment is corrected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 3 (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>
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>
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>
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>
Soul S3: GC_ACCESS_SIGNING_PRIVATE_KEY_PATH is now the only way the signing
key gets in, and its errors printed the path: the missing-file message
interpolated it, and fs errors from the read and the bootstrap mkdir carried
it in their message and properties. An operator moving off the deleted inline
PEM input who binds the key text to _PATH would have the private key written
to the journal.
The path now takes the secret reader's rules. loadConfig refuses a relative
value, which PEM text always is. Every signing-key failure is a fresh error
naming only the variable and a code, with no cause and no properties.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul F1: the unreadable-file error carried the NAME_FILE value, and Idunn
admits any string for a declared name, so a URL bound into
GC_ACCESS_DATABASE_URL_FILE by mistake was printed in full at startup. Errors
now name only the variable and an error code, and a NAME_FILE that is not an
absolute path is refused naming the variable alone.
Soul F2 and F6: a blank database URL file selected memory storage silently,
and a whitespace one selected postgres with a URL of " ". The reader now
refuses an empty or whitespace-only value from either form, so custody's own
"set but empty" check is redundant and is deleted; a short key still fails its
length check. The legacy service.env holds no blank secret-class value
(checked as name/blank booleans), so the legacy unit is unaffected.
Soul F3: tests pin that an empty NAME_FILE is refused rather than unset (S1),
and that each call site (token key, provider secret, app shared secret,
database URL, patron secret) refuses both forms, so no site can read the
plaintext name ahead of the reader (S6, S7, S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GC_ACCESS_SIGNING_PRIVATE_KEY_PEM put the signing key's bytes into the
process environment, and so into any binding, unit or plan that carries
environment. The recipe never declared it and GC_ACCESS_SIGNING_PRIVATE_KEY_PATH
already covers the key as a file, which a binding binds through
[workload.secret_files]. The signing key is now always a file.
Idunn secret-files cut, Cut 1 (delete first).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The runtime app-registration writer (POST /v1/apps) was deleted in an
earlier pass because it minted a client_secret nobody verified. This
pass kept resolveAppProfile's registered-app fallback and
listAppProfiles on the theory that AppSlug is an open string and
dynamic registration was a live extension point. Soul falsified that:
every HTTP route enumerates the four built-in slugs in its own JSON
schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body
field), so a registered row could never be resolved from a request.
With the writer already gone, this was unreachable code behind a dead
writer -- delete it rather than keep authenticating a door nobody can
open.
Deleted: src/app-registry.ts entirely (resolveAppProfile,
listAppProfiles, isBuiltInApp, registeredAppToProfile,
returnOriginsFromRedirectUris); StoredRegisteredApp and the
findRegisteredApp/listRegisteredApps HeimdallStore methods
(store/types.ts); their InMemoryStore and PostgresStore
implementations, including seedRegisteredApp and mapRegisteredApp; the
registered_apps table (store/schema.ts now drops it on any store built
before this schema); tests/app-registry.test.ts.
Every call site (app.ts x5, claims.ts, private-command-plane.ts) now
calls the existing built-in-only getAppProfile (app-profiles.ts)
directly -- sync, no store needed. If dynamic app registration is
wanted later it returns as a designed, authenticated feature, not by
resurrecting this vestige.
Net -305 lines against the prior commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The symbol brand on AppCaller stopped a TypeScript importer from
writing the `{ appSlug }` literal, but nothing enforced it at
runtime: no handler inspected the branded field, so a cast
(`as unknown as AppCaller`) reached refreshAppSession/startOAuthFlow's
caller gate with a forged value carrying the right appSlug. A brand
that claims an authority it does not enforce is worse than none --
this closes that gap by making the brand real instead of documenting
what it does not do.
brandCaller (app-caller.ts) now registers every AppCaller it mints in
a module-private WeakSet; isAppCaller is the only way a consumer may
trust one. startOAuthFlow and refreshAppSession call isAppCaller(caller)
instead of trusting the AppCaller | null type alone at their two gate
checks.
Flips Soul's forged-caller it.fails to a passing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The public /v1/oauth/:provider/start route read handoff.attemptId
straight out of the request body and folded it into the signed state
token unconditionally. Since the private command plane's beginAuth
mints an attempt handle server-side and later redeems by that handle
(consumeAuthCompletionByAttempt), anyone who learned a pending handle
could carry it through their own OAuth flow and bind their own
identity to it -- the app's completeAuth(H) would then authenticate
the victim's session as the attacker. Same family as the takeover
chain closed in the prior pass: a caller-chosen identifier trusted as
authority.
normalizeOAuthHandoff now always strips attemptId from a
browser_completion handoff; a public caller has no attempt to
correlate and gets its completion code directly. The only path an
attempt handle can reach the state token is startOAuthFlow's new
trustedBrowserAttemptId option, which is a TypeScript function
argument set by beginAuth from the handle it just minted -- never a
value parsed out of a request body.
Also adds the uniqueness constraint the by-attempt redemption needed:
a partial unique index on (app_slug, attempt_id) WHERE attempt_id IS
NOT NULL AND consumed_at IS NULL, mirrored in InMemoryStore. Without
it, consumeAuthCompletionByAttempt's UPDATE could match more than one
row and silently discard all but the one it returned.
Flips Soul's attempt-handle-binding it.fails to a passing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AppCaller used to be a plain { appSlug } interface, so any module could
construct one structurally — only grep discipline stopped an importer
from forging a caller. It is now branded with an unexported Symbol;
the only two constructors are resolveAppCaller (HTTP, unchanged) and
the new callerFromOpenedEnvelope (private command plane). The plane's
two `{ appSlug }` literals in refreshAuth/beginAuth are gone.
completeAuth also stops forging an HTTP self-call
(app.inject(".../auth-completions/redeem", { completionCode: handle }))
that only worked because handle used to equal the completion code. It
now redeems directly through store.consumeAuthCompletionByAttempt and
reproduces the audit event / attempt-consumed transition the HTTP route
does, since it is no longer routing through that HTTP handler at all.
The two ghostlight-only branches (`appSlug === "ghostlight"` requiring
a discord_role_access policy) are replaced by
requireEntitlementPolicyIfProfileDemandsIt, which reads
AppProfile.requiredEntitlementPolicyKind through resolveAppProfile —
the same profile-resolution path the rest of the app uses, so a future
app with the same requirement declares it in its profile instead of
adding another branch here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AppProfile gains allowedReturnOrigins (repixelizer/streampixels/bifrost
-> <slug>.gamecult.org, ghostlight -> yggdrasil.gamecult.org per
docs/app-profiles and the existing test fixtures) and an optional
requiredEntitlementPolicyKind, replacing the private command plane's
`appSlug === "ghostlight"` branches with data a future app can declare
for itself. A registered (non-built-in) app's allowedReturnOrigins is
derived from its stored redirectUris' origins — the same shape the
deleted registration validator used to check, now put to use instead of
only being validated at intake.
app-registry.ts also sheds the registration-only surface:
validateAppRegistration, AppRegistrationRequest, RegistrationProblem,
and AppRegistrationError deleted (the previous commit removed their
only writer, HeimdallStore.registerApp). resolveAppProfile/
listAppProfiles/isBuiltInApp are unchanged in shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createAuthCompletion no longer takes `code: handoff.attemptId` — the
store mints it (previous commit); attemptId is passed through only as
the new `attemptId` correlation field. The redeem route's
updateAuthAttempt call used request.body.completionCode as if it were
the attempt handle, which only ever worked by the coincidence that the
private plane's own flow had code === handle; it now uses
completion.attemptId, the real correlation.
startOAuthFlow also refuses any returnTo whose origin is not in the
target app's AppProfile.allowedReturnOrigins (400
return_to_not_allowed). Before this, returnTo was validated only as
`format: uri`, and the browser handoff page postMessages the
completion payload to new URL(returnTo).origin — an unlisted origin
was an exfiltration primitive for whatever the browser flow hands
back, completion code included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The completion code's `code?` input let a caller choose it; app.ts used
that to make handoff.attemptId become the code verbatim, which is the
account-takeover primitive Soul's probes proved. The store now always
mints the code itself. attemptId survives only as a separate
correlation field (StoredAuthCompletion.attemptId /
CreateAuthCompletionInput.attemptId), and a new
consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets a caller
that already knows the attempt handle (the private command plane)
redeem the completion it created without ever touching the code.
Also deletes HeimdallStore.registerApp: the runtime app-registration
HTTP surface that minted it is already gone from this branch, and
nothing else in the codebase writes to registered_apps (it sits empty
on the live host). findRegisteredApp/listRegisteredApps stay: AppSlug
is a plain string (contracts.ts), so registered_apps remains the real
extension point for a slug Heimdall was never rebuilt to know about; a
row is now provisioned by direct SQL, or InMemoryStore.seedRegisteredApp
in tests, not by any app-facing writer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
registerApp minted a client_secret whose hash nothing ever verified (six
routes gate on the shared-secret caller-identity check, not on registration);
registered_apps sits empty on the live host. Deleted: POST /v1/apps,
getRegistrationSecret, config.appRegistrationSecret, registerApp/
hashClientSecret/verifyClientSecret, the client_secret_hash column (dropped
idempotently on startup, same as it is created), and
tests/app-registration-endpoint.test.ts. registered_apps and resolveAppProfile
stay as a profile store; app-registry.test.ts fixtures that used registerApp
purely to seed a profile now write through store.registerApp directly, the
same primitive the built-in-shadow test already used.
Per-app identity with a global fallback made repixelizer and streampixels one
identity in practice. Each app now needs its own
GC_ACCESS_APP_<SLUG>_SHARED_SECRET; operator mints distinct values on
Yggdrasil in the same deploy window. Doc note in service-contract.md corrected
to stop advertising the fallback.
acceptsBackendCallback is now a pure per-app env allow-list lookup;
Repixelizer's callback must be configured via
GC_ACCESS_APP_REPIXELIZER_BACKEND_CALLBACK_URLS like every other app (R8:
operator sets this on the running legacy body before the new build starts).
Test config adds the allow-list entry so existing Repixelizer flows keep
passing, and a new negative test pins that the slug name alone buys nothing.
R1: no GC service calls this route. Its shape (caller-supplied facts handed
straight to issueAccessClaim) is a second entitlement authority even when
gated, so authenticating it would preserve the oracle behind a password.
Deletion-only commit: route, discovery advertisement, mapper, contract type,
positive test, and doc mentions in architecture.md/service-contract.md/
fresh-workspace-handoff.md.
Moving the CultLib pin forward to pick up the presence contract also brought
ten unrelated commits, one of which republished the TypeScript packages under
the @gamecult scope. Two things broke on that rename.
The recipe selected build workspaces by package name, so `--workspace=
cultcache-ts` no longer matched anything. Selecting by path instead says what
is actually meant and survives the next rename.
cultnet-ts depends on cultcache-ts by registry range, not by workspace
protocol, so the vendored copy is only used because Heimdall overrides that
name to the vendored path. The override still named the old one, and pnpm went
looking for @gamecult/cultcache-ts on a registry that does not have it.
Heimdall now uses the package's real name in its dependency, its override and
its imports.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Heimdall published `idunn.signed_daemon_health.v1`, the previous generation's
contract. Odin admits a document as runtime presence only when it carries
`gamecult.runtime_presence_health.v2` and its record key equals the signed
target; everything else it stores as an ordinary document. So Heimdall's
health arrived, was filed away, and no topology correlation for it was ever
produced -- which is what held every deployment at Warming.
The statement is dual-proved, so the process must also prove it is the
incarnation Idunn started, using the activation key passed as a descriptor
beside the presence identity. Every field describing the launch is read from
the runtime bundle rather than configuration.
Two things had to be fixed for that to be possible at all:
The descriptor lookup compared LISTEN_PID to our own pid. Under PrivatePIDs
systemd sets it to a pid in the outer namespace, so it never matched, and
because the lookup answers "undefined" rather than throwing, Heimdall quietly
self-enrolled a key nothing trusts -- exactly the failure its own comment
warns about. The name in LISTEN_FDNAMES identifies the descriptor.
The publisher sequence was an in-process counter starting at zero every boot.
Idunn treats a repeat or regression as a replay, so a restart would have made
every later statement refusable; it now lives beside the target's state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
App slugs were a compile-time union of four names. That was fine while every app
was ours and it blocks what Heimdall is for: an app it has never heard of,
running on someone else's machine, registering against a self-hosted instance
without forking and rebuilding the auth authority. A creator self-hosting
Erycina alongside Heimdall would otherwise maintain a patched auth server
forever.
AppSlug is now a string, which it always was on the wire; the union was a
narrowing the type system could enforce only because the set was closed.
Membership moved from appSlugs.includes to the registry, and where no store is
reachable — the offline token verifier is the case that matters — it is a shape
check, isAppSlug, rather than a false claim to know what exists. A host app
verifying its own tokens should compare aud against its own slug, which it knows
and Heimdall's built-in list does not.
Registration is data end to end. Storage carries the profile and a hash of the
client secret rather than the secret. Built-in profiles resolve first and their
slugs are refused at registration, in both places, so a registration cannot
shadow a shipped app and inherit its audience.
POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library
can read it, returning client_secret exactly once. It is gated on an operator
secret and closed entirely until one is configured: an open registration
endpoint on an OAuth provider lets anyone create a client that starts Discord
and Patreon flows under the instance's identity, which is a phishing surface
wearing the operator's name. A fresh instance is not accidentally open.
Redirect URIs are validated rather than stored as given. They are where an
authorization code is delivered, so a permissive one is a token-theft primitive:
absolute, no fragment, https unless loopback.
Six call sites assumed an app profile always exists. They now answer 404 for an
app nobody registered, and claim issuance refuses outright rather than signing a
token whose audience no verifier can resolve.
108 tests pass, 34 of them new. Zero type errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every app profile carried an evaluateSharedCapabilities function beside a
sharedRule string describing what that function did. Two representations of one
rule, free to drift, and the reason a profile could never arrive at runtime: you
cannot accept a function from a stranger and run it inside the auth authority.
The rules were never more than a disjunction. Every sharedRule across all four
built-in profiles is "a || b || c" over a closed vocabulary, and one of them is
a bare reference to another capability. So the executable form is a list, not an
expression language, and anyOf is now the only representation. The readable form
is derived from it rather than stored beside it.
Evaluation runs to a fixed point because capabilities may reference each other:
repixelizer's queue_submit derives from app_access, and a single pass grants it
only when the rules happen to be declared in dependency order. It terminates
because the granted set only grows and is bounded by the rule count.
validateCapabilityRules is the gate that makes runtime registration acceptable.
A term must name a fact Heimdall itself produces or another capability in the
same profile. A registered app selects from what Heimdall has already decided is
true about an account; it cannot invent a fact to grant itself something, and it
cannot reach into another app's capabilities. An unknown term is refused rather
than treated as false, because a rule that silently never fires becomes a
support ticket instead of an error.
The four built-in profiles are now seed data rather than special cases: a
registered app produces the same shape and runs through the same evaluator.
Behaviour is unchanged. 74 tests pass, 18 of them new, covering derived
capabilities in both declaration orders, hybrid capabilities never being granted
from shared rules, and every rejection path in the validator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first release that got far enough to report its own failure said
EADDRINUSE 127.0.0.1:4101: the CultNet private command plane. I gave the
HTTP listener a candidate bind and never noticed Heimdall has a second
listener on a fixed port, so the candidate collided with the incumbent and
died before it could warm.
A fixed private port makes coexistence impossible, which is the premise the
whole warm-then-cutover rests on. The private plane belongs to the same
generation as the HTTP listener, so under Idunn its port is derived from
the assigned candidate port with a fixed offset. Each generation gets its
own pair, and the published command-boundary endpoint stays truthful
because it reads the same config value. Outside Idunn the configured port
is unchanged.
The offset is large enough that a derived port cannot land inside the
route's candidate range and collide with another generation's HTTP
listener.
57 tests, 56 pass and the machine-id-bound one skipped on Windows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first sealed-release launch exited 1 with no stdout, no stderr and
nothing in the journal. The catch around startup logged through app.log,
which is pino: it buffers, and setting process.exitCode lets the process
exit before the buffer flushes, so the error was written nowhere.
Under a supervisor that is the worst shape a failure can take. Idunn
reported only "systemd unit ... is not running", the journal held two
systemd lines and nothing else, and the actual cause -- whatever it is --
was destroyed by the logger meant to record it.
Startup failures now also go to stderr directly, which systemd captures
synchronously. Every earlier failure in this deployment printed a stack
trace precisely because it happened before the logger existed; the ones
after it were silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>