Commit graph Heimdall/docs
Author SHA1 Message Date
Meta Vens
778eabaedc Docs: map managed credential custody, the callback mint and the open follow-ups
architecture.md gains a Managed credential custody section: the single
refresh owner, the store-port lock, per-process single flight, typed
revoked and refresh-failed answers, the provider descriptor token fields
and the one token request, the mode x role gates, custody-only
completions, verification layer and follow-ups by id. The spotify finding
now matches the removed spiverse profile; JOYSTICK joins the provider env
lists. implementation-plan and service-contract lose two stale lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:49:34 +02:00
Meta Vens
e1ac84fb70 Joystick custody needs a creator binding, at start and at the callback
callbackMint now takes the connection: custody_only is a connect for a
managed-connection-only provider bound to connection.kind creator with a
targetId. Anything else is refused at start with creator_connection_required
before any redirect, and at the callback from the signed state before the
code exchange. The streampixels profile note says what Heimdall guarantees
and records the S1 rule that SP's redeem treats a non-creator Joystick
completion as an error, never a sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:34:24 +02:00
Meta Vens
a32be41878 Token answers: a default lifetime replaces an unknown expiry; a malformed refresh_token is typed
A token answer without a usable expires_in (absent, non-numeric, a string that
is not a positive integer) now takes the descriptor's
defaultAccessTokenLifetimeSeconds (3600 for every provider), so no issued
token has a NULL expiry that dueForRefresh would never refresh. A 2xx whose
refresh_token is present but not a string is the typed
provider_body_not_token_set (502 managed_credential_refresh_failed, tokens
kept); an empty string stays a missing token, so a rotating provider still
fails closed. The expiry rule is now pinned on memory and both live Postgres
rigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:24:21 +02:00
Meta Vens
79ea0007d3 OAuth callback: mode and provider role decide what a completion mints
The start gate only refused early. The callback ignored the signed state's
mode, so a Joystick connect for StreamPixels redeemed into a viewer_access
session. callbackMint(profile, provider, mode) now decides at the callback:
identity providers keep a viewer session (Bifrost discord connect unchanged);
a connect for a managed-connection-only provider stores custody and mints a
claim with no facts, no capabilities and no refresh token (the refresh route
would recompute viewer_access from the account); anything else is refused.
StreamPixels' redeem reads only account, provider, mode, connection and
claimSet.identities, so it accepts the capability-free claim unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:23:53 +02:00
Meta Vens
c1fbc1261a OAuth start: connect keeps admitting identity providers, as Bifrost uses it
The first gate refused connect for a provider outside managedConnectionProviders,
which broke Bifrost's existing Discord connect. sign_in and link still take
identityProviders only, so a managed-only provider (Joystick) can never sign
anyone in or link; connect takes either list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:38:13 +02:00
Meta Vens
b15bf33b21 Refresh: one attempt per identity per process; waiters share its outcome
Callers that arrive while a refresh of one linked identity is in flight in
this process join it and receive its success or failure, instead of queuing
on the row lock and presenting the same refresh token again behind a failed
holder. The entry lives as long as the attempt, so a later request retries.
The store lock stays the cross-process guard; the residual risk (one
presentation per process per burst) is written into the service contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:34:39 +02:00
Meta Vens
d989860163 Refresh classification: revoke only on a 400 invalid_grant; any other answer keeps the tokens
A grant verdict is read from a 400 body by exact code, never from a 401, a
5xx or a 2xx. A 2xx that is not an object with a non-empty string
access_token and no error is refused as provider_body_not_token_set, so a
malformed success keeps the tokens and surfaces as the typed 502 for every
provider, rotating or not. A refresh answer without expires_in stores the
expiry as unknown rather than keeping the already-due one; an unknown expiry
is never due, so the next resolve does not refresh again.

Pins Soul's three surviving mutants: substring code match, the dropped
expiry rule, and the snapshot refresh token on a non-rotating provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:34:17 +02:00
Meta Vens
c554c00697 OAuth start: the profile's role lists decide which provider a mode may use
sign_in and link now take only identityProviders; connect takes only
managedConnectionProviders. supportsProvider(), which unioned the role lists
with entitlementSources and never looked at the mode, is deleted: a Joystick
sign_in for streampixels no longer starts, so a Deru sign-in cannot rewrite
his managed credential row. No built-in profile had a provider reachable
only through the union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:31:59 +02:00
Meta Vens
3eaa9ef265 Declare Joystick in the Idunn recipe and document the locked refresh
Recipe optional_environment gains the Joystick client id and secret file;
service-contract documents the row lock, rotation fail-closed and the 410/502
answers; the StreamPixels profile names Heimdall as sole holder of provider
refresh tokens (F5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:15:42 +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
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
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
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
827cdee761 Tests/docs: promote Soul's two it.fails, update the tripwire and fixtures
Both caller-identity-soul.test.ts holes are now closed and promoted
from it.fails to a plain it (both green): the caller-chosen
completionCode is never redeemable, and an arbitrary returnTo origin
is refused. The AppCaller-literal tripwire's expected forger list moves
from ["private-command-plane.ts"] to [] now that the private plane
constructs its caller through callerFromOpenedEnvelope instead of the
literal; the "refuses to mint on refresh" test's wrongApp caller is
built the same way, since a raw { appSlug } literal no longer
satisfies the now-branded AppCaller type.

access-plugin.test.ts's ghostlight completion-code assertion is
updated: `completion.code` is minted independently of the attempt
handle now, so the test documents that the code differs from the
handle instead of asserting they match.

app-registry.test.ts drops validateAppRegistration coverage (deleted)
and seeds registered-app fixtures through InMemoryStore.seedRegisteredApp
instead of the deleted store.registerApp.

docs/service-contract.md documents the new 400 return_to_not_allowed
failure mode. state/evidence.jsonl records this pass's decisions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:23:04 +02:00
Meta Vens
a7d1d2abbf Correct service-contract.md: entitlement policy trust is uniform, not per-slug
The doc still described the pre-cut world (Repixelizer named as the one app
allowed to send policy). Documents the actual failure mode: 401
app_auth_required from resolveAppCaller for any app, any handoff kind.
2026-09-08 13:53:09 +02:00
Meta Vens
326f613ffe Delete the global GC_ACCESS_APP_SHARED_SECRET fallback (R6, fork 2)
Per-app identity with a global fallback made repixelizer and streampixels one
identity in practice. Each app now needs its own
GC_ACCESS_APP_<SLUG>_SHARED_SECRET; operator mints distinct values on
Yggdrasil in the same deploy window. Doc note in service-contract.md corrected
to stop advertising the fallback.
2026-09-08 13:53:09 +02:00
Meta Vens
a999d48b6d Delete claims/issue: zero callers, arbitrary-facts oracle bypassing entitlement evaluation
R1: no GC service calls this route. Its shape (caller-supplied facts handed
straight to issueAccessClaim) is a second entitlement authority even when
gated, so authenticating it would preserve the oracle behind a password.
Deletion-only commit: route, discovery advertisement, mapper, contract type,
positive test, and doc mentions in architecture.md/service-contract.md/
fresh-workspace-handoff.md.
2026-09-08 13:53:09 +02:00
Meta Vens
707da368b1 Retire the Spotiverse profile; keep the spotify provider intact for restore
Spotiverse was lost with the E: drive and has no repository in the swarm,
but /v1/oauth/spotify/start stayed publicly reachable and
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID/_SECRET stayed configured on
yggdrasil. That path would have run a real OAuth handshake against a real
secret and taken custody of a user's Spotify refresh token for a consumer
that could not use it.

The seam is supportsProvider(profile, provider): a provider with no app
behind it is unreachable. So the registry entry is what had to go, not the
provider.

Removed: spotiverseProfile and its registry entry, the spotiverse slug
from appSlugs, the hardcoded callback arm in app.ts, and three
spotiverse-specific tests. Removed from the host: the two spotify env
vars, backed up to /root/heimdall-service.env.bak-20260906-spotify. Nine
other provider vars and the file's 0600 heimdall:heimdall ownership are
untouched.

Kept deliberately: the entire spotify provider -- the providers.ts catalog
entry, spotifyRuntime and its token/profile exchange in oauth.ts, and
"spotify" in the providers union. It is intact and unreferenced, which is
what makes restoring a registry change rather than protocol work. The
capability is wanted back; a Persona managing the queue during a stream is
the motivating case.

docs/app-profiles/spotiverse.md is now the restore spec rather than an
archive: what was removed, what was kept, the base commit 5a15f71 to
recover the profile verbatim, and the daemon's callback URL
(http://127.0.0.1:8796/auth/heimdall/callback), which existed only in a
hardcoded allowlist arm and would otherwise have been lost with it.

Before deleting the three tests I checked their routes keep coverage:
managed-credential resolve is exercised twice via streampixels, and
backend_callback handoff at three other sites. The generic
untrusted_backend_callback negative test was retargeted to bifrost rather
than deleted. 35 pass, 9 files; tsc clean.

Verified the deployed build still boots without the vars --
readProviderConfig returns an empty config rather than throwing, so the
running release fails that route at request time instead of at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:47:10 +02:00
Meta Vens
5a15f711e8 Propose a public surface boundary; record the orphaned spotify provider
Deployment topology already says same-host apps reach Heimdall over
localhost or private routing and verify claims locally. The vhost never
implemented it: heimdall.gamecult.org.conf is one
`location / { proxy_pass http://heimdall_app; }`, so every route Fastify
registers is on the open web. The boundary existed in this document and
nowhere in the running system.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:34:53 +02:00
Meta Vens
e9635038c5 Record Odin-discovered access deployment 2026-08-22 04:08:36 +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
210e16220f Add Ghostlight Dungeon Heimdall profile 2026-08-19 00:39:09 +02:00
Meta Vens
758892db87 Publish Heimdall daemon boundary state for Idunn 2026-06-16 18:01:07 +01:00
Meta Vens
207d3b9c87 Add Bifrost Patreon support sync 2026-06-09 20:22:58 +01:00
Meta Vens
18e2bf812e Add Bifrost Heimdall app profile 2026-06-08 21:04:51 +01:00
Meta Vens
5a06957a0d Add read-only Heimdall Eve witness advertisement 2026-06-03 03:21:31 +01:00
Meta Vens
f0d40e80a5 Allow configured Spotiverse portal callbacks 2026-06-02 19:25:14 +01:00
Meta Vens
55659cc640 Map Heimdall Verse service state 2026-06-02 18:54:02 +01:00
Meta Vens
e2a2f20693 Add Spotiverse Spotify credential custody 2026-06-02 16:25:38 +01:00
Meta Vens
ee8c012c85 Refresh Repixelizer app claims through Heimdall 2026-05-20 13:43:53 +01:00
Meta Vens
f3e1f9c292 Refresh managed provider credentials on resolve 2026-04-29 17:24:44 +01:00
Meta Vens
f22db66d64 Add StreamPixels managed auth seams 2026-04-29 15:00:55 +01:00
Meta Vens
5ef94bb725 Refresh Patreon launch state 2026-04-29 14:39:11 +01:00
Meta Vens
5043a5fc9a Accept caller-owned entitlement policy 2026-04-29 13:22:01 +01:00
Meta Vens
c8658ddd91 Allow Inner Sanctum Repixelizer access 2026-04-29 13:07:44 +01:00
Meta Vens
841617313e Record Heimdall Yggdrasil launch 2026-04-29 12:41:43 +01:00
Meta Vens
ccc713b911 Clarify Repixelizer Discord role policy 2026-04-29 11:54:01 +01:00
Meta Vens
0332e0c966 Refresh Heimdall launch state 2026-04-29 11:51:29 +01:00
Meta Vens
f8eec3558f Prefer direct backend auth handoff 2026-04-26 17:00:57 +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
12ce1f45ae Normalize Heimdall claim fact vocabulary 2026-04-26 14:58:58 +01:00
Meta Vens
cb0041343a Land first stateful Discord auth slice 2026-04-26 12:50:07 +01:00
Meta Vens
58a12a2835 Land Heimdall service skeleton 2026-04-26 12:25:11 +01:00
Meta Vens
49fb467950 Clarify Heimdall credential custody boundary 2026-04-26 12:15:05 +01:00
Meta Vens
e4eab9ed10 Clarify Heimdall provider roles 2026-04-26 12:00:28 +01:00
Meta Vens
17096e85b1 Rename repo to Heimdall 2026-04-26 11:41:27 +01:00
Meta Vens
397ccfd901 Extract GameCult access planning repo 2026-04-26 05:18:35 +01:00