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>
This commit is contained in:
Meta Vens 2026-09-06 01:34:53 +02:00
commit 5a15f711e8

View file

@ -443,6 +443,97 @@ Current intended first deployment shape:
This matches the host pattern already used by other GameCult app workloads.
## Public surface boundary
**Status: proposed, 2026-09-06. Not implemented.**
The topology above already says what should be true — same-host apps reach
Heimdall over localhost or private routing, and app backends verify claims
locally. The nginx vhost never implemented it. `heimdall.gamecult.org.conf` is a
single `location / { proxy_pass http://heimdall_app; }`, so every route Fastify
registers is on the open web. The boundary exists in this document and nowhere
in the running system.
### What actually has to be public
Only the browser-driven OAuth flow:
| Path | Why it must be public |
|---|---|
| `/v1/oauth/{provider}/start` | the user's browser goes here |
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable |
| `/.well-known/jwks.json` | *nothing external consumes it today* — see below |
Everything else is internal by the design already written down:
| Path | Consumer |
|---|---|
| `/.well-known/heimdall-configuration` | GC apps, mesh-reachable |
| `/v1/apps` | GC apps, mesh-reachable |
| `/v1/apps/{appSlug}/claims/issue` | GC app backends, mesh-reachable |
| `/healthz` | local probes |
The private command plane is not part of this question. It is
`startCultNetOperationServer` and never touches nginx.
### JWKS: keep it public, deliberately
No OAuth provider fetches it — every adapter in `src/oauth.ts` authenticates
with `client_secret`, not `private_key_jwt`. Every relying party today is
GameCult-owned and mesh-reachable, so nothing outside needs it.
Keep it public anyway. Publishing verification keys is cryptographically
harmless, and the failure mode of hiding it is asymmetric: the first relying
party hosted outside this mesh will be integrated with a standard OIDC library,
and a `jwks_uri` that resolves only on the mesh fails confusingly at integration
time rather than obviously at design time. This is the one endpoint where the
cost of being wrong is higher in the closed direction.
### Discovery and `/v1/apps`: move them internal
These carry no keys and no cryptographic obligation, and they publish a product
inventory. For every app, `serializeAppProfile` emits `slug`, `displayName`,
`profileVersion`, `identityProviders`, `entitlementSources`,
`managedConnectionProviders` and `capabilities`, alongside `supportedProviders`
and their roles. Since the private command plane's record kinds are
`discord_role_access` and `patreon_membership_access`, the entitlement and
monetization model is legible to anyone who fetches it.
Both routes must move together. Trimming the discovery document alone leaves
`/v1/apps` serving the same list.
### Consequence for the nginx vhost
The vhost stops being one `location /` and becomes an explicit allowlist:
`/v1/oauth/`, `/.well-known/jwks.json`, and nothing else by default. A path
added to Fastify should not become public by having been added — that is the
property the current catch-all lacks.
The vhost stays operator-owned and static regardless of who deploys Heimdall,
because `publicBaseUrl` builds the OAuth callback URL that is **registered with
each provider**. That URL cannot move on deploy.
### Finding: the `spotify` provider outlived its consumer
`spotiverse` is the only profile naming `spotify`, and Spotiverse was lost with
the `E:` drive — it was a small daemon exposing the Spotify API to agents, and
it no longer exists in the swarm.
What survives it: `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET` are configured in
`/srv/heimdall/env/service.env`, the profile is advertised in the public
discovery document, and `/v1/oauth/spotify/start` is reachable from the open
web. That path would run a real OAuth handshake against a real client secret and
take custody of a user's Spotify refresh token on behalf of a consumer that
cannot use it.
This is not an active compromise. It is a live credential and a token-custody
code path kept alive for nothing, which is exactly the shape that becomes an
incident later. Retire the profile and the provider config together, or record
why Spotiverse is expected back. Retiring is a change to `src/app-profiles.ts`
and the provider catalog and should be decided on its own, not folded into the
boundary change.
## Deployment modes
### Mode 1: Same-host shared access service