Commit graph Heimdall/src/store/postgres.ts
Author SHA1 Message Date
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
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
5a4da2ae30 Store: mint auth-completion codes server-side; drop the registration writer
The completion code's `code?` input let a caller choose it; app.ts used
that to make handoff.attemptId become the code verbatim, which is the
account-takeover primitive Soul's probes proved. The store now always
mints the code itself. attemptId survives only as a separate
correlation field (StoredAuthCompletion.attemptId /
CreateAuthCompletionInput.attemptId), and a new
consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets a caller
that already knows the attempt handle (the private command plane)
redeem the completion it created without ever touching the code.

Also deletes HeimdallStore.registerApp: the runtime app-registration
HTTP surface that minted it is already gone from this branch, and
nothing else in the codebase writes to registered_apps (it sits empty
on the live host). findRegisteredApp/listRegisteredApps stay: AppSlug
is a plain string (contracts.ts), so registered_apps remains the real
extension point for a slug Heimdall was never rebuilt to know about; a
row is now provisioned by direct SQL, or InMemoryStore.seedRegisteredApp
in tests, not by any app-facing writer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:22:12 +02:00
Meta Vens
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
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
9065dfa43b Bind Ghostlight logout to exact session custody 2026-08-22 01:53:21 +02:00
Meta Vens
19e5109cf4 Add Eve access plugin and private auth plane 2026-08-22 00:44:57 +02:00
Meta Vens
ee8c012c85 Refresh Repixelizer app claims through Heimdall 2026-05-20 13:43:53 +01:00
Meta Vens
f22db66d64 Add StreamPixels managed auth seams 2026-04-29 15:00:55 +01:00
Meta Vens
6795439ac7 Harden Heimdall custody and verifier seams 2026-04-26 16:23:29 +01:00
Meta Vens
8f173e937a Land one-time browser auth completion flow 2026-04-26 15:23:04 +01:00
Meta Vens
cb0041343a Land first stateful Discord auth slice 2026-04-26 12:50:07 +01:00