Commit graph

214 commits

Author SHA1 Message Date
Meta Vens
eff5098c23 Schema check: the collation alias is not a reserved word
`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>
2026-09-30 20:58:29 +02:00
Meta Vens
d2ac9696c7 Recipe test: combined runs from accepted values, config literals as probes, fresh import
Soul pass 6, F5 and F6. The all-at-once run set every name to one probe
value and died in loadConfig's first lines, so reads gated on a specific
value (Z4) or on two values late in the function (Z5) were missed, and a
copy of process.env taken at module load (Z6) was never the recorder.

Each name now gets each probe value alone, and the probe values include
every string literal in config.ts; the combined runs set every name at once
to values loadConfig accepted for it alone. config.ts is imported afresh
while the recorder is process.env, so a module-scope capture records too.
The remaining limit is stated in the test.

failureOf in the app tests now prints the error with util.inspect as well,
so a non-enumerable cause carrying the body (Z3) shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:13 +02:00
Meta Vens
0444360733 Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
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>
2026-09-30 20:40:12 +02:00
Meta Vens
6c0c87669f Schema check: a unique key is matched by shape, not name; quiet fixture pools
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>
2026-09-30 19:30:40 +02:00
Meta Vens
fd98e36186 Recipe test: explore loadConfig's branches, refuse environment copies
Soul pass 5, S5-7. The recording environment saw only the reads an empty
environment makes, so a secret read on a branch a value opens (X6) was
missed, and a read from a spread copy of the environment (X7) was
invisible.

loadConfig now runs over many environments until no run reads a new
name: every secret found so far set in plaintext, then each name read so
far set to each of five probe values, then all of them set to one probe
value at once; a run that throws keeps the reads it made first. Copying or
enumerating the environment (the Proxy's ownKeys and
getOwnPropertyDescriptor traps) is recorded, and a test refuses it, since
reads from a copy cannot be seen. HMAC joins the secret-shaped backstop
pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:58:02 +02:00
Meta Vens
35f804f7ef Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.

S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
Meta Vens
963150f537 Audit events never carry a completion code
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>
2026-09-30 18:56:35 +02:00
Meta Vens
731ce08cfa Schema check: keys as the store relies on them, typed columns, per-table privileges
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>
2026-09-30 18:55:45 +02:00
Meta Vens
b5d7b6b0cc A refused store closes its pool; pin the key's columns and uniqueness
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>
2026-09-30 17:29:19 +02:00
Meta Vens
804f41f5f7 Pin that no provider error carries the provider's body
F1's second half, tested where it is decided: the Discord token exchange
(a 2xx body that is not JSON, and a 401), the Discord guild lookup and the
YouTube channel lookup each fail with a ProviderResponseError whose code
and status name the failure, and no printed form of the error (message,
stack, JSON) holds a byte of the canary body. Until now only the callers'
fixed answers were pinned, so a body in the message went unseen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:16:39 +02:00
Meta Vens
7be541467d Schema check: a key is a unique index on its columns, primary or not
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>
2026-09-30 17:16:21 +02:00
Meta Vens
8541ae3f2f Startup proves the database can take Heimdall's writes
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>
2026-09-30 17:15:34 +02:00
Meta Vens
48915317ef Recipe test observes the secrets loadConfig reads
Soul pass 4, F3: the recipe test found Heimdall's secrets by parsing
readSecretInput calls out of the source, and an alias, a parenthesised
first argument or a colliding local name hid a new secret from it (S1-S3).

The test now runs the real loadConfig against an environment that records
every name looked up, standing in for process.env as well, and takes each
`_FILE` lookup as a secret: readSecretInput makes one per secret, however
the call is spelled and whatever list computes the name. Each must be
declared as `_FILE` in the recipe and never under its plaintext name. The
source scan and its table of computed names are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:09:20 +02:00
Meta Vens
2e06678301 Patron support sync keeps Bifrost's answer by status alone
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>
2026-09-30 17:08:29 +02:00
Meta Vens
b5b980578c Backend handoff failure names the status, never the app's answer
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>
2026-09-30 17:07:45 +02:00
Meta Vens
698f5dfc0e Private command diagnostics carry class and code, never a message
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>
2026-09-30 17:07:16 +02:00
Meta Vens
efd11373b8 Audit event keeps a provider's HTTP status, never its body
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>
2026-09-30 16:03:57 +02:00
Meta Vens
f94729f5f7 Relay's error description is set only when one survives
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>
2026-09-30 15:50:58 +02:00
Meta Vens
7c14414cd2 The failed-callback audit event keeps the error's class and code
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>
2026-09-30 15:49:32 +02:00
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
83d2f48256 Docs: secrets are _FILE names; drop the unread Spotiverse secret
Soul S4: the Bifrost handoff notes, the Bifrost profile and the service
contract still named plaintext secret variables; each now names the _FILE
credential path. .env.example and the Spotiverse profile taught
GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET_FILE, which nothing reads: spotiverse
is not in appSlugs. Both lines are removed rather than adding the slug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:40:21 +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
e091bb3fd1 Signing key path: absolute only, and no error carries the path
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>
2026-09-30 14:40:07 +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
fe26208f05 Docs and .env.example teach secrets as NAME_FILE credential files
Soul F4: the architecture configuration list, the Spotiverse restore steps and
.env.example still taught plaintext secret variables. The architecture list now
names only what src/config.ts reads, plain settings apart from secret files,
and drops the names nothing reads (GC_ACCESS_SESSION_SECRET, GC_ACCESS_ENABLED,
GC_ACCESS_INTERNAL_URL, the Discord bot token, the Patreon campaign id and
two TTLs). .env.example comments out the _FILE lines, because an empty
NAME_FILE= is refused at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:52:11 +02:00
Meta Vens
f8ff2b26ba Declare the GitHub and Spotify client-secret files the witness publishes
Soul F5: the provider witness names GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE
and GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE, but the recipe declared
neither, so an Idunn binding could not configure either provider. Both
providers are live code (the provider list, the catalog and the OAuth start
route serve them), so both names are declared as optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:51:18 +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
d2d0778847 Delete the inline signing-key PEM input
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>
2026-09-30 12:26:56 +02:00
Meta Vens
d5acc94fb9 Declare the Odin dependency Heimdall publishes to
Idunn no longer infers how a target proves readiness. Heimdall is routed
but never answers a route challenge; it publishes its presence to Odin, so
its recipe declares the odin.verse-rendezvous dependency and is admitted as
Odin-correlated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 12:51:56 +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
47747ebc99 REC-114: delete completed emergency-compaction scratch
Deletes state/scratch-compaction-d44a230f-4396-40bc-bade-8540321fc091.md, a
one-shot "imminent compaction" memory dump written before a StreamPixels
auth-migration session. Its content is fully superseded: it names dead
E:\Projects\Heimdall / E:\Projects\StreamPixels paths (this host is
F:\Projects), and its "next action" (Heimdall commit f22db66 "Add
StreamPixels managed auth seams", plus the paired StreamPixels-side commit)
already landed.

Kill attempt: rg -n "scratch-compaction-d44a230f" across all of F:/Projects
excluding target/node_modules/.git: only this file itself, the census docs,
and gamecult-site's generated interactive repo-doc-tree
(GameCult/static/interactive/gamecult-compound/repo-doc-tree/heimdall.json),
which is a static catalog snapshot linking to GitHub blob URLs for every
tracked file in every repo, not a functional reader — it will show one more
stale link next time it's regenerated, same as it would for any other file
deleted from any repo.

No collision check needed: single checkout on main, no other worktrees.

REC-114

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:15:37 +02:00
Meta Vens
7be58c4b71 Bump vendored CultLib for the bounded fragment reassembly map
cultnet-ts removed a fragment buffer only on successful reassembly, so a
set that lost one fragment was stranded for the life of the session and
the map grew without bound.

That path is live here. Ghostlight sends this service sealed envelopes
over CultMesh RUDP with max_fragment_bytes = 2048
(Ghostlight/crates/ghostlight-dungeon/src/heimdall.rs:697), and the
private command plane reassembles them through exactly that map, so every
lost fragment on that link left a buffer nothing would collect on a
long-running auth service.

The pin also carries the media stream contracts, but those are cultnet-rs
additions this service does not build -- the recipe builds cultcache-ts,
cultnet-ts and cultmesh-ts only. The functional change for Heimdall is the
32-line eviction in cultnet-ts/src/rudp.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:50:49 +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
8476c0290b R21.4: document StreamPixels' public origin
docs/app-profiles/streampixels.md carried no public origin, so the
allowlist entry in src/app-profiles.ts (https://streampixels.gamecult.org)
had only ops config as a corroborating source, not a doc owner. Add the
line, corroborated by gamecult-ops/nginx/streampixels.gamecult.org.conf:14
and StreamPixels' own .env example, so all four allowlist entries have
a doc owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:45 +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