Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5146c722af | ||
|
|
1a36175f3a | ||
|
|
c044155855 | ||
|
|
c0d30e8081 | ||
|
|
e8548614c8 | ||
|
|
55732a901d | ||
|
|
25d43d1121 | ||
|
|
899ea0b8fe | ||
|
|
c15fb97706 | ||
|
|
c1dfe15dd5 | ||
|
|
c7cf78bbbc | ||
|
|
5952cce8a0 | ||
|
|
30a1ace6fd | ||
|
|
df05570ef6 | ||
|
|
d51fa6b999 |
||
|
|
778eabaedc | ||
|
|
6db84bfae8 | ||
|
|
1d856ebd40 | ||
|
|
4e312828c3 | ||
|
|
09ea233592 | ||
|
|
e1ac84fb70 | ||
|
|
26f264877a | ||
|
|
a911da93d8 | ||
|
|
655fcc7d22 | ||
|
|
32208cc8e2 | ||
|
|
a32be41878 | ||
|
|
79ea0007d3 | ||
|
|
6bdaed2a9b | ||
|
|
c1fbc1261a | ||
|
|
b15bf33b21 | ||
|
|
d989860163 | ||
|
|
c554c00697 | ||
|
|
34a8c6a81d | ||
|
|
6b8c4a9c63 | ||
|
|
9b3d44f56a | ||
|
|
f6baadec0c | ||
|
|
7ce9588503 | ||
|
|
3eaa9ef265 | ||
|
|
43b7072dfb | ||
|
|
1c38d12050 | ||
|
|
c81f923890 | ||
|
|
0dbcd98019 | ||
|
|
2108d1088b | ||
|
|
8066bcbbee | ||
|
|
7904e17cd2 | ||
|
|
996f21f58c | ||
|
|
588739857a | ||
|
|
d029e08a2e | ||
|
|
ca2366cecf | ||
|
|
97632b7cfa | ||
|
|
eff5098c23 | ||
|
|
d2ac9696c7 | ||
|
|
0444360733 | ||
|
|
6c0c87669f | ||
|
|
fd98e36186 | ||
|
|
35f804f7ef | ||
|
|
963150f537 | ||
|
|
731ce08cfa | ||
|
|
b5d7b6b0cc | ||
|
|
804f41f5f7 | ||
|
|
7be541467d | ||
|
|
8541ae3f2f | ||
|
|
48915317ef | ||
|
|
2e06678301 | ||
|
|
b5b980578c | ||
|
|
698f5dfc0e | ||
|
|
efd11373b8 | ||
|
|
f94729f5f7 | ||
|
|
7c14414cd2 | ||
|
|
7de12e84be | ||
|
|
cad12165a9 | ||
|
|
869802126c | ||
|
|
ca2e78f00c | ||
|
|
c626a86d55 | ||
|
|
0f5ffd9e69 | ||
|
|
33afcbf364 | ||
|
|
376dfbd2c2 | ||
|
|
83d2f48256 | ||
|
|
2800d88d10 | ||
|
|
d73a26dfd1 | ||
|
|
e091bb3fd1 | ||
|
|
e35441e378 | ||
|
|
fe26208f05 | ||
|
|
f8ff2b26ba | ||
|
|
0178d071c1 | ||
|
|
9f0cd73299 | ||
|
|
ac86352972 | ||
|
|
d2d0778847 |
68 changed files with 6549 additions and 1045 deletions
22
.env.example
22
.env.example
|
|
@ -1,3 +1,5 @@
|
|||
# Secrets are files: set NAME_FILE to the absolute path of a file holding only
|
||||
# the value. An empty NAME_FILE= is refused, so unused ones stay commented out.
|
||||
HOST=127.0.0.1
|
||||
PORT=4100
|
||||
GC_ACCESS_PRIVATE_COMMAND_HOST=127.0.0.1
|
||||
|
|
@ -5,31 +7,29 @@ GC_ACCESS_PRIVATE_COMMAND_PORT=4101
|
|||
GC_ACCESS_BASE_URL=http://127.0.0.1:4100
|
||||
GC_ACCESS_ISSUER=http://127.0.0.1:4100
|
||||
GC_ACCESS_STORAGE_BACKEND=memory
|
||||
GC_ACCESS_DATABASE_URL=
|
||||
# GC_ACCESS_DATABASE_URL_FILE=/absolute/path/to/credential
|
||||
GC_ACCESS_APPLY_SCHEMA_ON_STARTUP=1
|
||||
GC_ACCESS_SESSION_TTL_SECONDS=3600
|
||||
GC_ACCESS_STATE_TTL_SECONDS=600
|
||||
GC_ACCESS_COMPLETION_TTL_SECONDS=300
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_PEM=
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH=
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=0
|
||||
GC_ACCESS_SIGNING_KEY_ID=
|
||||
GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64=
|
||||
# GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE=/absolute/path/to/credential
|
||||
|
||||
GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=
|
||||
# GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE=/absolute/path/to/credential
|
||||
GC_ACCESS_PROVIDER_PATREON_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET=
|
||||
# GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE=/absolute/path/to/credential
|
||||
GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET=
|
||||
# GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE=/absolute/path/to/credential
|
||||
GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET=
|
||||
# GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE=/absolute/path/to/credential
|
||||
GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET=
|
||||
# GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET_FILE=/absolute/path/to/credential
|
||||
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=
|
||||
# GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE=/absolute/path/to/credential
|
||||
|
||||
GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET=
|
||||
GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS=
|
||||
GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET=
|
||||
# GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET_FILE=/absolute/path/to/credential
|
||||
GC_ACCESS_APP_GHOSTLIGHT_RUNTIME_IDS=yggdrasil-ghostlight
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -12,6 +12,19 @@ The point is painfully simple:
|
|||
become "everything phones home for every request"
|
||||
- keep app-domain data separate so "shared auth" does not become "shared swamp"
|
||||
|
||||
## Front Door
|
||||
|
||||
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
|
||||
- **Problem:** every GameCult app was rebuilding Discord/Patreon login glue. Heimdall centralizes identity authority and keeps app-domain data in the apps.
|
||||
- **Entry points:**
|
||||
- `src/index.ts` and `src/app.ts`: the Fastify service. `src/signing.ts` and `src/claims.ts` issue JWTs and serve `/.well-known/jwks.json`; `src/oauth.ts` and `src/providers.ts` own provider flows.
|
||||
- `src/verifier.ts`: the local verifier helper an app backend imports to validate Heimdall JWTs without phoning home.
|
||||
- `src/odin-publication.ts` and `plugins/gamecult.heimdall.access`: the redacted Odin/Eve provider advertisement and the `gamecult.heimdall.access` plugin (`docs/eve-access-plugin.md`).
|
||||
- `docs/architecture.md`, `docs/service-contract.md`, `docs/verse-service-contract.md`, `docs/app-profiles/`: the contracts.
|
||||
- `pnpm install`, `pnpm dev`, `pnpm test` (see Dev Commands).
|
||||
- **Live:** a stateful first slice: signing and JWKS, OAuth state for six providers, in-memory and Postgres stores, a real Discord callback path for the Repixelizer flow, sealed provider tokens, the loopback CultNet operation boundary, Idunn health publication, and the deployed Ghostlight Eve host consuming the discovered boundary.
|
||||
- **Planned:** the two-account and eight-account Ghostlight human canaries, refresh and revocation flows, admin surfaces, and broader inspection projections beyond the redacted access boundary.
|
||||
|
||||
## Current Shape
|
||||
|
||||
This repo now has a stateful first slice instead of a decorative skeleton.
|
||||
|
|
|
|||
|
|
@ -131,17 +131,22 @@ arguments = [
|
|||
]
|
||||
transport = "http"
|
||||
route_required = true
|
||||
# A secret input is declared only as its `*_FILE` name. The binding binds it
|
||||
# under [workload.secret_files] to a root:root 0400 credential file, so the value
|
||||
# never sits in the binding, the unit or a plan. Idunn refuses a binding that
|
||||
# sets an undeclared name, so a plaintext secret has nowhere to go.
|
||||
optional_environment = [
|
||||
"CULTMESH_URI_ODIN_RUDP",
|
||||
"GC_ACCESS_APP_BIFROST_BACKEND_CALLBACK_URLS",
|
||||
"GC_ACCESS_APP_BIFROST_SHARED_SECRET",
|
||||
"GC_ACCESS_APP_BIFROST_SHARED_SECRET_FILE",
|
||||
"GC_ACCESS_APP_GHOSTLIGHT_BACKEND_CALLBACK_URLS",
|
||||
"GC_ACCESS_APP_GHOSTLIGHT_RUNTIME_IDS",
|
||||
"GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET",
|
||||
"GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET_FILE",
|
||||
"GC_ACCESS_APP_REPIXELIZER_SHARED_SECRET_FILE",
|
||||
"GC_ACCESS_APP_STREAMPIXELS_SHARED_SECRET_FILE",
|
||||
"GC_ACCESS_APPLY_SCHEMA_ON_STARTUP",
|
||||
"GC_ACCESS_APP_SHARED_SECRET",
|
||||
"GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT",
|
||||
"GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET",
|
||||
"GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE",
|
||||
"GC_ACCESS_COMPLETION_TTL_SECONDS",
|
||||
"GC_ACCESS_IDUNN_HEALTH_CONTRACT",
|
||||
"GC_ACCESS_IDUNN_RUDP_HEALTH",
|
||||
|
|
@ -150,27 +155,31 @@ optional_environment = [
|
|||
"GC_ACCESS_PRIVATE_COMMAND_HOST",
|
||||
"GC_ACCESS_PRIVATE_COMMAND_PORT",
|
||||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET",
|
||||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET",
|
||||
"GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET",
|
||||
"GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET",
|
||||
"GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_SESSION_TTL_SECONDS",
|
||||
"GC_ACCESS_SIGNING_KEY_ID",
|
||||
"GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP",
|
||||
"GC_ACCESS_SIGNING_PRIVATE_KEY_PATH",
|
||||
"GC_ACCESS_STATE_TTL_SECONDS",
|
||||
"GC_ACCESS_STORAGE_BACKEND",
|
||||
"GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64",
|
||||
"GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE",
|
||||
]
|
||||
required_environment = [
|
||||
"GAMECULT_IDUNN_RUNTIME_BUNDLE",
|
||||
"GAMECULT_IDUNN_CANDIDATE_BIND",
|
||||
"GAMECULT_IDUNN_PROCESS_WRITE_LEASE",
|
||||
"GC_ACCESS_BASE_URL",
|
||||
"GC_ACCESS_DATABASE_URL",
|
||||
"GC_ACCESS_DATABASE_URL_FILE",
|
||||
]
|
||||
|
||||
[service.health]
|
||||
|
|
|
|||
|
|
@ -68,10 +68,11 @@ paid membership for the requested tier title, and POSTs a signed
|
|||
`RecurringSupportSnapshot` fact to Bifrost's
|
||||
`/heimdall/patron-support/events` endpoint.
|
||||
|
||||
The outbound fact uses `X-Heimdall-Signature-256` with the shared
|
||||
`GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET`. Bifrost verifies that HMAC, resolves
|
||||
the Heimdall account id to its local user account, deduplicates by provider
|
||||
event id, records the support event, and derives patron points locally.
|
||||
The outbound fact uses `X-Heimdall-Signature-256` with the shared secret
|
||||
Heimdall reads from `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE`. Bifrost
|
||||
verifies that HMAC, resolves the Heimdall account id to its local user account,
|
||||
deduplicates by provider event id, records the support event, and derives
|
||||
patron points locally.
|
||||
|
||||
This route is a bridge from auth-owned Patreon evidence to Bifrost-owned
|
||||
governance meaning. Heimdall still does not own points, votes, tiers, ledger
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
> still reports `providers=2/6`, which counts it.
|
||||
>
|
||||
> **Why it was removed anyway:** `/v1/oauth/spotify/start` is gated by
|
||||
> `supportsProvider(profile, provider)`, so a provider with no app behind it is
|
||||
> `admitsProviderForMode(profile, provider, mode)`, so a provider with no app behind it is
|
||||
> unreachable. While the profile existed, that path was publicly reachable and
|
||||
> would have run a real OAuth handshake against a real client secret, taking
|
||||
> custody of a user's Spotify refresh token on behalf of a consumer that could
|
||||
|
|
@ -38,7 +38,7 @@ Everything below this line is still accurate. To bring Spotiverse back:
|
|||
**`http://127.0.0.1:8796/auth/heimdall/callback`**; that exact URL was the
|
||||
allowlisted value.
|
||||
3. Configure `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and
|
||||
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET` in the host service environment.
|
||||
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE` pointing at a credential file.
|
||||
They were removed from `/srv/heimdall/env/service.env` on retirement, so a
|
||||
fresh Spotify application registration may be needed.
|
||||
4. Restore the three tests from `5a15f71` if the capabilities they covered
|
||||
|
|
@ -80,13 +80,12 @@ Heimdall needs:
|
|||
|
||||
```text
|
||||
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID=...
|
||||
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=...
|
||||
GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET=...
|
||||
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE=/absolute/path/to/spotify-client-secret
|
||||
GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS=https://your-spotiverse-portal.example/auth/heimdall/callback
|
||||
```
|
||||
|
||||
Spotiverse needs only the Heimdall base URL, matching app secret, and local
|
||||
portal callback/return URLs. The callback URL Spotiverse sends in
|
||||
Spotiverse needs only the Heimdall base URL and local portal callback/return
|
||||
URLs. The callback URL Spotiverse sends in
|
||||
`HEIMDALL_CALLBACK_URL` must exactly match one URL in Heimdall's
|
||||
`GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS` allowlist unless both services
|
||||
are running on the same local host.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ Corroborated by `gamecult-ops/nginx/streampixels.gamecult.org.conf:14`
|
|||
The current StreamPixels architecture already makes useful splits:
|
||||
|
||||
- the web app owns the remembered viewer session
|
||||
- the service owns provider OAuth exchange and identity resolution
|
||||
- Heimdall owns provider OAuth exchange, identity resolution and refresh-token
|
||||
custody for Twitch, YouTube and Joystick; StreamPixels never calls a provider
|
||||
token endpoint and holds no provider refresh token. It asks Heimdall for a
|
||||
short-lived access token (`managed-credentials/resolve`) and treats `410
|
||||
managed_credential_revoked` as "the creator must reconnect"
|
||||
- the overlay remains auth-free
|
||||
- creator memberships and operator roles are separate from general viewer state
|
||||
|
||||
|
|
@ -38,7 +42,7 @@ In the service-first Heimdall version, the most likely reusable slice is:
|
|||
|
||||
- provider OAuth authority
|
||||
- identity-linking authority
|
||||
- managed Twitch/YouTube credential custody for creator connections
|
||||
- managed Twitch/YouTube/Joystick credential custody for creator connections (Joystick is a managed connection only, not a viewer sign-in provider: `startOAuthFlow` admits `sign_in` and `link` only for `identityProviders` and `connect` only for `managedConnectionProviders` or `identityProviders`, and refuses anything else before any redirect. The callback decides again from the signed state's mode: a `connect` for a managed-connection-only provider is admitted only with `connection.kind` `creator` and a `targetId`, refused otherwise at start and again at the callback before the code exchange; an admitted one stores the credential and mints a claim with no facts, no capabilities and no refresh token. Heimdall's claim is therefore never a viewer session. Heimdall cannot stop an app from minting its own viewer from a completion, so StreamPixels' redeem must treat a non-creator Joystick completion as an error, never as a sign-in. This is an S1 rule, recorded for SP)
|
||||
- entitlement refresh
|
||||
- shared claim semantics
|
||||
|
||||
|
|
|
|||
|
|
@ -305,6 +305,159 @@ Important rule:
|
|||
connection is tied to a user or account rather than a purely app-local secret
|
||||
- app-specific binding semantics and runtime behavior still stay app-owned
|
||||
|
||||
## Managed credential custody
|
||||
|
||||
Source-grounded as of branch `eureka/joystick-h1` at 6db84bf (PR
|
||||
GameCult/Heimdall#1, unmerged). Nothing here is deployed.
|
||||
|
||||
### One owner: `refreshManagedCredential`
|
||||
|
||||
`src/managed-credential.ts` owns every decision to present a stored refresh
|
||||
token to a provider. The entitlement path (`refreshAppSession`), the Bifrost
|
||||
Patreon sync and `managed-credentials/resolve` all call it; none of them reads
|
||||
`tokenExpiresAt` or a provider token endpoint itself. The earlier
|
||||
`refreshLinkedIdentityCredentialIfNeeded` is deleted, and callers no longer
|
||||
write refreshed tokens through `upsertLinkedIdentity`: a refresh reaches the row
|
||||
only through the store port below. The only other writer of credential columns
|
||||
is the OAuth callback's grant write (`app.ts`, once).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | a `StoredLinkedIdentity` snapshot, the config, the token custody (AES-256-GCM seal), the provider runtimes, an injectable clock and timeout |
|
||||
| Output | `{kind: "current" or "revoked", identity}`, or a thrown `ManagedCredentialRefreshFailed` (carries the provider and nothing the provider said) |
|
||||
| Forbidden writers | any caller writing token columns or `token_expires_at` outside `updateLinkedIdentityCredential` and the callback's grant write; any caller computing "is a refresh due" |
|
||||
|
||||
The snapshot is a hint. It may answer without a lock when the identity holds no
|
||||
access token (`revoked`) or no refresh is due (`current`; due means a known
|
||||
expiry within 5 minutes, a stored refresh token, and a runtime that refreshes).
|
||||
It never decides that a refresh happens.
|
||||
|
||||
### Three nested guards
|
||||
|
||||
1. **Single flight, per process.** `refreshesInFlight` is keyed by store, then
|
||||
linked identity id. A caller arriving while an attempt is in flight joins it
|
||||
and receives its outcome, success or failure, so nobody queues behind a
|
||||
failed holder and re-presents the same token. The entry ends with the
|
||||
attempt; a later request retries.
|
||||
2. **Store-port lock, across processes.**
|
||||
`HeimdallStore.updateLinkedIdentityCredential(id, decide)` runs `decide`
|
||||
against the row as read under the lock. `PostgresStore` takes
|
||||
`SELECT ... FOR UPDATE` inside a transaction; `InMemoryStore` serialises per
|
||||
id on a promise chain that deletes its entry only when it is still the tail.
|
||||
`decide` re-checks `dueForRefresh` on the locked row, so a process that
|
||||
waited finds the token already rotated and returns `keep`. The provider call
|
||||
is aborted at 10 seconds (`MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS`), which
|
||||
bounds how long any waiter holds a pooled connection.
|
||||
3. **Decision, not mutation.** `decide` returns a `CredentialDecision`: `keep`,
|
||||
`revoke`, or `rotate` with the access token, refresh token and
|
||||
`tokenExpiresAt` all required. The store applies it and returns the row. The
|
||||
new pair is persisted before the access token is returned. A store that
|
||||
keeps the old expiry on rotate is a type error, not a test.
|
||||
|
||||
### Typed answers
|
||||
|
||||
- **Revoked** (`410 managed_credential_revoked`, audit
|
||||
`managed_credential_revoked {provider, reason}`, all three token columns
|
||||
cleared). Two reasons only: `provider_revoked`, an HTTP 400 whose OAuth
|
||||
`error` is exactly `invalid_grant`; and `rotation_missing`, a well-formed
|
||||
refresh answer without a refresh token from a provider whose descriptor says
|
||||
`rotatesRefreshTokens`. An identity with no access token is also `revoked`.
|
||||
`404` means no linked identity exists. On the entitlement path a revoked
|
||||
identity is skipped; on the Bifrost sync it is `404
|
||||
patreon_identity_not_linked` with Bifrost uncalled.
|
||||
- **Refresh failed** (`502 managed_credential_refresh_failed`, tokens
|
||||
untouched, retry allowed): invalid client, a 401 or 5xx whatever its body,
|
||||
timeout, a 2xx without a string `access_token` or carrying `error`, a
|
||||
`refresh_token` present but not a string, an empty or non-JSON body.
|
||||
`ProviderResponseError.oauthError` is drawn only from the closed RFC 6749 code
|
||||
set and is read only from a 400; no provider body reaches a message, stack or
|
||||
audit payload.
|
||||
- An empty-string `refresh_token` counts as missing.
|
||||
|
||||
### Provider descriptor and the one token request
|
||||
|
||||
`providerCatalog` (`src/providers.ts`) is the only place a provider's token
|
||||
behaviour is written down. Fields beyond the authorize side:
|
||||
|
||||
- `tokenEndpoint`: where both grants go.
|
||||
- `tokenEndpointAuthMethod`: `client_secret_basic` or `client_secret_post`
|
||||
(RFC 7591 names); exactly one client-auth method per request.
|
||||
- `rotatesRefreshTokens`: true only for Joystick. Drives fail-closed on
|
||||
refresh (`rotation_missing`) and a failed callback on exchange when the grant
|
||||
carries no refresh token.
|
||||
- `defaultAccessTokenLifetimeSeconds`: 3600 for all seven providers. Applied
|
||||
when an answer has no usable `expires_in`; unusable means absent, non-numeric,
|
||||
not a positive number or positive-integer string, or above 365 days. An
|
||||
unknown expiry is therefore never stored, so every credential comes due.
|
||||
|
||||
`requestProviderTokens(provider, config, grant)` in `src/oauth.ts` replaces the
|
||||
ten per-provider exchange and refresh functions. Authorization-code and refresh
|
||||
grants share it, it returns an `ExpiringTokenSet` (`expiresAt` required), and it
|
||||
rejects a malformed 2xx typed as `provider_body_not_token_set` on both grants.
|
||||
The per-provider runtimes keep only identity resolution.
|
||||
|
||||
### Mode x role at the gates
|
||||
|
||||
`src/app-profiles.ts` holds both questions, and the profile's role lists are
|
||||
their only input:
|
||||
|
||||
- `admitsProviderForMode(profile, provider, mode)`, the start gate: `sign_in`
|
||||
and `link` take `identityProviders` only; `connect` takes
|
||||
`managedConnectionProviders` or `identityProviders` (Bifrost connects its
|
||||
identity providers). `supportsProvider` is deleted.
|
||||
- `callbackMint(profile, provider, mode, connection)`, decided at the callback
|
||||
from the signed state and asked again at start, so an unbound connect never
|
||||
reaches a redirect:
|
||||
- `viewer_session`: the provider is in `identityProviders`; a normal session
|
||||
with the profile's capabilities.
|
||||
- `custody_only`: a `connect` for a provider that is only a managed
|
||||
connection (Joystick on StreamPixels) with `connection.kind = "creator"` and
|
||||
a non-empty `targetId`.
|
||||
- `refused`: everything else, before the code exchange.
|
||||
|
||||
A custody-only completion stores the credential and issues a claim with no
|
||||
facts, no capabilities and no hybrid capabilities (`withoutCapabilities` in
|
||||
`src/claims.ts`, the one owner of a capability-free claim). The completion
|
||||
payload and the `backend_callback` handoff carry no `refreshToken` or
|
||||
`refresh`, because the refresh route recomputes facts from the account and would
|
||||
turn that session into a viewer session. A session row still exists, and
|
||||
`BackendHandoffSuccessPayload` types both fields optional for this reason. The
|
||||
consumer's redeem reads only account id, provider, mode, connection and claim
|
||||
identities, so the claim form is unchanged for it.
|
||||
|
||||
### Verification layer
|
||||
|
||||
The rules above are pinned by `tests/managed-credential.test.ts` on three rigs
|
||||
(memory, Postgres with one app, Postgres with two pools),
|
||||
`tests/provider-token-request.test.ts`, `tests/app.test.ts` (a matrix over all
|
||||
profiles x providers x modes, callback mint, handoff strip) and
|
||||
`tests/recipe-secrets.test.ts`. Evidence is the Yggdrasil `ygg-verify` run on
|
||||
Linux (node:24, postgres:16, `HEIMDALL_REQUIRE_LIVE_PG=1`; 600 passed, 8
|
||||
skipped, none a live-Postgres suite), hand and StrykerJS mutants over the diff,
|
||||
and Soul's pass 4 verdict `joystick:verdict:cut-h1.s4`. The deploy path's own
|
||||
builder was not used.
|
||||
|
||||
### Open follow-ups
|
||||
|
||||
- `joystick:follow_up:cross-process-failed-refresh-marker`: single flight is per
|
||||
process, so N Heimdall processes can present one token N times after a
|
||||
transient failure. Needs a failed-attempt marker on the row, read under the
|
||||
lock. Must land before or with Idunn onboarding.
|
||||
- `joystick:follow_up:connect-start-needs-app-auth`: connect starts are
|
||||
unauthenticated, so anyone can name `{creator, targetId}`; the callback
|
||||
re-check proves a binding exists, not that the caller owns it. It also owns
|
||||
the whitespace-only `targetId` that passes both gates and the callback's
|
||||
fail-open `''` fallback for a vanished profile (finding
|
||||
`joystick:finding:cut-h1.s4.whitespace-targetid-passes-custody-gate`). Must
|
||||
land before the public Joystick bot or a second creator.
|
||||
- `joystick:follow_up:heimdall-idunn-onboarding` (gamecult-ops): live Heimdall
|
||||
is a hand-copied release under `heimdall.service`, not an Idunn-admitted
|
||||
generation; the recipe's Joystick lines are declared but unproven by a
|
||||
deploy.
|
||||
- `joystick:follow_up:joystick-viewer-signin-public-bot`: Joystick stays out of
|
||||
StreamPixels `identityProviders` until the public bot is registered; viewer
|
||||
sign-in must not rewrite a creator's credential row.
|
||||
|
||||
## Policy model
|
||||
|
||||
The shared authority should not hard-code one app's access rules. It should
|
||||
|
|
@ -514,24 +667,17 @@ 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.
|
||||
`spotiverse` was the only profile naming `spotify`, and Spotiverse was lost with
|
||||
the `E:` drive. The profile has since been removed from the registry
|
||||
(`docs/app-profiles/spotiverse.md` is the restore spec), and
|
||||
`admitsProviderForMode` refuses a provider no profile lists, so
|
||||
`/v1/oauth/spotify/start` no longer reaches a handshake.
|
||||
|
||||
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.
|
||||
What survives: the `spotify` catalog entry and runtime, and, wherever an
|
||||
operator env still holds them, `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and
|
||||
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE`. A live credential kept for a
|
||||
consumer that does not exist is the shape that becomes an incident later.
|
||||
Retire the provider config, or record why Spotiverse is expected back.
|
||||
|
||||
## Deployment modes
|
||||
|
||||
|
|
@ -572,47 +718,46 @@ every app's audience or product data.
|
|||
|
||||
## Configuration split
|
||||
|
||||
Suggested generic env surface:
|
||||
Heimdall reads plain settings from its environment and every secret from a
|
||||
file. A secret input `NAME` is configured as `NAME_FILE`, the absolute path of
|
||||
a file holding only that value; under Idunn the binding binds it in
|
||||
`[workload.secret_files]` to a `root:root 0400` credential. A blank file is
|
||||
refused at startup, and so is setting both `NAME` and `NAME_FILE`.
|
||||
|
||||
Plain settings:
|
||||
|
||||
- `GC_ACCESS_ENABLED=1`
|
||||
- `GC_ACCESS_BASE_URL=https://heimdall.gamecult.org`
|
||||
- `GC_ACCESS_INTERNAL_URL=http://127.0.0.1:4100`
|
||||
- `GC_ACCESS_SESSION_SECRET=...`
|
||||
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM=...`
|
||||
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH=/var/lib/heimdall/signing-key.pem`
|
||||
- `GC_ACCESS_ISSUER=https://heimdall.gamecult.org`
|
||||
- `GC_ACCESS_STORAGE_BACKEND=postgres`
|
||||
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH=/etc/gamecult/heimdall/credentials/signing-key.pem`
|
||||
- `GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1`
|
||||
- `GC_ACCESS_SIGNING_KEY_ID=...`
|
||||
- `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64=...`
|
||||
- `GC_ACCESS_SESSION_TTL_SECONDS=3600`
|
||||
- `GC_ACCESS_STATE_TTL_SECONDS=600`
|
||||
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_PROVIDER_DISCORD_BOT_TOKEN=...`
|
||||
- `GC_ACCESS_PROVIDER_PATREON_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_PROVIDER_PATREON_CAMPAIGN_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_ENTITLEMENT_CACHE_TTL_SECONDS=900`
|
||||
- `GC_ACCESS_PROVIDER_FAILURE_GRACE_SECONDS=3600`
|
||||
- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_ID=...`
|
||||
- `GC_ACCESS_APP_{APP_SLUG}_BACKEND_CALLBACK_URLS=...`
|
||||
|
||||
Secret files:
|
||||
|
||||
- `GC_ACCESS_DATABASE_URL_FILE=/etc/gamecult/heimdall/credentials/GC_ACCESS_DATABASE_URL`
|
||||
- `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE=...`
|
||||
- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_SECRET_FILE=...`
|
||||
- `GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET_FILE=...`
|
||||
- `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE=...`
|
||||
|
||||
`src/config.ts` is the complete list; `deployment/idunn/recipe.toml` declares
|
||||
the subset an Idunn binding may set.
|
||||
|
||||
Only configure the providers Heimdall actually owns for a given deployment.
|
||||
|
||||
If `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` is absent, the current skeleton falls
|
||||
back to `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH` if present, and can bootstrap that
|
||||
file on first boot when `GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1`. If
|
||||
neither PEM nor path is configured, the current skeleton falls back to an
|
||||
The signing key is always a file: `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH` names
|
||||
it, and Heimdall can bootstrap that file on first boot when
|
||||
`GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1`. If no path is configured, the current skeleton falls back to an
|
||||
ephemeral dev signing key. That is acceptable for local iteration and
|
||||
absolutely not acceptable for real deployment.
|
||||
|
||||
Managed provider tokens should be sealed at rest with a stable
|
||||
`GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64` secret. The current runtime will still
|
||||
`GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE` secret. The current runtime will still
|
||||
mint an ephemeral dev token key for in-memory local work, but persistent
|
||||
Postgres deployments should treat a missing token-encryption key as a hard
|
||||
misconfiguration, not a vibe.
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ What exists right now is:
|
|||
and managed creator connections
|
||||
- an app-authenticated managed-credential resolve endpoint so StreamPixels can
|
||||
call provider APIs without storing refresh tokens locally
|
||||
- managed Twitch/YouTube credentials refresh inside Heimdall before resolution
|
||||
- managed Twitch/YouTube/Joystick credentials refresh inside Heimdall before resolution
|
||||
when they are expired or near expiry
|
||||
- StreamPixels now starts viewer and creator Twitch/YouTube OAuth through
|
||||
Heimdall, redeems one-time completion codes server-side, and keeps local
|
||||
|
|
@ -215,7 +215,7 @@ Landed in the first pass:
|
|||
Still to do in this phase:
|
||||
|
||||
- key rotation policy instead of just stable key loading
|
||||
- token custody retrieval/refresh surfaces beyond initial storage
|
||||
- token custody retrieval and refresh have landed (see `docs/architecture.md`, Managed credential custody); the open pieces are the follow-ups listed there
|
||||
|
||||
### Phase 2: Heimdall service skeleton
|
||||
|
||||
|
|
|
|||
|
|
@ -44,12 +44,11 @@ Purpose:
|
|||
Notes:
|
||||
|
||||
- current skeleton signs with Ed25519
|
||||
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` still works for direct secret injection
|
||||
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH` lets the service load a persisted key
|
||||
from disk
|
||||
- if the path is missing and `GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1`, the
|
||||
service bootstraps a new key file on first start
|
||||
- if neither PEM nor path is configured, the service still generates an
|
||||
- if no path is configured, the service still generates an
|
||||
ephemeral dev key at startup and deserves no sympathy when the `kid` changes
|
||||
|
||||
### `GET /.well-known/heimdall-configuration`
|
||||
|
|
@ -119,6 +118,7 @@ Supported providers in the skeleton:
|
|||
- `twitch`
|
||||
- `youtube`
|
||||
- `spotify`
|
||||
- `joystick`
|
||||
|
||||
Request body:
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ Failure modes:
|
|||
Launch note:
|
||||
|
||||
- Discord and Patreon are the implemented provider runtimes for Repixelizer
|
||||
- Twitch and YouTube now have generic OAuth exchange and identity runtimes for
|
||||
- Twitch, YouTube and Joystick have generic OAuth exchange and identity runtimes for
|
||||
StreamPixels viewer auth and managed creator connections
|
||||
- GitHub is still catalogued for start URLs, but its callback runtime is not
|
||||
implemented yet
|
||||
|
|
@ -431,9 +431,11 @@ Important behavior:
|
|||
|
||||
- the refresh token is signed by Heimdall and scoped to one app session
|
||||
- the access token remains short-lived; app backends still verify it locally
|
||||
- if a stored provider access token is expired or near expiry, Heimdall uses
|
||||
the stored provider refresh token and persists any rotation before
|
||||
evaluating entitlements
|
||||
- if a stored provider access token is expired or near expiry, Heimdall takes
|
||||
a row lock on the linked identity, re-reads it, uses the stored provider
|
||||
refresh token and persists any rotation before evaluating entitlements; an
|
||||
identity whose grant the provider has revoked (no stored access token)
|
||||
contributes no entitlement
|
||||
- any app authenticated via `x-heimdall-app-secret` (`resolveAppCaller`) may
|
||||
send `entitlementPolicies`; Heimdall matches each stored identity to the
|
||||
policy for that provider. Sending policy without authenticating is
|
||||
|
|
@ -452,8 +454,9 @@ Purpose:
|
|||
Authentication:
|
||||
|
||||
- requires `x-heimdall-app-secret`
|
||||
- the configured secret comes from `GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET`,
|
||||
per app; there is no generic fallback secret shared across apps
|
||||
- the configured secret comes from the credential file named by
|
||||
`GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET_FILE`, per app; there is no generic
|
||||
fallback secret shared across apps
|
||||
|
||||
Request body:
|
||||
|
||||
|
|
@ -484,7 +487,36 @@ Important behavior:
|
|||
- it returns access tokens only; refresh tokens remain in Heimdall custody
|
||||
- when a stored managed credential is expired or within the refresh window,
|
||||
Heimdall refreshes it before returning the access token and persists rotated
|
||||
access/refresh tokens
|
||||
access/refresh tokens. The refresh runs under a row lock on the linked
|
||||
identity (`SELECT ... FOR UPDATE`), so a given refresh token is presented to
|
||||
the provider once across concurrent requests and across two Heimdall
|
||||
processes sharing one database. Within one process, concurrent refreshes of
|
||||
one identity share a single attempt and its outcome (success or failure), so
|
||||
nobody re-presents a token behind a failed holder in that burst. Residual
|
||||
risk, tracked as follow-up `joystick:follow_up:cross-process-failed-refresh-marker`:
|
||||
the guard is one presentation per process per burst, so N
|
||||
Heimdall processes can present one token N times after a transient failure.
|
||||
The provider call is bounded at 10 seconds
|
||||
- a provider whose refresh tokens rotate (Joystick) fails closed: a refresh
|
||||
response without a new refresh token revokes the credential instead of
|
||||
keeping the old token
|
||||
- `410 {"error":"managed_credential_revoked","provider":...}`: the provider
|
||||
rejected the grant (HTTP 400 whose OAuth `error` is exactly `invalid_grant`),
|
||||
or the identity holds no access token.
|
||||
The stored tokens are cleared and an audit event `managed_credential_revoked`
|
||||
is written; the owner must reconnect. `404 managed_credential_not_found`
|
||||
means no linked identity exists
|
||||
- `502 {"error":"managed_credential_refresh_failed","provider":...}`: the
|
||||
refresh failed for any reason that is not a grant verdict (invalid client,
|
||||
a 401 or 5xx whatever its body says, timeout, a 2xx without a string
|
||||
`access_token` or carrying an `error`, a `refresh_token` that is present but
|
||||
not a string, an empty or non-JSON body). Stored tokens are untouched and a
|
||||
later request may succeed. An empty-string `refresh_token` counts as missing,
|
||||
so a rotating provider revokes (`rotation_missing`). A token answer without a
|
||||
usable `expires_in` (absent, non-numeric, or not a positive number or
|
||||
positive-integer string) stores the expiry as now plus the provider's default
|
||||
access-token lifetime (3600 seconds), so an expiry is never unknown and the
|
||||
credential always comes due for refresh
|
||||
- app-local binding, diagnostics, subscription setup, and runtime behavior stay
|
||||
app-owned
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Fresh Workspace Handoff
|
||||
|
||||
This is the re-entry packet for `E:\Projects\Heimdall`.
|
||||
This is the re-entry packet for this repo (path: whatever the checkout is; it has moved drives before).
|
||||
|
||||
It is intentionally short. Historical proof belongs in git history and the
|
||||
distilled `state/evidence.jsonl` ledger; the shared architecture belongs in
|
||||
|
|
@ -12,8 +12,7 @@ distilled `state/evidence.jsonl` ledger; the shared architecture belongs in
|
|||
From the repo root:
|
||||
|
||||
```powershell
|
||||
# This repo has no dedicated venv yet. Use any working Python 3.11+.
|
||||
E:\Projects\repixelizer\.venv\Scripts\python.exe .\tools\heimdall_state.py status
|
||||
python .\tools\heimdall_state.py status
|
||||
Get-Content '.\state\map.yaml'
|
||||
Get-Content '.\notes\fresh-workspace-handoff.md'
|
||||
Get-Content '.\docs\architecture.md'
|
||||
|
|
@ -25,10 +24,25 @@ Get-Content '.\state\evidence.jsonl' -Tail 8
|
|||
|
||||
Do not trust this file for the exact live HEAD. Always check git.
|
||||
|
||||
## Active Work
|
||||
|
||||
Joystick H1 is merged and live (PR GameCult/Heimdall#1; campaign
|
||||
`joystick:campaign:self`). Next: `joystick:follow_up:h1-joystick-consent-round-trip`, then
|
||||
StreamPixels S0/S1 (other sessions). Open Heimdall follow-ups, by id:
|
||||
`joystick:follow_up:cross-process-failed-refresh-marker`,
|
||||
`joystick:follow_up:connect-start-needs-app-auth`,
|
||||
`joystick:follow_up:heimdall-idunn-onboarding`,
|
||||
`joystick:follow_up:joystick-viewer-signin-public-bot`. The Ghostlight KLTST
|
||||
canaries below stay open, behind them.
|
||||
|
||||
## Current Orientation
|
||||
|
||||
- Yggdrasil runs Heimdall
|
||||
`1086aee01169bf60e8a492b2740db1c6f3e8cabf` with CultLib
|
||||
Yggdrasil runs Heimdall `d51fa6b`, hand-deployed 2026-10-19 15:51Z;
|
||||
gamecult-ops `210a1af` records backup, rollback and provenance. Facts below
|
||||
about Odin, Ghostlight and CultLib pins were verified at `1086aee` (2026-08-22)
|
||||
and are not re-verified at `d51fa6b`.
|
||||
|
||||
- At `1086aee01169bf60e8a492b2740db1c6f3e8cabf` Heimdall ran with CultLib
|
||||
`5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9`
|
||||
- Heimdall publishes four redacted access records to Odin under unique keys:
|
||||
`eve:provider:heimdall`, `heimdall:command-boundary`,
|
||||
|
|
@ -148,8 +162,7 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
over `cultnet.transport.rudp.v0` when `GC_ACCESS_IDUNN_RUDP_HEALTH` is
|
||||
configured; HTTP `/healthz`, JWKS, discovery, systemd, and nginx routing
|
||||
remain compatibility witnesses
|
||||
- Yggdrasil now runs Heimdall commit
|
||||
`1086aee01169bf60e8a492b2740db1c6f3e8cabf`. Heimdall persists its private
|
||||
- Since `1086aee` Heimdall persists its private
|
||||
provider-health identity at `/srv/heimdall/keys/provider-health-identity.cc`
|
||||
and publishes canonical signed health independently of the slower Verse
|
||||
snapshot cadence. Idunn owns the exact root trust binding, authenticated
|
||||
|
|
@ -171,6 +184,8 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
|
||||
## Next Real Move
|
||||
|
||||
The Active Work follow-ups first. Everything below is the standing queue.
|
||||
|
||||
Do not continue implementation automatically from a rehydrate-only request.
|
||||
|
||||
The Idunn/swarm health transport cut is live. Do not regress Heimdall to
|
||||
|
|
@ -233,9 +248,10 @@ Bifrost needs browser verification after deployment:
|
|||
|
||||
- configure Heimdall with
|
||||
`GC_ACCESS_APP_BIFROST_BACKEND_CALLBACK_URLS=https://bifrost.gamecult.org/auth/heimdall/callback`
|
||||
- configure Heimdall with `GC_ACCESS_APP_BIFROST_SHARED_SECRET`,
|
||||
- configure Heimdall with `GC_ACCESS_APP_BIFROST_SHARED_SECRET_FILE`,
|
||||
`GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT=https://bifrost.gamecult.org/heimdall/patron-support/events`,
|
||||
and `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET`
|
||||
and `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE`, each naming a credential
|
||||
file
|
||||
- configure Bifrost with `Heimdall__DiscordGuildId`,
|
||||
`Heimdall__DiscordAllowedRoleIds__0=<KTLST-role-id>`, and
|
||||
`Heimdall__PatreonTierTitle=Inner Sanctum`
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
"@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts",
|
||||
"cultmesh-ts": "file:vendor/CultLib/packages/cultmesh-ts",
|
||||
"cultnet-ts": "file:vendor/CultLib/packages/cultnet-ts",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify": "^5.12.6",
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
|
|
|||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
|
|
@ -28,8 +28,8 @@ importers:
|
|||
specifier: file:vendor/CultLib/packages/cultnet-ts
|
||||
version: file:vendor/CultLib/packages/cultnet-ts
|
||||
fastify:
|
||||
specifier: ^5.8.5
|
||||
version: 5.8.5
|
||||
specifier: ^5.12.6
|
||||
version: 5.12.7
|
||||
pg:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
|
|
@ -532,17 +532,23 @@ packages:
|
|||
fast-json-stringify@6.3.0:
|
||||
resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==}
|
||||
|
||||
fast-json-stringify@7.1.3:
|
||||
resolution: {integrity: sha512-5TDM1yOF/BDFOclKz2G6rs1RsIl5HXMCrg/npmdNIPNQCezshR5kIHjhwBNuMY67jhkTUAm9DguL8xkL/x0Wtw==}
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fast-uri@4.2.3:
|
||||
resolution: {integrity: sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==}
|
||||
|
||||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify@5.8.5:
|
||||
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
|
||||
fastify@5.12.7:
|
||||
resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
|
@ -556,8 +562,8 @@ packages:
|
|||
picomatch:
|
||||
optional: true
|
||||
|
||||
find-my-way@9.5.0:
|
||||
resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==}
|
||||
find-my-way@9.10.0:
|
||||
resolution: {integrity: sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
|
|
@ -853,8 +859,8 @@ packages:
|
|||
process-warning@4.0.1:
|
||||
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
|
||||
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
process-warning@5.1.1:
|
||||
resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
|
@ -1475,15 +1481,26 @@ snapshots:
|
|||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
||||
fast-json-stringify@7.1.3:
|
||||
dependencies:
|
||||
'@fastify/merge-json-schemas': 0.2.1
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 4.2.3
|
||||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
dependencies:
|
||||
fast-decode-uri-component: 1.0.1
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-uri@4.2.3: {}
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify@5.8.5:
|
||||
fastify@5.12.7:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
'@fastify/error': 4.2.0
|
||||
|
|
@ -1491,11 +1508,11 @@ snapshots:
|
|||
'@fastify/proxy-addr': 5.1.0
|
||||
abstract-logging: 2.0.1
|
||||
avvio: 9.2.0
|
||||
fast-json-stringify: 6.3.0
|
||||
find-my-way: 9.5.0
|
||||
fast-json-stringify: 7.1.3
|
||||
find-my-way: 9.10.0
|
||||
light-my-request: 6.6.0
|
||||
pino: 10.3.1
|
||||
process-warning: 5.0.0
|
||||
process-warning: 5.1.1
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 4.1.0
|
||||
semver: 7.7.4
|
||||
|
|
@ -1509,7 +1526,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
find-my-way@9.5.0:
|
||||
find-my-way@9.10.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-querystring: 1.1.2
|
||||
|
|
@ -1733,7 +1750,7 @@ snapshots:
|
|||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 3.0.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.0.0
|
||||
process-warning: 5.1.1
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
|
|
@ -1758,7 +1775,7 @@ snapshots:
|
|||
|
||||
process-warning@4.0.1: {}
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
process-warning@5.1.1: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"type": "object",
|
||||
"required": ["provider", "mode", "returnTo", "entitlementPolicy"],
|
||||
"properties": {
|
||||
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify"] },
|
||||
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] },
|
||||
"mode": { "enum": ["sign_in", "link", "connect"] },
|
||||
"returnTo": { "type": "string", "format": "uri" },
|
||||
"entitlementPolicy": {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
type CapabilityDefinition,
|
||||
type CapabilityMode,
|
||||
} from "./capability-rules.js";
|
||||
import { type AppSlug, type LinkedIdentityInput, type OAuthEntitlementPolicy, type Provider } from "./contracts.js";
|
||||
import { type AppSlug, type LinkedIdentityInput, type OAuthConnectionBinding, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js";
|
||||
import { entitlementFacts, grantFacts, identityFacts } from "./facts.js";
|
||||
|
||||
export type { CapabilityDefinition, CapabilityMode };
|
||||
|
|
@ -99,10 +99,10 @@ const repixelizerProfile: AppProfile = {
|
|||
const streampixelsProfile: AppProfile = {
|
||||
slug: "streampixels",
|
||||
displayName: "StreamPixels",
|
||||
profileVersion: "2026-04-26",
|
||||
profileVersion: "2026-10-19",
|
||||
identityProviders: ["twitch", "youtube"],
|
||||
entitlementSources: [],
|
||||
managedConnectionProviders: ["twitch", "youtube"],
|
||||
managedConnectionProviders: ["twitch", "youtube", "joystick"],
|
||||
allowedReturnOrigins: ["https://streampixels.gamecult.org"],
|
||||
capabilities: [
|
||||
{
|
||||
|
|
@ -222,12 +222,19 @@ export function serializeAppProfile(profile: AppProfile): Record<string, unknown
|
|||
};
|
||||
}
|
||||
|
||||
export function supportsProvider(profile: AppProfile, provider: Provider): boolean {
|
||||
return (
|
||||
profile.identityProviders.includes(provider) ||
|
||||
profile.managedConnectionProviders.includes(provider) ||
|
||||
profile.entitlementSources.includes(provider)
|
||||
);
|
||||
/**
|
||||
* Whether the profile admits this provider for this mode. The role lists
|
||||
* decide: `sign_in` and `link` mint viewer identity, so they take only
|
||||
* `identityProviders`; a provider that is only a managed connection (Joystick)
|
||||
* can never sign anyone in or link. `connect` takes either list:
|
||||
* `managedConnectionProviders` for creator credentials, and
|
||||
* `identityProviders` because Bifrost and its siblings already connect their
|
||||
* identity providers. A provider in neither list is refused before any redirect.
|
||||
*/
|
||||
export function admitsProviderForMode(profile: AppProfile, provider: Provider, mode: OAuthMode): boolean {
|
||||
return mode === "connect"
|
||||
? profile.managedConnectionProviders.includes(provider) || profile.identityProviders.includes(provider)
|
||||
: profile.identityProviders.includes(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -243,3 +250,34 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a completed OAuth callback may mint, decided at the callback from the
|
||||
* signed state's mode and the provider's role in the profile (the start gate
|
||||
* is only an early refusal). `viewer_session`: the provider is one of the
|
||||
* profile's `identityProviders`, so the completion signs the account in with
|
||||
* the profile's capabilities. `custody_only`: a `connect` for a provider that
|
||||
* is only a managed connection (Joystick on StreamPixels), bound to a creator
|
||||
* target, stores the creator credential and mints a claim with no capabilities
|
||||
* and no refresh token. Without a creator binding it is `refused`, as is any
|
||||
* other combination. The start gate asks this same question, so an unbound
|
||||
* connect never reaches a redirect.
|
||||
*/
|
||||
export type CallbackMint = "viewer_session" | "custody_only" | "refused";
|
||||
|
||||
export function callbackMint(
|
||||
profile: AppProfile,
|
||||
provider: Provider,
|
||||
mode: OAuthMode,
|
||||
connection: OAuthConnectionBinding | null | undefined
|
||||
): CallbackMint {
|
||||
if (profile.identityProviders.includes(provider)) {
|
||||
return "viewer_session";
|
||||
}
|
||||
return mode === "connect" &&
|
||||
profile.managedConnectionProviders.includes(provider) &&
|
||||
connection?.kind === "creator" &&
|
||||
connection.targetId !== ""
|
||||
? "custody_only"
|
||||
: "refused";
|
||||
}
|
||||
|
|
|
|||
258
src/app.ts
258
src/app.ts
|
|
@ -1,6 +1,6 @@
|
|||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
|
||||
import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js";
|
||||
import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js";
|
||||
import {
|
||||
|
|
@ -23,7 +23,7 @@ import {
|
|||
type Provider,
|
||||
} from "./contracts.js";
|
||||
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
|
||||
import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, supportsProvider } from "./app-profiles.js";
|
||||
import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint } from "./app-profiles.js";
|
||||
import { renderBrowserHandoffPage } from "./browser-handoff.js";
|
||||
import { type HeimdallConfig, loadConfig } from "./config.js";
|
||||
import { createTokenCustody, type TokenCustody } from "./custody.js";
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
summarizePatreonMemberships,
|
||||
type OAuthRuntimeRegistry,
|
||||
} from "./oauth.js";
|
||||
import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js";
|
||||
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
|
||||
import { createStore, type HeimdallStore } from "./store/index.js";
|
||||
|
|
@ -44,8 +45,6 @@ import {
|
|||
type UpsertLinkedIdentityInput,
|
||||
} from "./store/types.js";
|
||||
|
||||
const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: HeimdallConfig;
|
||||
store?: HeimdallStore;
|
||||
|
|
@ -238,6 +237,30 @@ export function verifyRefreshToken(
|
|||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's `error` and `error_description` as Heimdall relays them. They
|
||||
* arrive as query parameters anyone can write, so only the characters RFC 6749
|
||||
* (4.1.2.1) allows in them survive, and each is cut to a bound. The browser
|
||||
* page escapes what is left for its own contexts.
|
||||
*/
|
||||
function relayedProviderText(value: string | undefined, bound: number): string | undefined {
|
||||
const printable = value?.replace(/[^\x20-\x21\x23-\x5b\x5d-\x7e]/g, "").slice(0, bound);
|
||||
return printable ? printable : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What may be recorded or answered about a caught error: its class and its
|
||||
* code, each only when it is a short identifier. Never its message, which
|
||||
* carries whatever the thrower quoted (a host, a database, a provider's body).
|
||||
*/
|
||||
export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } {
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
return {
|
||||
errorClass: error instanceof Error && /^[A-Za-z]{1,64}$/.test(error.name) ? error.name : "Error",
|
||||
errorCode: typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : null,
|
||||
};
|
||||
}
|
||||
|
||||
function prefersHtml(acceptHeader: string | undefined): boolean {
|
||||
if (!acceptHeader) {
|
||||
return false;
|
||||
|
|
@ -343,63 +366,6 @@ function centsToDecimalString(cents: number): string {
|
|||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
async function refreshLinkedIdentityCredentialIfNeeded(options: {
|
||||
config: HeimdallConfig;
|
||||
store: HeimdallStore;
|
||||
tokenCustody: TokenCustody;
|
||||
oauthRuntimes: OAuthRuntimeRegistry;
|
||||
linkedIdentity: StoredLinkedIdentity;
|
||||
now: string;
|
||||
}): Promise<StoredLinkedIdentity> {
|
||||
const expiresAt = options.linkedIdentity.tokenExpiresAt ? Date.parse(options.linkedIdentity.tokenExpiresAt) : undefined;
|
||||
if (
|
||||
!expiresAt ||
|
||||
expiresAt - Date.now() > MANAGED_CREDENTIAL_REFRESH_SKEW_MS ||
|
||||
!options.linkedIdentity.refreshTokenEncrypted
|
||||
) {
|
||||
return options.linkedIdentity;
|
||||
}
|
||||
|
||||
const runtime = options.oauthRuntimes[options.linkedIdentity.provider];
|
||||
if (!runtime.refreshAccessToken) {
|
||||
return options.linkedIdentity;
|
||||
}
|
||||
|
||||
const refreshToken = options.tokenCustody.decrypt(options.linkedIdentity.refreshTokenEncrypted);
|
||||
const refreshed = await runtime.refreshAccessToken({
|
||||
config: options.config,
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
const upsertInput: UpsertLinkedIdentityInput = {
|
||||
id: options.linkedIdentity.id,
|
||||
accountId: options.linkedIdentity.accountId,
|
||||
provider: options.linkedIdentity.provider,
|
||||
providerUserId: options.linkedIdentity.providerUserId,
|
||||
accessTokenEncrypted: options.tokenCustody.encrypt(refreshed.accessToken),
|
||||
refreshTokenEncrypted: options.tokenCustody.encrypt(refreshed.refreshToken ?? refreshToken),
|
||||
scopes: refreshed.scope.length ? refreshed.scope : options.linkedIdentity.scopes,
|
||||
profileJson: options.linkedIdentity.profileJson,
|
||||
createdAt: options.linkedIdentity.createdAt,
|
||||
updatedAt: options.now,
|
||||
};
|
||||
const tokenExpiresAt = refreshed.expiresAt ?? options.linkedIdentity.tokenExpiresAt;
|
||||
if (tokenExpiresAt !== undefined) {
|
||||
upsertInput.tokenExpiresAt = tokenExpiresAt;
|
||||
}
|
||||
if (options.linkedIdentity.username !== undefined) {
|
||||
upsertInput.username = options.linkedIdentity.username;
|
||||
}
|
||||
if (options.linkedIdentity.displayName !== undefined) {
|
||||
upsertInput.displayName = options.linkedIdentity.displayName;
|
||||
}
|
||||
if (options.linkedIdentity.primaryEmail !== undefined) {
|
||||
upsertInput.primaryEmail = options.linkedIdentity.primaryEmail;
|
||||
}
|
||||
|
||||
return options.store.upsertLinkedIdentity(upsertInput);
|
||||
}
|
||||
|
||||
function storedIdentityToResolvedIdentity(identity: StoredLinkedIdentity) {
|
||||
const resolved = {
|
||||
provider: identity.provider,
|
||||
|
|
@ -426,7 +392,6 @@ async function evaluateStoredEntitlements(options: {
|
|||
appSlug: AppSlug;
|
||||
accountId: string;
|
||||
entitlementPolicies: OAuthEntitlementPolicy[];
|
||||
now: string;
|
||||
}): Promise<string[]> {
|
||||
const profile = getAppProfile(options.appSlug);
|
||||
if (!profile) {
|
||||
|
|
@ -436,19 +401,13 @@ async function evaluateStoredEntitlements(options: {
|
|||
const facts: string[] = [];
|
||||
|
||||
for (const storedIdentity of linkedIdentities) {
|
||||
if (!profile.entitlementSources.includes(storedIdentity.provider) || !storedIdentity.accessTokenEncrypted) {
|
||||
if (!profile.entitlementSources.includes(storedIdentity.provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({
|
||||
config: options.config,
|
||||
store: options.store,
|
||||
tokenCustody: options.tokenCustody,
|
||||
oauthRuntimes: options.oauthRuntimes,
|
||||
linkedIdentity: storedIdentity,
|
||||
now: options.now,
|
||||
});
|
||||
if (!linkedIdentity.accessTokenEncrypted) {
|
||||
const refreshed = await refreshManagedCredential(options, storedIdentity);
|
||||
const linkedIdentity = refreshed.identity;
|
||||
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -532,13 +491,23 @@ export async function startOAuthFlow(
|
|||
const { config, keys } = ctx;
|
||||
const profile = getAppProfile(input.appSlug);
|
||||
if (!profile) {
|
||||
return { statusCode: 404, body: { error: "unknown_app", detail: `No app '${input.appSlug}' is registered.` } };
|
||||
return { statusCode: 404, body: { error: "unknown_app", detail: "No app is registered under that slug." } };
|
||||
}
|
||||
|
||||
if (!supportsProvider(profile, provider)) {
|
||||
if (!admitsProviderForMode(profile, provider, input.mode)) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { error: "provider_not_supported_for_app", provider, appSlug: profile.slug },
|
||||
body: { error: "provider_not_supported_for_app", provider, mode: input.mode, appSlug: profile.slug },
|
||||
};
|
||||
}
|
||||
|
||||
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: "creator_connection_required",
|
||||
detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -690,7 +659,6 @@ export async function refreshAppSession(
|
|||
appSlug,
|
||||
accountId: refreshClaim.account_id,
|
||||
entitlementPolicies,
|
||||
now: nowIso,
|
||||
});
|
||||
const issueInput: IssueAccessClaimInput = {
|
||||
appSlug,
|
||||
|
|
@ -736,6 +704,31 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
...(options.oauthRuntimes ?? {}),
|
||||
};
|
||||
const app = Fastify({ logger: false });
|
||||
// Fastify's default handlers answer with the error's message, and messages
|
||||
// carry input: pg and Node name the database host or the database (which a
|
||||
// misbound URL fills with part of a secret), the default not-found answer
|
||||
// quotes the path, and any error thrown with a 4xx status says whatever its
|
||||
// thrower wrote. So every framework answer here is built from fixed text,
|
||||
// the error code and the route's own schema, never from the message.
|
||||
app.setErrorHandler((error: FastifyError, request, reply) => {
|
||||
const status = error.statusCode !== undefined && error.statusCode >= 400 && error.statusCode < 600 ? error.statusCode : 500;
|
||||
const code = typeof error.code === "string" ? error.code : "error";
|
||||
reply.code(status);
|
||||
if (status >= 500) {
|
||||
console.error(`Heimdall ${request.method} ${request.routeOptions.url ?? "(no route)"} failed (${code}).`);
|
||||
return { error: "internal_error" };
|
||||
}
|
||||
if (error.validation) {
|
||||
// Ajv's messages and schema paths come from the route's schema, not the request.
|
||||
const failures = error.validation.map((failure) => `${failure.schemaPath} ${failure.message ?? failure.keyword}`);
|
||||
return { error: "invalid_request", detail: `${error.validationContext ?? "request"}: ${failures.join("; ")}` };
|
||||
}
|
||||
return { error: "invalid_request", code };
|
||||
});
|
||||
app.setNotFoundHandler((_request, reply) => {
|
||||
reply.code(404);
|
||||
return { error: "not_found" };
|
||||
});
|
||||
app.decorate("heimdallContext", { config, keys, store, oauthRuntimes, tokenCustody });
|
||||
app.addHook("onClose", async () => {
|
||||
await store.close();
|
||||
|
|
@ -788,7 +781,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
const profile = getAppProfile(request.params.appSlug);
|
||||
if (!profile) {
|
||||
reply.code(404);
|
||||
return { error: "unknown_app", detail: `No app '${request.params.appSlug}' is registered.` };
|
||||
return { error: "unknown_app", detail: "No app is registered under that slug." };
|
||||
}
|
||||
return serializeAppProfile(profile);
|
||||
}
|
||||
|
|
@ -934,6 +927,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
};
|
||||
|
||||
if (request.query.error) {
|
||||
const providerError = relayedProviderText(request.query.error, 64);
|
||||
const providerErrorDescription = relayedProviderText(request.query.error_description, 256);
|
||||
const relayedDescription = providerErrorDescription ?? providerError;
|
||||
const relayedDescriptionField = relayedDescription ? { errorDescription: relayedDescription } : {};
|
||||
await denyBrowserAttempt("provider_error");
|
||||
if (handoff.kind === "backend_callback") {
|
||||
await maybeDeliverBackendHandoff(handoff, {
|
||||
|
|
@ -948,7 +945,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
returnTo: statePayload.return_to,
|
||||
connection: statePayload.connection,
|
||||
error: "provider_error",
|
||||
errorDescription: request.query.error_description ?? request.query.error,
|
||||
...relayedDescriptionField,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -964,7 +961,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
returnTo: statePayload.return_to,
|
||||
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
|
||||
error: "provider_error",
|
||||
errorDescription: request.query.error_description ?? request.query.error,
|
||||
...relayedDescriptionField,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -973,8 +970,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
return {
|
||||
error: "provider_error",
|
||||
provider: request.params.provider,
|
||||
providerError: request.query.error,
|
||||
providerErrorDescription: request.query.error_description,
|
||||
providerError,
|
||||
providerErrorDescription,
|
||||
returnTo: statePayload.return_to,
|
||||
};
|
||||
}
|
||||
|
|
@ -1021,6 +1018,11 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
const runtime = oauthRuntimes[request.params.provider];
|
||||
|
||||
try {
|
||||
const callbackProfile = getAppProfile(statePayload.app_slug);
|
||||
const mint = callbackProfile ? callbackMint(callbackProfile, request.params.provider, statePayload.mode, statePayload.connection) : "refused";
|
||||
if (mint === "refused") {
|
||||
throw new Error("The signed state names a mode the provider has no role for in this app.");
|
||||
}
|
||||
const tokenSet = await runtime.exchangeAuthorizationCode({
|
||||
config,
|
||||
code: request.query.code,
|
||||
|
|
@ -1106,6 +1108,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
accountId: account.id,
|
||||
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
|
||||
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
|
||||
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
|
||||
};
|
||||
if (account.displayName !== undefined) {
|
||||
issueInput.displayName = account.displayName;
|
||||
|
|
@ -1117,6 +1120,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
store,
|
||||
input: issueInput,
|
||||
});
|
||||
// A custody-only completion carries no refresh token: the refresh
|
||||
// route recomputes capabilities from the account's facts, so one
|
||||
// would turn the capability-free session into a viewer session.
|
||||
const { refreshToken: issuedRefreshToken, refresh: issuedRefresh, ...issuedWithoutRefresh } = issued;
|
||||
const delivered =
|
||||
mint === "custody_only"
|
||||
? issuedWithoutRefresh
|
||||
: { ...issuedWithoutRefresh, refreshToken: issuedRefreshToken, refresh: issuedRefresh };
|
||||
|
||||
await store.createAuditEvent({
|
||||
accountId: account.id,
|
||||
|
|
@ -1156,7 +1167,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
entitlements: entitlementEvaluation,
|
||||
returnTo: statePayload.return_to,
|
||||
connection: statePayload.connection,
|
||||
...issued,
|
||||
...delivered,
|
||||
};
|
||||
if (handoff.kind === "backend_callback") {
|
||||
await maybeDeliverBackendHandoff(handoff, {
|
||||
|
|
@ -1172,7 +1183,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
connection: statePayload.connection,
|
||||
account: accountSummary,
|
||||
entitlements: entitlementEvaluation,
|
||||
...issued,
|
||||
...delivered,
|
||||
});
|
||||
|
||||
await store.createAuditEvent({
|
||||
|
|
@ -1251,10 +1262,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
sessionId: issued.session.sessionId,
|
||||
appSlug: statePayload.app_slug,
|
||||
eventType: "auth_completion_created",
|
||||
// Never the completion code: until it expires, the unauthenticated
|
||||
// redeem route turns it into the session. The event's sessionId
|
||||
// already names the completion, one per callback.
|
||||
eventPayloadJson: {
|
||||
provider: request.params.provider,
|
||||
mode: statePayload.mode,
|
||||
completionCode: completion.code,
|
||||
expiresAt: completion.expiresAt,
|
||||
},
|
||||
createdAt: nowIso,
|
||||
|
|
@ -1288,7 +1301,13 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
...completionPayload,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "OAuth callback failed.";
|
||||
// Neither the answer nor the audit event carries the error's text.
|
||||
// This path catches store and provider failures: a Postgres or
|
||||
// resolver error names a constraint, the host or the database, and
|
||||
// JSON.parse quotes the provider's response body. The audit event
|
||||
// keeps the error's class, its code, and a provider's HTTP status.
|
||||
const status = (error as { status?: unknown }).status;
|
||||
const failure = "The provider callback could not be completed.";
|
||||
const nowIso = new Date().toISOString();
|
||||
await denyBrowserAttempt("oauth_callback_failed");
|
||||
await store.createAuditEvent({
|
||||
|
|
@ -1297,7 +1316,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
eventPayloadJson: {
|
||||
provider: request.params.provider,
|
||||
mode: statePayload.mode,
|
||||
error: message,
|
||||
...errorIdentity(error),
|
||||
providerStatus: typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,
|
||||
},
|
||||
createdAt: nowIso,
|
||||
});
|
||||
|
|
@ -1316,7 +1336,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
returnTo: statePayload.return_to,
|
||||
connection: statePayload.connection,
|
||||
error: "oauth_callback_failed",
|
||||
errorDescription: message,
|
||||
errorDescription: failure,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
|
@ -1333,7 +1353,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
returnTo: statePayload.return_to,
|
||||
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
|
||||
error: "oauth_callback_failed",
|
||||
errorDescription: message,
|
||||
errorDescription: failure,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -1341,10 +1361,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
reply.code(502);
|
||||
return {
|
||||
error: "oauth_callback_failed",
|
||||
// Deliberately not `message`: this path catches store failures, and a
|
||||
// Postgres unique violation would otherwise hand the caller a
|
||||
// constraint name. The full text is in the audit event above.
|
||||
detail: "The provider callback could not be completed.",
|
||||
detail: failure,
|
||||
provider: request.params.provider,
|
||||
appSlug: statePayload.app_slug,
|
||||
returnTo: statePayload.return_to,
|
||||
|
|
@ -1392,7 +1409,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
eventPayloadJson: {
|
||||
provider: completion.provider,
|
||||
mode: completion.mode,
|
||||
completionCode: completion.code,
|
||||
},
|
||||
createdAt: nowIso,
|
||||
});
|
||||
|
|
@ -1544,7 +1560,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
const profile = getAppProfile(request.params.appSlug);
|
||||
if (!profile) {
|
||||
reply.code(404);
|
||||
return { error: "unknown_app", detail: `No app '${request.params.appSlug}' is registered.` };
|
||||
return { error: "unknown_app", detail: "No app is registered under that slug." };
|
||||
}
|
||||
if (!resolveAppCaller(config, profile.slug, request.headers)) {
|
||||
reply.code(401);
|
||||
|
|
@ -1562,21 +1578,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
}
|
||||
|
||||
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, "patreon");
|
||||
if (!storedLinkedIdentity?.accessTokenEncrypted) {
|
||||
if (!storedLinkedIdentity) {
|
||||
reply.code(404);
|
||||
return { error: "patreon_identity_not_linked" };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({
|
||||
config,
|
||||
store,
|
||||
tokenCustody,
|
||||
oauthRuntimes,
|
||||
linkedIdentity: storedLinkedIdentity,
|
||||
now,
|
||||
});
|
||||
if (!linkedIdentity.accessTokenEncrypted) {
|
||||
const refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
|
||||
const linkedIdentity = refreshed.identity;
|
||||
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
|
||||
reply.code(404);
|
||||
return { error: "patreon_identity_not_linked" };
|
||||
}
|
||||
|
|
@ -1632,7 +1642,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
},
|
||||
body,
|
||||
});
|
||||
const bifrostBody = await bifrostResponse.text();
|
||||
// Bifrost's answer is kept by its status alone, in the audit event and
|
||||
// in the reply: another service's body can quote the signed fact or
|
||||
// its own configuration, and both of those are stored and relayed.
|
||||
await bifrostResponse.body?.cancel();
|
||||
|
||||
await store.createAuditEvent({
|
||||
accountId: request.body.accountId,
|
||||
|
|
@ -1643,7 +1656,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
requiredTierTitle: request.body.requiredTierTitle,
|
||||
providerEventId: supportFact.providerEventId,
|
||||
bifrostStatus: bifrostResponse.status,
|
||||
bifrostResponse: bifrostBody,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
|
@ -1653,14 +1665,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
return {
|
||||
error: "bifrost_patron_support_rejected",
|
||||
statusCode: bifrostResponse.status,
|
||||
detail: bifrostBody,
|
||||
detail: "Bifrost refused the patron support fact.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "synced",
|
||||
supportFact,
|
||||
bifrostResponse: bifrostBody,
|
||||
bifrostStatus: bifrostResponse.status,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
@ -1695,7 +1707,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
const profile = getAppProfile(request.params.appSlug);
|
||||
if (!profile) {
|
||||
reply.code(404);
|
||||
return { error: "unknown_app", detail: `No app '${request.params.appSlug}' is registered.` };
|
||||
return { error: "unknown_app", detail: "No app is registered under that slug." };
|
||||
}
|
||||
if (!resolveAppCaller(config, profile.slug, request.headers)) {
|
||||
reply.code(401);
|
||||
|
|
@ -1712,22 +1724,24 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
}
|
||||
|
||||
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, request.body.provider);
|
||||
if (!storedLinkedIdentity?.accessTokenEncrypted) {
|
||||
if (!storedLinkedIdentity) {
|
||||
reply.code(404);
|
||||
return { error: "managed_credential_not_found" };
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({
|
||||
config,
|
||||
store,
|
||||
tokenCustody,
|
||||
oauthRuntimes,
|
||||
linkedIdentity: storedLinkedIdentity,
|
||||
now,
|
||||
});
|
||||
if (!linkedIdentity.accessTokenEncrypted) {
|
||||
reply.code(404);
|
||||
return { error: "managed_credential_not_found" };
|
||||
let refreshed;
|
||||
try {
|
||||
refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedCredentialRefreshFailed) {
|
||||
reply.code(502);
|
||||
return { error: error.code, provider: error.provider };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const linkedIdentity = refreshed.identity;
|
||||
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
|
||||
reply.code(410);
|
||||
return { error: "managed_credential_revoked", provider: linkedIdentity.provider };
|
||||
}
|
||||
|
||||
const response: {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,13 @@ interface BackendHandoffBase {
|
|||
connection: OAuthConnectionBinding | null;
|
||||
}
|
||||
|
||||
export interface BackendHandoffSuccessPayload extends BackendHandoffBase, IssuedAccessClaimResult {
|
||||
/** A custody-only completion (callbackMint) carries no refresh token. */
|
||||
type RefreshFields = "refreshToken" | "refresh";
|
||||
|
||||
export interface BackendHandoffSuccessPayload
|
||||
extends BackendHandoffBase,
|
||||
Omit<IssuedAccessClaimResult, RefreshFields>,
|
||||
Partial<Pick<IssuedAccessClaimResult, RefreshFields>> {
|
||||
status: "success";
|
||||
mode: OAuthMode;
|
||||
account: AccountSummary;
|
||||
|
|
@ -44,10 +50,10 @@ export async function deliverBackendHandoff(callbackUrl: string, payload: Backen
|
|||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
// The status alone: the app's answer body can quote the handoff, which
|
||||
// carries tokens, and whatever catches this may record or print it.
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(
|
||||
`Backend handoff delivery failed (status ${response.status}): ${body || "empty response"}`
|
||||
);
|
||||
await response.body?.cancel();
|
||||
throw new Error(`Backend handoff delivery failed (status ${response.status}).`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string {
|
|||
}
|
||||
}
|
||||
|
||||
// URLSearchParams and the URL serializer percent-encode `"`, `<` and `>`,
|
||||
// so the page can place this URL in an attribute as it is. Every other value
|
||||
// in the page goes through serializeForScript; there is no template engine.
|
||||
url.hash = fragment.toString();
|
||||
return url.toString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ export interface IssueAccessClaimInput {
|
|||
displayName?: string;
|
||||
linkedIdentities: LinkedIdentityInput[];
|
||||
facts: string[];
|
||||
/**
|
||||
* The claim names the account and its identities and carries no facts and
|
||||
* no capabilities (a managed-credential connect for a provider that is not
|
||||
* an identity provider). The facts are not even `identity.authenticated`.
|
||||
*/
|
||||
withoutCapabilities?: boolean;
|
||||
sessionId?: string;
|
||||
accessRevision?: number;
|
||||
ttlSeconds?: number;
|
||||
|
|
@ -80,11 +86,15 @@ export async function issueAccessClaim(options: {
|
|||
// would name an app no verifier can resolve a profile for.
|
||||
throw new Error(`Cannot issue an access claim for unknown app '${options.input.appSlug}'.`);
|
||||
}
|
||||
const facts = normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts);
|
||||
const factSet = new Set(facts);
|
||||
const withoutCapabilities = options.input.withoutCapabilities === true;
|
||||
const facts = withoutCapabilities
|
||||
? []
|
||||
: normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts);
|
||||
// Every shared capability needs a non-empty anyOf (capability-rules), so an
|
||||
// empty fact set yields no capabilities: facts are the one owner of that.
|
||||
const sharedCapabilities = evaluateSharedCapabilities(profile, {
|
||||
accountId: options.input.accountId,
|
||||
facts: factSet,
|
||||
facts: new Set(facts),
|
||||
identities: options.input.linkedIdentities,
|
||||
});
|
||||
|
||||
|
|
@ -176,6 +186,8 @@ export async function issueAccessClaim(options: {
|
|||
kid: options.keys.kid,
|
||||
},
|
||||
sharedCapabilities,
|
||||
hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
|
||||
hybridCapabilities: withoutCapabilities
|
||||
? []
|
||||
: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { appSlugs, providers, type AppSlug, type Provider } from "./contracts.js";
|
||||
|
|
@ -35,7 +36,6 @@ export interface HeimdallConfig {
|
|||
refreshTtlSeconds: number;
|
||||
stateTtlSeconds: number;
|
||||
completionTtlSeconds: number;
|
||||
signingPrivateKeyPem?: string;
|
||||
signingPrivateKeyPath?: string;
|
||||
bootstrapSigningPrivateKeyOnMissing: boolean;
|
||||
signingKeyId?: string;
|
||||
|
|
@ -121,11 +121,64 @@ function readIdunnCandidateBind(
|
|||
return { host, port };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one secret input. Its value lives in a file named by `NAME_FILE`: under
|
||||
* Idunn that is a systemd credential, so the value never enters a binding, a
|
||||
* unit, a plan or the process environment. One trailing newline is stripped.
|
||||
*
|
||||
* LEGACY SHIM, deleted with the legacy EnvironmentFile unit (secret-files
|
||||
* Cut 5): when `NAME_FILE` is absent, the plaintext `NAME` is read instead.
|
||||
* Setting both is refused, so one input has one owner. Under Idunn the recipe
|
||||
* declares only the `_FILE` names, so the plaintext form cannot be bound there.
|
||||
*
|
||||
* `NAME_FILE` must be an absolute path, and a value that is empty or only
|
||||
* whitespace is refused: a blank secret is a misconfiguration, never "unset".
|
||||
*
|
||||
* Errors name only the variable and an error code. They never carry the path,
|
||||
* a value or a file's contents: Idunn admits any string for a declared name, so
|
||||
* a secret bound into a `_FILE` slot by mistake would otherwise be printed.
|
||||
*/
|
||||
export function readSecretInput(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const fileVariable = `${name}_FILE`;
|
||||
const filePath = env[fileVariable];
|
||||
const plaintext = env[name];
|
||||
|
||||
if (filePath !== undefined && plaintext !== undefined) {
|
||||
throw new Error(`${name} and ${fileVariable} are both set; set only ${fileVariable}.`);
|
||||
}
|
||||
|
||||
let value: string;
|
||||
let source: string;
|
||||
if (filePath === undefined) {
|
||||
if (plaintext === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
value = plaintext;
|
||||
source = name;
|
||||
} else {
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
throw new Error(`${fileVariable} is not an absolute path.`);
|
||||
}
|
||||
try {
|
||||
value = readFileSync(filePath, "utf8").replace(/\r?\n$/, "");
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code ?? "read error";
|
||||
throw new Error(`${fileVariable} could not be read (${code}).`);
|
||||
}
|
||||
source = fileVariable;
|
||||
}
|
||||
|
||||
if (value.trim() === "") {
|
||||
throw new Error(`${source} is empty.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readProviderConfig(env: NodeJS.ProcessEnv, provider: Provider): ProviderClientConfig {
|
||||
const prefix = `GC_ACCESS_PROVIDER_${provider.toUpperCase()}`;
|
||||
const config: ProviderClientConfig = {};
|
||||
const clientId = env[`${prefix}_CLIENT_ID`];
|
||||
const clientSecret = env[`${prefix}_CLIENT_SECRET`];
|
||||
const clientSecret = readSecretInput(env, `${prefix}_CLIENT_SECRET`);
|
||||
|
||||
if (clientId) {
|
||||
config.clientId = clientId;
|
||||
|
|
@ -201,8 +254,9 @@ export function loadConfig(
|
|||
const issuer = trimTrailingSlash(env.GC_ACCESS_ISSUER ?? publicBaseUrl);
|
||||
const dataRoot =
|
||||
stateRootArgument ?? env.GC_ACCESS_DATA_ROOT ?? path.join(workspaceRoot, ".heimdall-data");
|
||||
const databaseUrl = readSecretInput(env, "GC_ACCESS_DATABASE_URL");
|
||||
const storageBackend =
|
||||
env.GC_ACCESS_STORAGE_BACKEND === "postgres" || env.GC_ACCESS_DATABASE_URL ? "postgres" : "memory";
|
||||
env.GC_ACCESS_STORAGE_BACKEND === "postgres" || databaseUrl ? "postgres" : "memory";
|
||||
const providersConfig = Object.fromEntries(
|
||||
providers.map((provider) => [provider, readProviderConfig(env, provider)])
|
||||
) as Record<Provider, ProviderClientConfig>;
|
||||
|
|
@ -210,7 +264,7 @@ export function loadConfig(
|
|||
appSlugs
|
||||
.map((appSlug) => {
|
||||
const envKey = `GC_ACCESS_APP_${appSlug.toUpperCase()}_SHARED_SECRET`;
|
||||
return [appSlug, env[envKey]];
|
||||
return [appSlug, readSecretInput(env, envKey)];
|
||||
})
|
||||
.filter(([, value]) => Boolean(value))
|
||||
) as Partial<Record<AppSlug, string>>;
|
||||
|
|
@ -278,33 +332,38 @@ export function loadConfig(
|
|||
config.odinCultMeshUri = odinCultMeshUri;
|
||||
}
|
||||
|
||||
const signingPrivateKeyPem = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PEM?.replace(/\\n/g, "\n");
|
||||
if (signingPrivateKeyPem) {
|
||||
config.signingPrivateKeyPem = signingPrivateKeyPem;
|
||||
}
|
||||
|
||||
if (env.GC_ACCESS_SIGNING_PRIVATE_KEY_PATH) {
|
||||
config.signingPrivateKeyPath = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PATH;
|
||||
// The signing key path follows the secret reader's rules: it must be
|
||||
// absolute, and no error repeats it. An operator moving off the deleted
|
||||
// inline PEM input who binds the key text here would otherwise have the
|
||||
// private key printed back into the journal.
|
||||
const signingPrivateKeyPath = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PATH;
|
||||
if (signingPrivateKeyPath) {
|
||||
if (!path.isAbsolute(signingPrivateKeyPath)) {
|
||||
throw new Error("GC_ACCESS_SIGNING_PRIVATE_KEY_PATH is not an absolute path.");
|
||||
}
|
||||
config.signingPrivateKeyPath = signingPrivateKeyPath;
|
||||
}
|
||||
|
||||
if (env.GC_ACCESS_SIGNING_KEY_ID) {
|
||||
config.signingKeyId = env.GC_ACCESS_SIGNING_KEY_ID;
|
||||
}
|
||||
|
||||
if (env.GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64) {
|
||||
config.tokenEncryptionKeyBase64 = env.GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64;
|
||||
const tokenEncryptionKeyBase64 = readSecretInput(env, "GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64");
|
||||
if (tokenEncryptionKeyBase64) {
|
||||
config.tokenEncryptionKeyBase64 = tokenEncryptionKeyBase64;
|
||||
}
|
||||
|
||||
if (env.GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT) {
|
||||
config.bifrostPatronSupportEndpoint = env.GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT;
|
||||
}
|
||||
|
||||
if (env.GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET) {
|
||||
config.bifrostPatronSupportSecret = env.GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET;
|
||||
const bifrostPatronSupportSecret = readSecretInput(env, "GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET");
|
||||
if (bifrostPatronSupportSecret) {
|
||||
config.bifrostPatronSupportSecret = bifrostPatronSupportSecret;
|
||||
}
|
||||
|
||||
if (env.GC_ACCESS_DATABASE_URL) {
|
||||
config.storage.databaseUrl = env.GC_ACCESS_DATABASE_URL;
|
||||
if (databaseUrl) {
|
||||
config.storage.databaseUrl = databaseUrl;
|
||||
}
|
||||
|
||||
return config;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify"] as const;
|
||||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const;
|
||||
|
||||
export type Provider = (typeof providers)[number];
|
||||
|
||||
|
|
|
|||
|
|
@ -12,15 +12,11 @@ export interface TokenCustody {
|
|||
}
|
||||
|
||||
function decodeConfiguredKey(base64Value: string): Buffer {
|
||||
const normalized = base64Value.trim();
|
||||
if (!normalized) {
|
||||
throw new Error("GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64 is set but empty.");
|
||||
}
|
||||
|
||||
const key = Buffer.from(normalized, "base64");
|
||||
// readSecretInput refuses a blank key; a short one fails the length check.
|
||||
const key = Buffer.from(base64Value.trim(), "base64");
|
||||
if (key.byteLength !== TOKEN_KEY_BYTES) {
|
||||
throw new Error(
|
||||
`GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64 must decode to exactly ${TOKEN_KEY_BYTES} bytes for AES-256-GCM.`
|
||||
`GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE must decode to exactly ${TOKEN_KEY_BYTES} bytes for AES-256-GCM.`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +47,7 @@ export function createTokenCustody(config: HeimdallConfig): TokenCustody {
|
|||
: (() => {
|
||||
if (config.storage.backend === "postgres") {
|
||||
throw new Error(
|
||||
"GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64 is required when Heimdall uses persistent postgres storage."
|
||||
"GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE is required when Heimdall uses persistent postgres storage."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
45
src/index.ts
45
src/index.ts
|
|
@ -1,4 +1,3 @@
|
|||
import path from "node:path";
|
||||
import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js";
|
||||
import { buildApp } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
|
|
@ -7,14 +6,16 @@ import { publishHeimdallOdinState } from "./odin-publication.js";
|
|||
import { startHeimdallPrivateCommandPlane } from "./private-command-plane.js";
|
||||
import type { CultNetOperationServer } from "cultnet-ts";
|
||||
import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js";
|
||||
import {
|
||||
buildPresenceStatement,
|
||||
nextPublisherSequence,
|
||||
readRuntimeBundleFacts,
|
||||
type RuntimeBundleFacts,
|
||||
} from "./runtime-presence.js";
|
||||
import { openPresenceAuthority } from "./runtime-presence.js";
|
||||
|
||||
const config = loadConfig();
|
||||
// Under Idunn the launch's presence keys, bundle facts and publisher sequence
|
||||
// open here, before anything listens. A launch-contract fault throws, uncaught,
|
||||
// and the process exits non-zero with no listener: Idunn fails Warming in
|
||||
// seconds rather than waiting on a candidate that can never sign.
|
||||
const presenceAuthority = config.idunnRuntimeBundlePath
|
||||
? await openPresenceAuthority(config)
|
||||
: undefined;
|
||||
const app = await buildApp({ config });
|
||||
const versePulseIntervalMs = 60_000;
|
||||
const healthPulseIntervalMs = 10_000;
|
||||
|
|
@ -70,13 +71,6 @@ async function publishVerseState(): Promise<void> {
|
|||
await publishHeimdallOdinState(config, pulse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read once and keep: the bundle is Idunn's immutable record of this launch and
|
||||
* cannot change while the process lives, so re-reading it every pulse would be
|
||||
* work that can only ever return the same answer or fail.
|
||||
*/
|
||||
let runtimeBundleFacts: RuntimeBundleFacts | undefined;
|
||||
|
||||
async function publishHealthState(): Promise<void> {
|
||||
const pulse = createHeimdallRuntimePulse(config);
|
||||
|
||||
|
|
@ -94,32 +88,17 @@ async function publishHealthState(): Promise<void> {
|
|||
? buildHeimdallHealthDetail(config, pulse)
|
||||
: `Heimdall candidate warming; ${lease.reason}`;
|
||||
|
||||
if (!config.idunnRuntimeBundlePath) {
|
||||
if (!presenceAuthority) {
|
||||
// Outside Idunn there is no launch to attest and nothing that admits the
|
||||
// statement; publishing is simply not part of that world.
|
||||
return;
|
||||
}
|
||||
runtimeBundleFacts ??= await readRuntimeBundleFacts(config.idunnRuntimeBundlePath);
|
||||
|
||||
const observedAtUnixMillis = Date.parse(pulse.updatedAt);
|
||||
const presence = await buildPresenceStatement({
|
||||
bundle: runtimeBundleFacts,
|
||||
state: lease.mayWrite ? "active" : "warming",
|
||||
detail,
|
||||
boundEndpoint: `http://${config.host}:${config.port}`,
|
||||
// A warming candidate holds no lease and must not claim one; the contract
|
||||
// refuses the pair, and Idunn needs the warming statement to promote it.
|
||||
writeLeaseSha256: null,
|
||||
observedAtUnixMillis,
|
||||
publisherSequence: await nextPublisherSequence(
|
||||
path.join(config.dataRoot, "runtime-presence-sequence"),
|
||||
),
|
||||
providerHealthIdentityPath: config.providerHealthIdentityPath,
|
||||
});
|
||||
const state = lease.mayWrite ? "active" : "warming";
|
||||
const presence = presenceAuthority.sign(state, detail, Date.parse(pulse.updatedAt));
|
||||
|
||||
await publishIdunnRudpHealth(config, {
|
||||
daemonId: config.daemonId,
|
||||
state: lease.mayWrite ? "active" : "warming",
|
||||
state,
|
||||
detail,
|
||||
observedAt: pulse.updatedAt,
|
||||
presence,
|
||||
|
|
|
|||
181
src/managed-credential.ts
Normal file
181
src/managed-credential.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { type HeimdallConfig } from "./config.js";
|
||||
import { type Provider } from "./contracts.js";
|
||||
import { type TokenCustody } from "./custody.js";
|
||||
import { type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js";
|
||||
import { providerCatalog } from "./providers.js";
|
||||
import { type HeimdallStore } from "./store/index.js";
|
||||
import { type CredentialDecision, type StoredLinkedIdentity } from "./store/types.js";
|
||||
|
||||
/** A stored access token this close to expiry (or past it) is refreshed before use. */
|
||||
export const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The longest a refresh may hold the row lock: the provider call is aborted
|
||||
* at this bound, and every waiter holds a pooled connection for at most this
|
||||
* long behind the holder.
|
||||
*/
|
||||
export const MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface ManagedCredentialContext {
|
||||
config: HeimdallConfig;
|
||||
store: HeimdallStore;
|
||||
tokenCustody: TokenCustody;
|
||||
oauthRuntimes: OAuthRuntimeRegistry;
|
||||
clock?: () => number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export type ManagedCredentialRevocationReason = "provider_revoked" | "rotation_missing";
|
||||
|
||||
/** `revoked` means the identity holds no access token: the provider took the grant back, or it was never held. */
|
||||
export type ManagedCredentialOutcome =
|
||||
| { kind: "current"; identity: StoredLinkedIdentity }
|
||||
| { kind: "revoked"; identity: StoredLinkedIdentity };
|
||||
|
||||
/**
|
||||
* The provider could not be asked, or answered with something that is not a
|
||||
* grant verdict (invalid_client, 5xx, timeout, malformed body). The stored
|
||||
* tokens are untouched. Carries the provider and nothing the provider said.
|
||||
*/
|
||||
export class ManagedCredentialRefreshFailed extends Error {
|
||||
readonly code = "managed_credential_refresh_failed";
|
||||
readonly provider: Provider;
|
||||
|
||||
constructor(provider: Provider) {
|
||||
super(`${providerCatalog[provider].displayName} managed credential refresh failed.`);
|
||||
this.name = "ManagedCredentialRefreshFailed";
|
||||
this.provider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
function dueForRefresh(identity: StoredLinkedIdentity, runtime: OAuthProviderRuntime, now: number): boolean {
|
||||
if (!identity.tokenExpiresAt || !identity.refreshTokenEncrypted || !runtime.refreshAccessToken) {
|
||||
return false;
|
||||
}
|
||||
return Date.parse(identity.tokenExpiresAt) - now <= MANAGED_CREDENTIAL_REFRESH_SKEW_MS;
|
||||
}
|
||||
|
||||
function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity {
|
||||
const { accessTokenEncrypted: _a, refreshTokenEncrypted: _r, tokenExpiresAt: _e, ...rest } = identity;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes in flight in this process, per store and linked identity. The row
|
||||
* lock serialises processes; it cannot tell a waiter that queued behind a
|
||||
* failed holder from a caller that arrived after the failure, so each waiter
|
||||
* would present the same refresh token again. Callers that arrive while an
|
||||
* attempt is in flight join it and receive its outcome, success or failure.
|
||||
* The entry lives exactly as long as the attempt: a later request retries.
|
||||
*
|
||||
* Known residual risk (docs/service-contract.md): one presentation per
|
||||
* process per burst, so N processes may present one token N times. The
|
||||
* cross-process failed-refresh marker that would close it is
|
||||
* joystick:follow_up:cross-process-failed-refresh-marker.
|
||||
*/
|
||||
const refreshesInFlight = new WeakMap<HeimdallStore, Map<string, Promise<ManagedCredentialOutcome>>>();
|
||||
|
||||
/**
|
||||
* The one refresh policy. `snapshot` is a hint: it may skip the lock when no
|
||||
* refresh is due and never decides one. The row's lock owns when a refresh
|
||||
* token is presented across processes; this process makes one attempt per
|
||||
* identity at a time; the decision is made on the row as read under the lock.
|
||||
*/
|
||||
export async function refreshManagedCredential(
|
||||
ctx: ManagedCredentialContext,
|
||||
snapshot: StoredLinkedIdentity
|
||||
): Promise<ManagedCredentialOutcome> {
|
||||
const clock = ctx.clock ?? Date.now;
|
||||
const runtime = ctx.oauthRuntimes[snapshot.provider];
|
||||
|
||||
if (!snapshot.accessTokenEncrypted) {
|
||||
return { kind: "revoked", identity: snapshot };
|
||||
}
|
||||
if (!dueForRefresh(snapshot, runtime, clock())) {
|
||||
return { kind: "current", identity: snapshot };
|
||||
}
|
||||
|
||||
let inFlight = refreshesInFlight.get(ctx.store);
|
||||
if (!inFlight) {
|
||||
inFlight = new Map();
|
||||
refreshesInFlight.set(ctx.store, inFlight);
|
||||
}
|
||||
const joined = inFlight.get(snapshot.id);
|
||||
if (joined) {
|
||||
return joined;
|
||||
}
|
||||
const attempt = refreshUnderLock(ctx, snapshot).finally(() => {
|
||||
inFlight.delete(snapshot.id);
|
||||
});
|
||||
inFlight.set(snapshot.id, attempt);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
async function refreshUnderLock(
|
||||
ctx: ManagedCredentialContext,
|
||||
snapshot: StoredLinkedIdentity
|
||||
): Promise<ManagedCredentialOutcome> {
|
||||
const clock = ctx.clock ?? Date.now;
|
||||
const provider = snapshot.provider;
|
||||
const runtime = ctx.oauthRuntimes[provider];
|
||||
|
||||
let revocation: ManagedCredentialRevocationReason | undefined;
|
||||
const decide = async (locked: StoredLinkedIdentity): Promise<CredentialDecision> => {
|
||||
if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) {
|
||||
return { kind: "keep" };
|
||||
}
|
||||
const updatedAt = new Date(clock()).toISOString();
|
||||
const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!);
|
||||
|
||||
let refreshed;
|
||||
try {
|
||||
refreshed = await runtime.refreshAccessToken!({
|
||||
config: ctx.config,
|
||||
refreshToken,
|
||||
signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") {
|
||||
revocation = "provider_revoked";
|
||||
return { kind: "revoke", updatedAt };
|
||||
}
|
||||
throw new ManagedCredentialRefreshFailed(provider);
|
||||
}
|
||||
|
||||
let refreshTokenEncrypted: string;
|
||||
if (refreshed.refreshToken) {
|
||||
refreshTokenEncrypted = ctx.tokenCustody.encrypt(refreshed.refreshToken);
|
||||
} else if (providerCatalog[provider].rotatesRefreshTokens) {
|
||||
revocation = "rotation_missing";
|
||||
return { kind: "revoke", updatedAt };
|
||||
} else {
|
||||
refreshTokenEncrypted = locked.refreshTokenEncrypted!;
|
||||
}
|
||||
|
||||
// The refreshed set always carries an expiry (the provider's, or the
|
||||
// descriptor's default lifetime): the old one is already due, and an
|
||||
// unknown one would never come due (dueForRefresh).
|
||||
return {
|
||||
kind: "rotate",
|
||||
accessTokenEncrypted: ctx.tokenCustody.encrypt(refreshed.accessToken),
|
||||
refreshTokenEncrypted,
|
||||
tokenExpiresAt: refreshed.expiresAt,
|
||||
scopes: refreshed.scope.length ? refreshed.scope : locked.scopes,
|
||||
updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
const committed = await ctx.store.updateLinkedIdentityCredential(snapshot.id, decide);
|
||||
if (revocation) {
|
||||
await ctx.store.createAuditEvent({
|
||||
accountId: snapshot.accountId,
|
||||
eventType: "managed_credential_revoked",
|
||||
eventPayloadJson: { provider, reason: revocation },
|
||||
createdAt: new Date(clock()).toISOString(),
|
||||
});
|
||||
}
|
||||
if (!committed) {
|
||||
return { kind: "revoked", identity: withoutTokens(snapshot) };
|
||||
}
|
||||
return { kind: committed.accessTokenEncrypted ? "current" : "revoked", identity: committed };
|
||||
}
|
||||
756
src/oauth.ts
756
src/oauth.ts
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "./contracts.js";
|
||||
import { type HeimdallConfig } from "./config.js";
|
||||
import { entitlementFacts } from "./facts.js";
|
||||
import { providerCatalog } from "./providers.js";
|
||||
import { type CreateEntitlementSnapshotInput } from "./store/types.js";
|
||||
|
||||
export interface OAuthTokenSet {
|
||||
|
|
@ -40,6 +41,9 @@ export interface OAuthCallbackContext {
|
|||
entitlementPolicy: OAuthEntitlementPolicy | null;
|
||||
}
|
||||
|
||||
/** A token set whose access-token expiry is known: the provider's own, or the descriptor's default. */
|
||||
export type ExpiringTokenSet = OAuthTokenSet & { expiresAt: string };
|
||||
|
||||
export interface OAuthProviderRuntime {
|
||||
exchangeAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
|
|
@ -49,7 +53,8 @@ export interface OAuthProviderRuntime {
|
|||
refreshAccessToken?(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
}): Promise<OAuthTokenSet>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ExpiringTokenSet>;
|
||||
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
|
||||
evaluateEntitlements(options: {
|
||||
config: HeimdallConfig;
|
||||
|
|
@ -59,19 +64,75 @@ export interface OAuthProviderRuntime {
|
|||
}): Promise<EntitlementEvaluation>;
|
||||
}
|
||||
|
||||
function buildErrorMessage(status: number, body: string, fallback: string): string {
|
||||
return `${fallback} (status ${status}): ${body || "empty response"}`;
|
||||
const oauthErrorCodes = [
|
||||
"invalid_request",
|
||||
"invalid_client",
|
||||
"invalid_grant",
|
||||
"unauthorized_client",
|
||||
"unsupported_grant_type",
|
||||
"invalid_scope",
|
||||
] as const;
|
||||
export type OAuthErrorCode = (typeof oauthErrorCodes)[number];
|
||||
|
||||
function readOAuthErrorCode(text: string): OAuthErrorCode | undefined {
|
||||
try {
|
||||
const error = (JSON.parse(text) as { error?: unknown } | null)?.error;
|
||||
return oauthErrorCodes.find((code) => code === error);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T>(input: string | URL, init: RequestInit, fallbackError: string): Promise<T> {
|
||||
const response = await fetch(input, init);
|
||||
const text = await response.text();
|
||||
/**
|
||||
* A provider answer Heimdall cannot use: a non-2xx status, or a body that is
|
||||
* not JSON. It carries the status and a code, never the body or the parser's
|
||||
* message: a provider's body can quote the request or hold tokens (JSON.parse
|
||||
* quotes the text around the fault), and whatever catches this may record,
|
||||
* print or relay it.
|
||||
*/
|
||||
export class ProviderResponseError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: "provider_status" | "provider_body_not_json" | "provider_body_not_token_set";
|
||||
/**
|
||||
* The RFC 6749 section 5.2 error code of a refusal, when the provider sent
|
||||
* one from that closed set. The only value taken from a provider's body.
|
||||
*/
|
||||
readonly oauthError: OAuthErrorCode | undefined;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(buildErrorMessage(response.status, text, fallbackError));
|
||||
constructor(status: number, code: ProviderResponseError["code"], failure: string, oauthError?: OAuthErrorCode) {
|
||||
super(`${failure} (status ${status}, ${code}).`);
|
||||
this.name = "ProviderResponseError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.oauthError = oauthError;
|
||||
}
|
||||
}
|
||||
|
||||
return (text ? JSON.parse(text) : {}) as T;
|
||||
/** The JSON body of a provider's answer; refuses any other answer by status and code alone. */
|
||||
async function readProviderJson<T>(response: Response, failure: string): Promise<T> {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
// RFC 6749 section 5.2 sends a grant verdict as 400 only. The same JSON
|
||||
// behind a 401, a 5xx or a gateway page is not one.
|
||||
throw new ProviderResponseError(
|
||||
response.status,
|
||||
"provider_status",
|
||||
failure,
|
||||
response.status === 400 ? readOAuthErrorCode(text) : undefined
|
||||
);
|
||||
}
|
||||
if (!text) {
|
||||
return {} as T;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new ProviderResponseError(response.status, "provider_body_not_json", failure);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T>(input: string | URL, init: RequestInit, failure: string): Promise<T> {
|
||||
return readProviderJson<T>(await fetch(input, init), failure);
|
||||
}
|
||||
|
||||
function normalizeScopes(scope: string | string[] | undefined): string[] {
|
||||
|
|
@ -82,512 +143,133 @@ function normalizeScopes(scope: string | string[] | undefined): string[] {
|
|||
return scope?.split(/\s+/).filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
async function exchangeDiscordAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.discord;
|
||||
export type TokenGrant =
|
||||
| { grant_type: "authorization_code"; code: string; redirect_uri: string }
|
||||
| { grant_type: "refresh_token"; refresh_token: string };
|
||||
|
||||
/**
|
||||
* The one token request: endpoint, client authentication and rotation all come
|
||||
* from the provider's descriptor. `basic` sends the client as an Authorization
|
||||
* header and nothing in the body; `post` sends it in the body. A refresh token
|
||||
* is returned only when the provider sent one; keeping or discarding the old
|
||||
* one is the caller's rule (ProviderDescriptor.rotatesRefreshTokens).
|
||||
*/
|
||||
export async function requestProviderTokens(
|
||||
provider: Provider,
|
||||
config: HeimdallConfig,
|
||||
grant: TokenGrant,
|
||||
signal?: AbortSignal
|
||||
): Promise<ExpiringTokenSet> {
|
||||
const descriptor = providerCatalog[provider];
|
||||
const providerConfig = config.providers[provider];
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Discord OAuth is not fully configured.");
|
||||
throw new Error(`${descriptor.displayName} OAuth is not fully configured.`);
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
redirect_uri: options.redirectUri,
|
||||
});
|
||||
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64");
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://discord.com/api/v10/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
},
|
||||
"Discord token exchange failed"
|
||||
);
|
||||
const body = new URLSearchParams(grant);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (descriptor.tokenEndpointAuthMethod === "client_secret_basic") {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64")}`;
|
||||
} else {
|
||||
body.set("client_id", providerConfig.clientId);
|
||||
body.set("client_secret", providerConfig.clientSecret);
|
||||
}
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
const init: RequestInit = { method: "POST", headers, body };
|
||||
if (signal) {
|
||||
init.signal = signal;
|
||||
}
|
||||
const label = grant.grant_type === "refresh_token" ? "refresh" : "exchange";
|
||||
const failure = `${descriptor.displayName} token ${label} failed`;
|
||||
const response = await fetch(descriptor.tokenEndpoint, init);
|
||||
const answer = await readProviderJson<unknown>(response, failure);
|
||||
// A 2xx is a token set only when it is an object holding a string access
|
||||
// token and no error.
|
||||
if (
|
||||
typeof answer !== "object" ||
|
||||
answer === null ||
|
||||
typeof (answer as { access_token?: unknown }).access_token !== "string" ||
|
||||
!(answer as { access_token: string }).access_token ||
|
||||
(answer as { error?: unknown }).error !== undefined
|
||||
) {
|
||||
throw new ProviderResponseError(response.status, "provider_body_not_token_set", failure);
|
||||
}
|
||||
const tokenResponse = answer as {
|
||||
access_token: string;
|
||||
refresh_token?: unknown;
|
||||
token_type?: string;
|
||||
expires_in?: unknown;
|
||||
scope?: string | string[];
|
||||
};
|
||||
|
||||
// A refresh token that is present must be a string: anything else is a
|
||||
// malformed grant answer, refused here as a typed failure rather than
|
||||
// reaching custody as a TypeError.
|
||||
if (tokenResponse.refresh_token !== undefined && typeof tokenResponse.refresh_token !== "string") {
|
||||
throw new ProviderResponseError(response.status, "provider_body_not_token_set", failure);
|
||||
}
|
||||
|
||||
// Nothing issued here has an unknown expiry: an answer without a usable
|
||||
// expires_in takes the descriptor's default lifetime.
|
||||
const lifetimeSeconds = usableExpiresIn(tokenResponse.expires_in) ?? descriptor.defaultAccessTokenLifetimeSeconds;
|
||||
const tokenSet: ExpiringTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type ?? "Bearer",
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
expiresAt: new Date(Date.now() + lifetimeSeconds * 1000).toISOString(),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function refreshDiscordAccessToken(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.discord;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Discord OAuth is not fully configured.");
|
||||
}
|
||||
/** The longest access token lifetime taken at its word: a year. Anything above is not a lifetime. */
|
||||
const MAX_USABLE_EXPIRES_IN_SECONDS = 365 * 24 * 60 * 60;
|
||||
|
||||
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64");
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://discord.com/api/v10/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: options.refreshToken,
|
||||
}),
|
||||
},
|
||||
"Discord token refresh failed"
|
||||
);
|
||||
/**
|
||||
* A positive finite number of seconds, or a string that is a positive integer,
|
||||
* no longer than a year. A larger value would overflow the Date the expiry is
|
||||
* stored as, so it is unusable and the descriptor's default lifetime applies.
|
||||
*/
|
||||
function usableExpiresIn(value: unknown): number | undefined {
|
||||
const seconds = typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : value;
|
||||
return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 && seconds <= MAX_USABLE_EXPIRES_IN_SECONDS
|
||||
? seconds
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] {
|
||||
return async (options) => {
|
||||
const tokenSet = await requestProviderTokens(provider, options.config, {
|
||||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
redirect_uri: options.redirectUri,
|
||||
});
|
||||
// A rotating provider's refresh token is the only way back to the
|
||||
// credential: a grant that arrives without one would be stored as a row
|
||||
// the first refresh revokes, so the callback fails before it writes.
|
||||
if (providerCatalog[provider].rotatesRefreshTokens && !tokenSet.refreshToken) {
|
||||
throw new ProviderResponseError(200, "provider_body_not_token_set", `${providerCatalog[provider].displayName} token exchange failed`);
|
||||
}
|
||||
return tokenSet;
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function exchangePatreonAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.patreon;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Patreon OAuth is not fully configured.");
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
client_id: providerConfig.clientId,
|
||||
client_secret: providerConfig.clientSecret,
|
||||
redirect_uri: options.redirectUri,
|
||||
});
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://www.patreon.com/api/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
},
|
||||
"Patreon token exchange failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
if (tokenResponse.expires_in) {
|
||||
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
function sharedRuntimeRefresh(provider: Provider): NonNullable<OAuthProviderRuntime["refreshAccessToken"]> {
|
||||
return (options) =>
|
||||
requestProviderTokens(
|
||||
provider,
|
||||
options.config,
|
||||
{ grant_type: "refresh_token", refresh_token: options.refreshToken },
|
||||
options.signal
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshPatreonAccessToken(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.patreon;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Patreon OAuth is not fully configured.");
|
||||
}
|
||||
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://www.patreon.com/api/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: options.refreshToken,
|
||||
client_id: providerConfig.clientId,
|
||||
client_secret: providerConfig.clientSecret,
|
||||
}),
|
||||
},
|
||||
"Patreon token refresh failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
if (tokenResponse.expires_in) {
|
||||
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function exchangeTwitchAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.twitch;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Twitch OAuth is not fully configured.");
|
||||
}
|
||||
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://id.twitch.tv/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: providerConfig.clientId,
|
||||
client_secret: providerConfig.clientSecret,
|
||||
code: options.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: options.redirectUri,
|
||||
}),
|
||||
},
|
||||
"Twitch token exchange failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
if (tokenResponse.expires_in) {
|
||||
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function refreshTwitchAccessToken(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.twitch;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Twitch OAuth is not fully configured.");
|
||||
}
|
||||
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://id.twitch.tv/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: providerConfig.clientId,
|
||||
client_secret: providerConfig.clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: options.refreshToken,
|
||||
}),
|
||||
},
|
||||
"Twitch token refresh failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
if (tokenResponse.expires_in) {
|
||||
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function exchangeYouTubeAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.youtube;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("YouTube OAuth is not fully configured.");
|
||||
}
|
||||
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string | string[];
|
||||
id_token?: string;
|
||||
}>(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: providerConfig.clientId,
|
||||
client_secret: providerConfig.clientSecret,
|
||||
code: options.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: options.redirectUri,
|
||||
}),
|
||||
},
|
||||
"YouTube token exchange failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
if (tokenResponse.expires_in) {
|
||||
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function refreshYouTubeAccessToken(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.youtube;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("YouTube OAuth is not fully configured.");
|
||||
}
|
||||
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: providerConfig.clientId,
|
||||
client_secret: providerConfig.clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: options.refreshToken,
|
||||
}),
|
||||
},
|
||||
"YouTube token refresh failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
if (tokenResponse.expires_in) {
|
||||
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function exchangeSpotifyAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.spotify;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Spotify OAuth is not fully configured.");
|
||||
}
|
||||
|
||||
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64");
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://accounts.spotify.com/api/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
redirect_uri: options.redirectUri,
|
||||
}),
|
||||
},
|
||||
"Spotify token exchange failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
if (tokenResponse.expires_in) {
|
||||
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function refreshSpotifyAccessToken(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.spotify;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Spotify OAuth is not fully configured.");
|
||||
}
|
||||
|
||||
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64");
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://accounts.spotify.com/api/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: options.refreshToken,
|
||||
}),
|
||||
},
|
||||
"Spotify token refresh failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
if (tokenResponse.expires_in) {
|
||||
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function resolveDiscordIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
||||
const user = await fetchJson<{
|
||||
|
|
@ -772,7 +454,7 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise
|
|||
});
|
||||
|
||||
if (channelResponse.ok) {
|
||||
const payload = (await channelResponse.json()) as {
|
||||
const payload = await readProviderJson<{
|
||||
items?: Array<{
|
||||
id: string;
|
||||
snippet?: {
|
||||
|
|
@ -785,7 +467,7 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise
|
|||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>(channelResponse, "YouTube channel lookup failed");
|
||||
const channel = payload.items?.[0];
|
||||
|
||||
if (channel?.id) {
|
||||
|
|
@ -886,6 +568,45 @@ async function resolveSpotifyIdentity(options: { accessToken: string }): Promise
|
|||
return identity;
|
||||
}
|
||||
|
||||
async function resolveJoystickIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
||||
const profile = await fetchJson<{
|
||||
channel_id?: string;
|
||||
username?: string;
|
||||
nickname?: string;
|
||||
photo_url?: string;
|
||||
}>(
|
||||
"https://api.joystick.tv/api/v1/me/identity",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.accessToken}`,
|
||||
},
|
||||
},
|
||||
"Joystick identity lookup failed"
|
||||
);
|
||||
|
||||
if (!profile.channel_id) {
|
||||
throw new Error("Joystick identity response did not include a channel id.");
|
||||
}
|
||||
|
||||
const identity: ResolvedIdentity = {
|
||||
provider: "joystick",
|
||||
providerUserId: profile.channel_id,
|
||||
profile: profile as unknown as Record<string, unknown>,
|
||||
};
|
||||
const displayName = profile.nickname ?? profile.username;
|
||||
if (profile.username) {
|
||||
identity.username = profile.username;
|
||||
}
|
||||
if (displayName) {
|
||||
identity.displayName = displayName;
|
||||
}
|
||||
if (profile.photo_url) {
|
||||
identity.avatarUrl = profile.photo_url;
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function evaluateDiscordEntitlements(options: {
|
||||
config: HeimdallConfig;
|
||||
callback: OAuthCallbackContext;
|
||||
|
|
@ -926,16 +647,11 @@ async function evaluateDiscordEntitlements(options: {
|
|||
};
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(buildErrorMessage(response.status, text, "Discord guild member lookup failed"));
|
||||
}
|
||||
|
||||
const member = (text ? JSON.parse(text) : {}) as {
|
||||
const member = await readProviderJson<{
|
||||
nick?: string;
|
||||
roles?: string[];
|
||||
joined_at?: string;
|
||||
};
|
||||
}>(response, "Discord guild member lookup failed");
|
||||
const roles = member.roles ?? [];
|
||||
const matchedRoles = allowedRoleIds.filter((roleId) => roles.includes(roleId));
|
||||
const facts: string[] = [];
|
||||
|
|
@ -1198,22 +914,22 @@ async function evaluatePatreonEntitlements(options: {
|
|||
}
|
||||
|
||||
const discordRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangeDiscordAuthorizationCode,
|
||||
refreshAccessToken: refreshDiscordAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("discord"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("discord"),
|
||||
resolveIdentity: resolveDiscordIdentity,
|
||||
evaluateEntitlements: evaluateDiscordEntitlements,
|
||||
};
|
||||
|
||||
const patreonRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangePatreonAuthorizationCode,
|
||||
refreshAccessToken: refreshPatreonAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("patreon"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("patreon"),
|
||||
resolveIdentity: resolvePatreonIdentity,
|
||||
evaluateEntitlements: evaluatePatreonEntitlements,
|
||||
};
|
||||
|
||||
const twitchRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangeTwitchAuthorizationCode,
|
||||
refreshAccessToken: refreshTwitchAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("twitch"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("twitch"),
|
||||
resolveIdentity: resolveTwitchIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
|
|
@ -1221,8 +937,8 @@ const twitchRuntime: OAuthProviderRuntime = {
|
|||
};
|
||||
|
||||
const youtubeRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangeYouTubeAuthorizationCode,
|
||||
refreshAccessToken: refreshYouTubeAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("youtube"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("youtube"),
|
||||
resolveIdentity: resolveYouTubeIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
|
|
@ -1230,14 +946,23 @@ const youtubeRuntime: OAuthProviderRuntime = {
|
|||
};
|
||||
|
||||
const spotifyRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangeSpotifyAuthorizationCode,
|
||||
refreshAccessToken: refreshSpotifyAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("spotify"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("spotify"),
|
||||
resolveIdentity: resolveSpotifyIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const joystickRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("joystick"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("joystick"),
|
||||
resolveIdentity: resolveJoystickIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const notImplementedRuntime: OAuthProviderRuntime = {
|
||||
async exchangeAuthorizationCode() {
|
||||
throw new Error("Provider callback exchange is not implemented yet.");
|
||||
|
|
@ -1260,5 +985,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
|
|||
twitch: twitchRuntime,
|
||||
youtube: youtubeRuntime,
|
||||
spotify: spotifyRuntime,
|
||||
joystick: joystickRuntime,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type CultNetOperationServer,
|
||||
} from "cultnet-ts";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { getHeimdallRuntimeContext, refreshAppSession, startOAuthFlow, verifyRefreshToken } from "./app.js";
|
||||
import { errorIdentity, getHeimdallRuntimeContext, refreshAppSession, startOAuthFlow, verifyRefreshToken } from "./app.js";
|
||||
import { callerFromOpenedEnvelope } from "./app-caller.js";
|
||||
import { getAppProfile } from "./app-profiles.js";
|
||||
import { executeHeimdallAccessPlugin, HEIMDALL_ACCESS_PLUGIN_ID, type EvePluginAbiRequest } from "./access-plugin.js";
|
||||
|
|
@ -138,6 +138,12 @@ export async function startHeimdallPrivateCommandPlane(
|
|||
}
|
||||
return response(request, cachedResult.status, cachedResult.envelope, config.daemonId);
|
||||
} catch (error) {
|
||||
// The reply's diagnostics travel outside the sealed envelope, and the
|
||||
// caller logs them. Errors caught here carry input: JSON.parse quotes
|
||||
// a provider's body, a store error names the database. So the
|
||||
// diagnostic is fixed text plus the error's class and code; anything
|
||||
// more lives only in a sealed payload the plane authored.
|
||||
const { errorClass, errorCode } = errorIdentity(error);
|
||||
return {
|
||||
schemaVersion: "cultnet.operation_response.v0",
|
||||
messageId: request.messageId,
|
||||
|
|
@ -147,7 +153,7 @@ export async function startHeimdallPrivateCommandPlane(
|
|||
payloadSchema: "gamecult.cultnet.operation_failure.v1",
|
||||
payloadEncoding: "messagepack-base64",
|
||||
payload: Buffer.from(encode({ code: "private-command-denied", message: "Heimdall denied the private command." })).toString("base64"),
|
||||
diagnostics: [error instanceof Error ? error.message : "Private command failed."],
|
||||
diagnostics: [`Heimdall denied the private command (${errorClass}${errorCode ? `, ${errorCode}` : ""}).`],
|
||||
sourceRuntimeId: config.daemonId,
|
||||
};
|
||||
}
|
||||
|
|
@ -351,7 +357,6 @@ async function completeAuth(
|
|||
eventPayloadJson: {
|
||||
provider: completion.provider,
|
||||
mode: completion.mode,
|
||||
completionCode: completion.code,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
sign,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { closeSync, fstatSync, readSync } from "node:fs";
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import { SingleFileMessagePackBackingStore, type CultCacheEnvelope } from "@gamecult/cultcache-ts";
|
||||
import { readPrivateStoreRecord } from "./idunn-store.js";
|
||||
|
|
@ -71,7 +71,7 @@ export async function openOrEnrollProviderHealthIdentity(path: string): Promise<
|
|||
if (existing.length !== 1) {
|
||||
throw new Error("Provider-health identity store must contain exactly one record.");
|
||||
}
|
||||
return decodePrivateIdentity(existing[0]!.payload, await readMachineId());
|
||||
return decodePrivateIdentity(existing[0]!.payload, await readMachineId(), "bin");
|
||||
}
|
||||
|
||||
const PRESENCE_IDENTITY_FD_NAME = "gamecult-runtime-presence-identity";
|
||||
|
|
@ -124,19 +124,50 @@ export function passedDescriptor(
|
|||
*/
|
||||
export async function openProviderHealthIdentity(
|
||||
path: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
machineId: () => Promise<string> = readMachineId
|
||||
): Promise<PrivateIdentity> {
|
||||
const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env);
|
||||
if (descriptor === undefined) {
|
||||
if (env.GAMECULT_IDUNN_RUNTIME_BUNDLE) {
|
||||
// Inside an Idunn generation a self-enrolled key is one Idunn does not
|
||||
// trust, and its private store would land in a state root the next
|
||||
// generation must share. The descriptor is the only identity there.
|
||||
throw new Error(
|
||||
`Idunn runtime bundle is set but the ${PRESENCE_IDENTITY_FD_NAME} descriptor was not passed; refusing to self-enroll`
|
||||
);
|
||||
}
|
||||
return openOrEnrollProviderHealthIdentity(path);
|
||||
}
|
||||
|
||||
const payload = readPrivateStoreRecord(
|
||||
readFileSync(descriptor),
|
||||
readPassedDescriptor(descriptor),
|
||||
"Idunn runtime presence identity"
|
||||
);
|
||||
|
||||
return decodePrivateIdentity(payload, await readMachineId());
|
||||
// The descriptor's writer is the Rust owner, whose key fields are integer arrays.
|
||||
return decodePrivateIdentity(payload, await machineId(), "int-array");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a passed descriptor whole, from offset 0, and close it. A descriptor is
|
||||
* a one-shot input: reading it again starts at the offset the last read left,
|
||||
* which is the end, so the owner reads once at open and keeps what it decoded.
|
||||
*/
|
||||
export function readPassedDescriptor(descriptor: number): Buffer {
|
||||
try {
|
||||
const size = fstatSync(descriptor).size;
|
||||
const bytes = Buffer.alloc(size);
|
||||
let read = 0;
|
||||
while (read < size) {
|
||||
const count = readSync(descriptor, bytes, read, size - read, read);
|
||||
if (count === 0) break;
|
||||
read += count;
|
||||
}
|
||||
return bytes.subarray(0, read);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function signProviderHealthPayload(identity: PrivateIdentity, payload: Uint8Array): Uint8Array {
|
||||
|
|
@ -168,23 +199,58 @@ export function providerHealthPublicIdentity(identity: PrivateIdentity): {
|
|||
return { identityId: identity.identityId, publicKeyHex: Buffer.from(identity.publicKey).toString("hex") };
|
||||
}
|
||||
|
||||
function decodePrivateIdentity(payload: Uint8Array, machineId: string): PrivateIdentity {
|
||||
/**
|
||||
* Which writer produced the record. The self-enrolling store this process
|
||||
* writes keeps key fields as MessagePack bin; the Rust owner (cultnet-rs
|
||||
* service_identity.rs) serialises a Vec<u8> without serde_bytes, so its key
|
||||
* fields are arrays of integers. Each source is read in its own shape and the
|
||||
* other is refused.
|
||||
*/
|
||||
type KeyShape = "bin" | "int-array";
|
||||
|
||||
function keyBytes(value: unknown, shape: KeyShape, field: string): Uint8Array {
|
||||
const bytes =
|
||||
shape === "bin"
|
||||
? value instanceof Uint8Array
|
||||
? value
|
||||
: undefined
|
||||
: Array.isArray(value) &&
|
||||
value.every((byte) => typeof byte === "number" && Number.isInteger(byte) && byte >= 0 && byte <= 255)
|
||||
? Uint8Array.from(value as number[])
|
||||
: undefined;
|
||||
if (bytes === undefined || bytes.length !== 32) {
|
||||
throw new Error(
|
||||
`Provider-health identity key shape check failed: ${field} is not a 32-byte ${shape === "bin" ? "binary" : "integer-array"} key field.`
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodePrivateIdentity(payload: Uint8Array, machineId: string, shape: KeyShape): PrivateIdentity {
|
||||
const value = decode(payload);
|
||||
if (!Array.isArray(value) || value.length !== 10) {
|
||||
throw new Error("Provider-health identity payload is malformed.");
|
||||
throw new Error("Provider-health identity schema check failed: payload is not a 10-field record.");
|
||||
}
|
||||
const [schema, identityId, publicKey, protectedSeed, protector, binding, version] = value;
|
||||
if (
|
||||
schema !== PRIVATE_SCHEMA ||
|
||||
typeof identityId !== "string" ||
|
||||
!(publicKey instanceof Uint8Array) || publicKey.length !== 32 ||
|
||||
!(protectedSeed instanceof Uint8Array) || protectedSeed.length !== 32 ||
|
||||
protector !== "linux_file_mode_machine_id_binding" ||
|
||||
binding !== protectorBinding(machineId) ||
|
||||
version !== "v1" ||
|
||||
identityId !== deriveIdentityId(publicKey)
|
||||
) {
|
||||
throw new Error("Provider-health identity does not belong to this host or profile.");
|
||||
const [schema, identityId, publicKeyField, protectedSeedField, protector, binding, version] = value;
|
||||
if (schema !== PRIVATE_SCHEMA) {
|
||||
throw new Error("Provider-health identity schema check failed: unexpected schema id.");
|
||||
}
|
||||
if (typeof identityId !== "string") {
|
||||
throw new Error("Provider-health identity id check failed: id is not a string.");
|
||||
}
|
||||
const publicKey = keyBytes(publicKeyField, shape, "public key");
|
||||
const protectedSeed = keyBytes(protectedSeedField, shape, "protected seed");
|
||||
if (protector !== "linux_file_mode_machine_id_binding") {
|
||||
throw new Error("Provider-health identity protector check failed: unexpected protector.");
|
||||
}
|
||||
if (binding !== protectorBinding(machineId)) {
|
||||
throw new Error("Provider-health identity binding check failed: not bound to this host's machine id.");
|
||||
}
|
||||
if (version !== "v1") {
|
||||
throw new Error("Provider-health identity version check failed: unexpected version.");
|
||||
}
|
||||
if (identityId !== deriveIdentityId(publicKey)) {
|
||||
throw new Error("Provider-health identity id check failed: id does not derive from the public key.");
|
||||
}
|
||||
const seed = xorSeed(protectedSeed, binding);
|
||||
const privateKey = privateKeyFromSeed(seed);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@ export interface ProviderDescriptor {
|
|||
key: Provider;
|
||||
displayName: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
/** RFC 7591 names: how the client authenticates at the token endpoint. */
|
||||
tokenEndpointAuthMethod: "client_secret_basic" | "client_secret_post";
|
||||
/** True when a refresh response replaces the refresh token and the old one dies. */
|
||||
rotatesRefreshTokens: boolean;
|
||||
/**
|
||||
* Seconds an access token lives when a token answer carries no usable
|
||||
* `expires_in` (RFC 6749 5.1 makes it RECOMMENDED, not required). An unknown
|
||||
* expiry is never stored: the credential would never come due for refresh.
|
||||
*/
|
||||
defaultAccessTokenLifetimeSeconds: number;
|
||||
defaultScopes: string[];
|
||||
roles: ProviderRole[];
|
||||
clientIdEnv: string;
|
||||
|
|
@ -18,42 +29,62 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
key: "discord",
|
||||
displayName: "Discord",
|
||||
authorizationEndpoint: "https://discord.com/oauth2/authorize",
|
||||
tokenEndpoint: "https://discord.com/api/v10/oauth2/token",
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identify", "guilds.members.read"],
|
||||
roles: ["identity", "entitlement"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
|
||||
},
|
||||
patreon: {
|
||||
key: "patreon",
|
||||
displayName: "Patreon",
|
||||
authorizationEndpoint: "https://www.patreon.com/oauth2/authorize",
|
||||
tokenEndpoint: "https://www.patreon.com/api/oauth2/token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identity", "identity[email]"],
|
||||
roles: ["identity", "entitlement"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE",
|
||||
},
|
||||
github: {
|
||||
key: "github",
|
||||
displayName: "GitHub",
|
||||
authorizationEndpoint: "https://github.com/login/oauth/authorize",
|
||||
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["read:user", "user:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
|
||||
},
|
||||
twitch: {
|
||||
key: "twitch",
|
||||
displayName: "Twitch",
|
||||
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
|
||||
tokenEndpoint: "https://id.twitch.tv/oauth2/token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["user:read:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE",
|
||||
},
|
||||
youtube: {
|
||||
key: "youtube",
|
||||
displayName: "YouTube",
|
||||
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: [
|
||||
"openid",
|
||||
"email",
|
||||
|
|
@ -62,7 +93,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET_FILE",
|
||||
additionalAuthorizationParams: {
|
||||
access_type: "offline",
|
||||
include_granted_scopes: "true",
|
||||
|
|
@ -73,6 +104,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
key: "spotify",
|
||||
displayName: "Spotify",
|
||||
authorizationEndpoint: "https://accounts.spotify.com/authorize",
|
||||
tokenEndpoint: "https://accounts.spotify.com/api/token",
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: [
|
||||
"user-read-currently-playing",
|
||||
"user-read-playback-state",
|
||||
|
|
@ -80,7 +115,20 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
|
||||
},
|
||||
joystick: {
|
||||
key: "joystick",
|
||||
displayName: "Joystick",
|
||||
authorizationEndpoint: "https://joystick.tv/api/oauth/authorize",
|
||||
tokenEndpoint: "https://api.joystick.tv/api/oauth/token",
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
rotatesRefreshTokens: true,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@
|
|||
|
||||
import path from "node:path";
|
||||
import { createHash, createPrivateKey, type KeyObject } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile, writeFile, rename } from "node:fs/promises";
|
||||
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import {
|
||||
|
|
@ -34,11 +32,14 @@ import {
|
|||
type RuntimePresenceState,
|
||||
} from "cultnet-ts";
|
||||
|
||||
import type { HeimdallConfig } from "./config.js";
|
||||
import { readIdunnStoreRecord } from "./idunn-store.js";
|
||||
import {
|
||||
openProviderHealthIdentity,
|
||||
passedDescriptor,
|
||||
readPassedDescriptor,
|
||||
signWithIdentity,
|
||||
type PrivateIdentity,
|
||||
} from "./provider-health-identity.js";
|
||||
|
||||
const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
|
|
@ -178,70 +179,37 @@ export async function readRuntimeBundleFacts(bundlePath: string): Promise<Runtim
|
|||
}
|
||||
|
||||
/**
|
||||
* Open the activation signing key Idunn passed as a descriptor.
|
||||
* The publisher sequence a process stamps on its presence statements.
|
||||
*
|
||||
* The credential is exactly 32 bytes: the raw Ed25519 seed. It is passed as a
|
||||
* parent-only descriptor and not as a path, so only the process Idunn launched
|
||||
* can produce this half of the proof.
|
||||
* Odin compares `publisher_sequence` only against stored presence of the same
|
||||
* activation, and Idunn mints a fresh activation for every start, continuity
|
||||
* restarts included. So the sequence need only strictly increase within this
|
||||
* process; nothing has to survive a restart, and nothing is written to the
|
||||
* state root. Muninn derives its sequence the same way: the observation clock,
|
||||
* never below the previous value plus one.
|
||||
*/
|
||||
export function openActivationSigner(env: NodeJS.ProcessEnv = process.env): KeyObject | undefined {
|
||||
const descriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env);
|
||||
if (descriptor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const seed = readFileSync(descriptor);
|
||||
if (seed.length !== 32) {
|
||||
throw new Error("Idunn runtime activation credential is not a 32-byte seed");
|
||||
}
|
||||
return createPrivateKey({
|
||||
key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
});
|
||||
}
|
||||
export class PublisherSequence {
|
||||
private last = 0;
|
||||
|
||||
/**
|
||||
* The publisher sequence must strictly increase for the life of a signer
|
||||
* identity, because Idunn treats a repeat or a regression as a replay and
|
||||
* refuses the observation.
|
||||
*
|
||||
* An in-process counter is therefore not enough: a restart would reset it and
|
||||
* every statement afterwards would be refused until the count climbed past the
|
||||
* old high-water mark. It lives beside the target's state instead, and is
|
||||
* advanced before use.
|
||||
*/
|
||||
export async function nextPublisherSequence(sequencePath: string): Promise<number> {
|
||||
let current = 0;
|
||||
try {
|
||||
const raw = (await readFile(sequencePath, "utf8")).trim();
|
||||
const parsed = Number(raw);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0) {
|
||||
current = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
next(observedAtUnixMillis: number): number {
|
||||
if (!Number.isSafeInteger(observedAtUnixMillis)) {
|
||||
throw new Error("observedAtUnixMillis is not a safe integer");
|
||||
}
|
||||
this.last = Math.max(observedAtUnixMillis, this.last + 1);
|
||||
return this.last;
|
||||
}
|
||||
const next = current + 1;
|
||||
// Written through a temporary file: a torn write here would look like a
|
||||
// regression and cost the target every future observation.
|
||||
const temporary = `${sequencePath}.next`;
|
||||
await writeFile(temporary, `${next}\n`, { mode: 0o600 });
|
||||
await rename(temporary, sequencePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface PresenceStatementInputs {
|
||||
bundle: RuntimeBundleFacts;
|
||||
identity: PrivateIdentity;
|
||||
activationKey: KeyObject;
|
||||
state: RuntimePresenceState;
|
||||
detail: string;
|
||||
boundEndpoint: string | null;
|
||||
writeLeaseSha256: string | null;
|
||||
observedAtUnixMillis: number;
|
||||
publisherSequence: number;
|
||||
providerHealthIdentityPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface PresenceStatement {
|
||||
|
|
@ -250,24 +218,14 @@ export interface PresenceStatement {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build and dual-sign one presence statement.
|
||||
* Build and dual-sign one presence statement from keys and facts already open.
|
||||
*
|
||||
* Returns the target alongside the bytes because the document key must equal
|
||||
* the signed target; Odin refuses the pair when they differ, and that is the
|
||||
* one part of the envelope the transport cannot infer.
|
||||
*/
|
||||
export async function buildPresenceStatement(
|
||||
inputs: PresenceStatementInputs,
|
||||
): Promise<PresenceStatement> {
|
||||
const env = inputs.env ?? process.env;
|
||||
const activationKey = openActivationSigner(env);
|
||||
if (activationKey === undefined) {
|
||||
throw new Error(
|
||||
"Idunn passed no runtime activation credential, so this process cannot prove it is the " +
|
||||
"incarnation Idunn started",
|
||||
);
|
||||
}
|
||||
const identity = await openProviderHealthIdentity(inputs.providerHealthIdentityPath, env);
|
||||
export function buildPresenceStatement(inputs: PresenceStatementInputs): PresenceStatement {
|
||||
const { identity } = inputs;
|
||||
|
||||
const presence: RuntimePresenceHealth = {
|
||||
target: inputs.bundle.target,
|
||||
|
|
@ -295,7 +253,7 @@ export async function buildPresenceStatement(
|
|||
const proof = runtimePresenceProofPayload(presence);
|
||||
const signature = signWithIdentity(identity, runtimePresenceProviderSigningMessage(proof));
|
||||
const activationSignature = signWithIdentity(
|
||||
{ ...identity, privateKey: activationKey },
|
||||
{ ...identity, privateKey: inputs.activationKey },
|
||||
runtimePresenceActivationSigningMessage(proof),
|
||||
);
|
||||
|
||||
|
|
@ -304,3 +262,72 @@ export async function buildPresenceStatement(
|
|||
payload: encodeRuntimePresenceHealth(presence, signature, activationSignature),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PresenceAuthority {
|
||||
sign(
|
||||
state: RuntimePresenceState,
|
||||
detail: string,
|
||||
observedAtUnixMillis: number,
|
||||
): PresenceStatement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open everything this launch's presence statements stand on, once, before the
|
||||
* process listens: Idunn's bundle facts, the activation seed and the presence
|
||||
* identity (each descriptor is read whole and closed), and the one publisher
|
||||
* sequence. It owns them for the life of the process; a pulse supplies only its
|
||||
* state, detail and observation time.
|
||||
*
|
||||
* A launch-contract fault (a missing or malformed bundle, descriptor or
|
||||
* identity) throws here, so the process exits before it accepts a request and
|
||||
* Idunn fails Warming at once, instead of a listener that can never sign.
|
||||
*/
|
||||
export async function openPresenceAuthority(
|
||||
config: Pick<HeimdallConfig, "idunnRuntimeBundlePath" | "host" | "port" | "providerHealthIdentityPath">,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
machineId?: () => Promise<string>,
|
||||
): Promise<PresenceAuthority> {
|
||||
if (!config.idunnRuntimeBundlePath) {
|
||||
throw new Error("no Idunn runtime bundle path: presence has no launch to attest");
|
||||
}
|
||||
const bundle = await readRuntimeBundleFacts(config.idunnRuntimeBundlePath);
|
||||
|
||||
const activationDescriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env);
|
||||
if (activationDescriptor === undefined) {
|
||||
throw new Error(
|
||||
"Idunn passed no runtime activation credential, so this process cannot prove it is the " +
|
||||
"incarnation Idunn started",
|
||||
);
|
||||
}
|
||||
const seed = readPassedDescriptor(activationDescriptor);
|
||||
if (seed.length !== 32) {
|
||||
throw new Error("Idunn runtime activation credential is not a 32-byte seed");
|
||||
}
|
||||
const activationKey = createPrivateKey({
|
||||
key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
});
|
||||
|
||||
const identity = await openProviderHealthIdentity(config.providerHealthIdentityPath, env, machineId);
|
||||
const boundEndpoint = `http://${config.host}:${config.port}`;
|
||||
const sequence = new PublisherSequence();
|
||||
|
||||
return {
|
||||
sign: (state, detail, observedAtUnixMillis) =>
|
||||
buildPresenceStatement({
|
||||
bundle,
|
||||
identity,
|
||||
activationKey,
|
||||
state,
|
||||
detail,
|
||||
boundEndpoint,
|
||||
// A warming candidate holds no lease and must not claim one; the
|
||||
// contract refuses the pair, and Idunn needs the warming statement to
|
||||
// promote it.
|
||||
writeLeaseSha256: null,
|
||||
observedAtUnixMillis,
|
||||
publisherSequence: sequence.next(observedAtUnixMillis),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export interface RuntimeKeyMaterial {
|
|||
privateKey: KeyObject;
|
||||
publicKey: KeyObject;
|
||||
publicJwk: PublicSigningJwk;
|
||||
source: "configured_pem" | "configured_file" | "bootstrapped_file" | "ephemeral_dev";
|
||||
source: "configured_file" | "bootstrapped_file" | "ephemeral_dev";
|
||||
}
|
||||
|
||||
function encodeBase64Url(input: string | Buffer): string {
|
||||
|
|
@ -56,8 +56,30 @@ function exportPrivateKeyPem(privateKey: KeyObject): string {
|
|||
return typeof exported === "string" ? exported : exported.toString("utf8");
|
||||
}
|
||||
|
||||
const SIGNING_KEY_VARIABLE = "GC_ACCESS_SIGNING_PRIVATE_KEY_PATH";
|
||||
|
||||
/**
|
||||
* Every signing-key failure names only the variable and an error code. It
|
||||
* carries no path, no file contents and no cause, because Node prints an
|
||||
* error's cause and properties along with its message.
|
||||
*/
|
||||
function signingKeyError(failure: string, error: unknown): Error {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code ?? "error";
|
||||
return new Error(`${SIGNING_KEY_VARIABLE} ${failure} (${code}).`);
|
||||
}
|
||||
|
||||
function loadPrivateKeyFromPath(path: string): KeyObject {
|
||||
return createPrivateKey(readFileSync(path, "utf8"));
|
||||
let pem: string;
|
||||
try {
|
||||
pem = readFileSync(path, "utf8");
|
||||
} catch (error) {
|
||||
throw signingKeyError("could not be read", error);
|
||||
}
|
||||
try {
|
||||
return createPrivateKey(pem);
|
||||
} catch (error) {
|
||||
throw signingKeyError("does not hold a usable private key", error);
|
||||
}
|
||||
}
|
||||
|
||||
function createOrLoadPrivateKeyFromPath(config: HeimdallConfig): {
|
||||
|
|
@ -81,12 +103,16 @@ function createOrLoadPrivateKeyFromPath(config: HeimdallConfig): {
|
|||
|
||||
if (!config.bootstrapSigningPrivateKeyOnMissing) {
|
||||
throw new Error(
|
||||
`Signing key file '${path}' does not exist. Set GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1 to create it on first boot.`
|
||||
`${SIGNING_KEY_VARIABLE} names no file (ENOENT). Set GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1 to create it on first boot.`
|
||||
);
|
||||
}
|
||||
|
||||
const privateKey = generateKeyPairSync("ed25519").privateKey;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
} catch (error) {
|
||||
throw signingKeyError("could not be created", error);
|
||||
}
|
||||
try {
|
||||
writeFileSync(path, exportPrivateKeyPem(privateKey), {
|
||||
encoding: "utf8",
|
||||
|
|
@ -99,7 +125,7 @@ function createOrLoadPrivateKeyFromPath(config: HeimdallConfig): {
|
|||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw error;
|
||||
throw signingKeyError("could not be created", error);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -110,12 +136,7 @@ function createOrLoadPrivateKeyFromPath(config: HeimdallConfig): {
|
|||
}
|
||||
|
||||
export function createRuntimeKeyMaterial(config: HeimdallConfig): RuntimeKeyMaterial {
|
||||
const loaded = config.signingPrivateKeyPem
|
||||
? {
|
||||
privateKey: createPrivateKey(config.signingPrivateKeyPem),
|
||||
source: "configured_pem" as const,
|
||||
}
|
||||
: createOrLoadPrivateKeyFromPath(config);
|
||||
const loaded = createOrLoadPrivateKeyFromPath(config);
|
||||
const privateKey = loaded.privateKey;
|
||||
const publicKey = createPublicKey(privateKey);
|
||||
const exportedJwk = publicKey.export({ format: "jwk" }) as Jwk;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type CreateAuthCompletionInput,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
type CreateEntitlementSnapshotInput,
|
||||
type CreateSessionInput,
|
||||
type CreatePrivateCommandReceiptInput,
|
||||
|
|
@ -37,6 +38,8 @@ function isGrantActive(grant: StoredCapabilityGrant, appSlug: AppSlug, at: strin
|
|||
}
|
||||
|
||||
export class InMemoryStore implements HeimdallStore {
|
||||
/** Tail of each linked identity's credential-decision chain; an entry is removed when it drains. */
|
||||
private readonly credentialChains = new Map<string, Promise<unknown>>();
|
||||
private readonly accounts = new Map<string, StoredAccount>();
|
||||
private readonly linkedIdentities = new Map<string, StoredLinkedIdentity>();
|
||||
private readonly grants = new Map<string, StoredCapabilityGrant>();
|
||||
|
|
@ -174,6 +177,43 @@ export class InMemoryStore implements HeimdallStore {
|
|||
return clone(linkedIdentity);
|
||||
}
|
||||
|
||||
async updateLinkedIdentityCredential(
|
||||
id: string,
|
||||
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,
|
||||
): Promise<StoredLinkedIdentity | null> {
|
||||
const previous = this.credentialChains.get(id) ?? Promise.resolve();
|
||||
const run = previous.then(async () => {
|
||||
const current = this.linkedIdentities.get(id);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const decision = await decide(clone(current));
|
||||
if (decision.kind === "keep") {
|
||||
return clone(current);
|
||||
}
|
||||
const next: StoredLinkedIdentity = { ...current, updatedAt: decision.updatedAt };
|
||||
delete next.accessTokenEncrypted;
|
||||
delete next.refreshTokenEncrypted;
|
||||
delete next.tokenExpiresAt;
|
||||
if (decision.kind === "rotate") {
|
||||
next.accessTokenEncrypted = decision.accessTokenEncrypted;
|
||||
next.refreshTokenEncrypted = decision.refreshTokenEncrypted;
|
||||
next.tokenExpiresAt = decision.tokenExpiresAt;
|
||||
next.scopes = [...decision.scopes];
|
||||
}
|
||||
this.linkedIdentities.set(id, clone(next));
|
||||
return clone(next);
|
||||
});
|
||||
const tail = run.catch(() => undefined);
|
||||
this.credentialChains.set(id, tail);
|
||||
void tail.then(() => {
|
||||
if (this.credentialChains.get(id) === tail) {
|
||||
this.credentialChains.delete(id);
|
||||
}
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
async listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]> {
|
||||
return [...this.linkedIdentities.values()]
|
||||
.filter((identity) => identity.accountId === accountId)
|
||||
|
|
|
|||
|
|
@ -8,13 +8,37 @@ export { InMemoryStore } from "./in-memory.js";
|
|||
export { PostgresStore, createPostgresStore } from "./postgres.js";
|
||||
|
||||
export async function createStore(config: HeimdallConfig): Promise<HeimdallStore> {
|
||||
const store =
|
||||
config.storage.backend === "postgres"
|
||||
? createPostgresStore(config.storage.databaseUrl ?? "postgres://127.0.0.1/heimdall")
|
||||
: new InMemoryStore();
|
||||
if (config.storage.backend !== "postgres") {
|
||||
return new InMemoryStore();
|
||||
}
|
||||
|
||||
if (config.storage.applySchemaOnStartup) {
|
||||
await store.ensureSchema();
|
||||
const store = createPostgresStore(config.storage.databaseUrl ?? "postgres://127.0.0.1/heimdall");
|
||||
try {
|
||||
// Every start connects and proves the database can take its requests,
|
||||
// whether or not it applied the schema first (CREATE ... IF NOT EXISTS
|
||||
// leaves a damaged table as it is), so a misbound URL, an empty or
|
||||
// damaged database, or a role that may not write fails here rather than
|
||||
// in the first request.
|
||||
if (config.storage.applySchemaOnStartup) {
|
||||
await store.ensureSchema();
|
||||
}
|
||||
await store.checkSchema();
|
||||
} catch (error) {
|
||||
// pg's and Node's errors name the host, the database or the URL itself,
|
||||
// and any of those can hold part of a secret that was bound or escaped
|
||||
// wrongly. Startup prints this error, so it names only the input and a
|
||||
// code, and carries no cause.
|
||||
const code = (error as { code?: unknown }).code;
|
||||
// A refused store is never handed out, so it is closed here: its pool
|
||||
// would otherwise hold the connection open.
|
||||
await store.close().catch(() => undefined);
|
||||
// A role that may not create temporary tables is not a bad URL, so that
|
||||
// refusal names the privilege instead.
|
||||
const remedy =
|
||||
code === "SCHEMA_TEMP_PRIVILEGE"
|
||||
? "grant the database role TEMPORARY on its database"
|
||||
: "check GC_ACCESS_DATABASE_URL_FILE";
|
||||
throw new Error(`Postgres storage could not be prepared (${typeof code === "string" ? code : "error"}); ${remedy}.`);
|
||||
}
|
||||
|
||||
return store;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { Pool, type PoolClient, type QueryResultRow } from "pg";
|
||||
import { type AppSlug, type HeimdallAuthAttemptStatus, type LinkedIdentityInput, type Provider } from "../contracts.js";
|
||||
import { CREATE_SCHEMA_SQL } from "./schema.js";
|
||||
import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "./schema.js";
|
||||
import {
|
||||
type CreateAccountInput,
|
||||
type CreateAuthAttemptInput,
|
||||
type CreateAuthCompletionInput,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
type CreateEntitlementSnapshotInput,
|
||||
type CreateSessionInput,
|
||||
type CreatePrivateCommandReceiptInput,
|
||||
|
|
@ -158,8 +159,8 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
|
|||
providerUserId: row.provider_user_id,
|
||||
scopes: parseScopes(row.scopes),
|
||||
profileJson: row.profile_json,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
updatedAt: new Date(row.updated_at).toISOString(),
|
||||
};
|
||||
|
||||
if (row.username) {
|
||||
|
|
@ -183,7 +184,8 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
|
|||
}
|
||||
|
||||
if (row.token_expires_at) {
|
||||
identity.tokenExpiresAt = row.token_expires_at;
|
||||
// pg hands back a Date for TIMESTAMPTZ whatever the row type says; the port promises ISO strings.
|
||||
identity.tokenExpiresAt = new Date(row.token_expires_at).toISOString();
|
||||
}
|
||||
|
||||
return identity;
|
||||
|
|
@ -284,13 +286,279 @@ function mapAuthAttemptRow(row: AuthAttemptRow): StoredAuthAttempt {
|
|||
return attempt;
|
||||
}
|
||||
|
||||
/**
|
||||
* The AND-ed conditions of a predicate as Postgres prints it, each without a
|
||||
* pair of parentheses that encloses all of it. Postgres flattens a chain of
|
||||
* ANDs into one list, so `((a) AND (b) AND (c))` yields `a`, `b`, `c`. An OR,
|
||||
* or an AND nested under one, stays whole: `((a OR b) AND c)` yields `a OR b`
|
||||
* and `c`, and `(a OR (b AND c))` yields itself. Quoted text is never split.
|
||||
*/
|
||||
export function predicateConditions(printed: string): string[] {
|
||||
const unwrap = (text: string): string => {
|
||||
let current = text.trim();
|
||||
while (current.startsWith("(") && closingParenthesis(current, 0) === current.length - 1) {
|
||||
current = current.slice(1, -1).trim();
|
||||
}
|
||||
return current;
|
||||
};
|
||||
const whole = unwrap(printed);
|
||||
if (whole === "") return [];
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let quote: string | undefined;
|
||||
let start = 0;
|
||||
for (let position = 0; position < whole.length; position += 1) {
|
||||
const character = whole[position]!;
|
||||
if (quote) {
|
||||
if (character === quote) quote = undefined;
|
||||
} else if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
} else if (character === "(") {
|
||||
depth += 1;
|
||||
} else if (character === ")") {
|
||||
depth -= 1;
|
||||
} else if (depth === 0 && whole.startsWith(" AND ", position)) {
|
||||
parts.push(whole.slice(start, position));
|
||||
start = position + " AND ".length;
|
||||
position = start - 1;
|
||||
}
|
||||
}
|
||||
parts.push(whole.slice(start));
|
||||
return parts.map(unwrap);
|
||||
}
|
||||
|
||||
/** The index of the parenthesis closing the one at `open`, outside quoted text, or -1. */
|
||||
function closingParenthesis(text: string, open: number): number {
|
||||
let depth = 0;
|
||||
let quote: string | undefined;
|
||||
for (let position = open; position < text.length; position += 1) {
|
||||
const character = text[position]!;
|
||||
if (quote) {
|
||||
if (character === quote) quote = undefined;
|
||||
} else if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
} else if (character === "(") {
|
||||
depth += 1;
|
||||
} else if (character === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return position;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an index predicate, as Postgres prints it, keeps exactly the rows
|
||||
* the declared one (printed the same way) does: the same set of AND-ed
|
||||
* conditions, ignoring `key_column IS NOT NULL` unless the index treats NULLs
|
||||
* as equal, since otherwise rows with a NULL key never collide anyway.
|
||||
*/
|
||||
export function samePredicate(declared: string, actual: string, keyColumns: string[], nullsNotDistinct: boolean): boolean {
|
||||
// A key without a predicate must have none: ON CONFLICT cannot infer a
|
||||
// partial index as its arbiter.
|
||||
if (predicateConditions(declared).length === 0) return actual.trim() === "";
|
||||
const redundant = new Set(nullsNotDistinct ? [] : keyColumns.map((column) => `${column} IS NOT NULL`));
|
||||
const conditions = (printed: string) =>
|
||||
[...new Set(predicateConditions(printed).filter((condition) => !redundant.has(condition)))].sort();
|
||||
const left = conditions(declared);
|
||||
const right = conditions(actual);
|
||||
return left.length === right.length && left.every((condition, index) => condition === right[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the store's statements do to each table, and so the privileges its
|
||||
* database role needs: it reads what it returns or looks up, inserts what it
|
||||
* creates, updates what it changes or upserts, and deletes nothing.
|
||||
* audit_events is written and never read back.
|
||||
*/
|
||||
export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT" | "INSERT" | "UPDATE")[]>> = {
|
||||
accounts: ["SELECT", "INSERT", "UPDATE"],
|
||||
linked_identities: ["SELECT", "INSERT", "UPDATE"],
|
||||
sessions: ["SELECT", "INSERT", "UPDATE"],
|
||||
auth_attempts: ["SELECT", "INSERT", "UPDATE"],
|
||||
private_command_receipts: ["SELECT", "INSERT"],
|
||||
auth_completions: ["SELECT", "INSERT", "UPDATE"],
|
||||
capability_grants: ["SELECT", "INSERT"],
|
||||
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
|
||||
audit_events: ["INSERT"],
|
||||
};
|
||||
|
||||
/** What a database must hold for a store to serve it; see checkSchemaRequirements. */
|
||||
export interface SchemaRequirements {
|
||||
tables: readonly string[];
|
||||
columns: readonly { table: string; column: string; type: string; nullable: boolean }[];
|
||||
keys: readonly { table: string; columns: string; predicate: string }[];
|
||||
privileges: Readonly<Record<string, readonly string[]>>;
|
||||
}
|
||||
|
||||
/** What Heimdall's store needs, read from its schema and its statements. */
|
||||
export const HEIMDALL_SCHEMA_REQUIREMENTS: SchemaRequirements = {
|
||||
tables: REQUIRED_TABLES,
|
||||
columns: REQUIRED_COLUMNS,
|
||||
keys: REQUIRED_KEYS,
|
||||
privileges: STORE_TABLE_PRIVILEGES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Each required key's predicate as Postgres prints it back (empty for none).
|
||||
* One simple-protocol round trip on one connection: a transaction that builds
|
||||
* a temporary copy of each keyed table's declared columns, indexes it with the
|
||||
* declared predicate, reads the printed form and rolls back. The names and SQL
|
||||
* come from the requirements, which are constants, never input. A role that
|
||||
* may not create temporary tables fails with code SCHEMA_TEMP_PRIVILEGE.
|
||||
*/
|
||||
export async function printedDeclaredPredicates(pool: Pick<Pool, "query">, requirements: SchemaRequirements): Promise<string[]> {
|
||||
const partial = requirements.keys.flatMap((required, index) => (required.predicate ? [{ ...required, index }] : []));
|
||||
if (partial.length === 0) return requirements.keys.map(() => "");
|
||||
const statements = partial.flatMap(({ table, columns, predicate, index }) => {
|
||||
const probe = `heimdall_key_probe_${index}`;
|
||||
const declared = requirements.columns
|
||||
.filter((column) => column.table === table)
|
||||
.map((column) => `${column.column} ${column.type}`)
|
||||
.join(", ");
|
||||
return [
|
||||
`CREATE TEMP TABLE ${probe} (${declared}) ON COMMIT DROP`,
|
||||
`CREATE UNIQUE INDEX ${probe}_idx ON ${probe} (${columns}) WHERE ${predicate}`,
|
||||
];
|
||||
});
|
||||
const select = partial
|
||||
.map(({ index }) => `SELECT ${index} AS key, pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'pg_temp.heimdall_key_probe_${index}_idx'::regclass`)
|
||||
.join(" UNION ALL ");
|
||||
let results: Array<{ rows: Array<{ key: number; predicate: string }> }>;
|
||||
try {
|
||||
results = (await pool.query(["BEGIN", ...statements, select, "ROLLBACK"].join(";\n"))) as unknown as typeof results;
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown }).code === "42501") {
|
||||
throw Object.assign(new Error("The Heimdall database role may not create temporary tables."), { code: "SCHEMA_TEMP_PRIVILEGE" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const printed = results[results.length - 2]!.rows;
|
||||
return requirements.keys.map((_, index) => printed.find((row) => row.key === index)?.predicate ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves that the database can take a store's requests: every required
|
||||
* table; every column with its declared type, under a deterministic
|
||||
* collation, and nullable where declared nullable; every unique key; and
|
||||
* every privilege, held on the table or on each of its columns. A missing or
|
||||
* altered piece fails with code SCHEMA_MISSING and a missing privilege with
|
||||
* SCHEMA_PRIVILEGES; neither error names the piece.
|
||||
*
|
||||
* A key is met by a unique index on its table that is valid (a failed
|
||||
* CREATE INDEX CONCURRENTLY or an interrupted DROP INDEX CONCURRENTLY leaves
|
||||
* one that is not; Postgres never marks an index valid before it is ready),
|
||||
* immediate (ON CONFLICT refuses a deferrable arbiter), has exactly the key's
|
||||
* columns as its key columns (INCLUDE columns aside), compares them under
|
||||
* deterministic collations (a case-insensitive one would merge two
|
||||
* providers' user ids into one row), and has the key's predicate. A
|
||||
* predicate matches when both, as Postgres prints them, are the same set of
|
||||
* AND-ed conditions; a `key_column IS NOT NULL` condition may be absent on
|
||||
* either side, because a unique index never makes NULL keys collide unless
|
||||
* it is NULLS NOT DISTINCT. Postgres prints the declared predicates itself
|
||||
* (printedDeclaredPredicates).
|
||||
*
|
||||
* What this does not prove is listed where the schema is: see schema.ts.
|
||||
*/
|
||||
export async function checkSchemaRequirements(pool: Pick<Pool, "query">, requirements: SchemaRequirements): Promise<void> {
|
||||
const declaredPredicates = await printedDeclaredPredicates(pool, requirements);
|
||||
const privileges = Object.entries(requirements.privileges).flatMap(([table, wanted]) =>
|
||||
wanted.map((privilege) => ({ table, privilege }))
|
||||
);
|
||||
const result = await pool.query<{
|
||||
kind: "missing" | "privilege" | "candidate";
|
||||
key: number | null;
|
||||
predicate: string | null;
|
||||
nulls_not_distinct: boolean | null;
|
||||
}>(
|
||||
`
|
||||
SELECT 'missing' AS kind, NULL::int AS key, NULL::text AS predicate, NULL::boolean AS nulls_not_distinct
|
||||
FROM unnest($1::text[]) AS name WHERE to_regclass(name) IS NULL
|
||||
UNION ALL
|
||||
SELECT 'missing', NULL, NULL, NULL
|
||||
FROM unnest($2::text[], $3::text[], $4::text[], $5::boolean[]) AS required(table_name, column_name, type_name, nullable)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM pg_attribute a
|
||||
WHERE a.attrelid = to_regclass(required.table_name)
|
||||
AND a.attname = required.column_name
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
AND a.atttypid = to_regtype(required.type_name)
|
||||
AND (NOT required.nullable OR NOT a.attnotnull)
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_collation c WHERE c.oid = a.attcollation AND NOT c.collisdeterministic)
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'candidate', required.key::int, pg_get_expr(i.indpred, i.indrelid),
|
||||
coalesce((to_jsonb(i) ->> 'indnullsnotdistinct')::boolean, false)
|
||||
FROM unnest($6::text[], $7::text[]) WITH ORDINALITY AS required(table_name, columns, key)
|
||||
JOIN pg_index i ON i.indrelid = to_regclass(required.table_name)
|
||||
WHERE i.indisunique
|
||||
AND i.indisvalid
|
||||
AND i.indimmediate
|
||||
AND i.indnkeyatts = cardinality(string_to_array(required.columns, ','))
|
||||
AND (
|
||||
SELECT string_agg(a.attname, ',' ORDER BY a.attname)
|
||||
FROM unnest(i.indkey::int2[]) WITH ORDINALITY AS key(attnum, position)
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = key.attnum
|
||||
WHERE key.position <= i.indnkeyatts
|
||||
) = required.columns
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM unnest(i.indcollation::oid[]) WITH ORDINALITY AS key_collation(oid, position)
|
||||
JOIN pg_collation c ON c.oid = key_collation.oid
|
||||
WHERE key_collation.position <= i.indnkeyatts AND NOT c.collisdeterministic
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'privilege', NULL, NULL, NULL
|
||||
FROM unnest($8::text[], $9::text[]) AS required(table_name, privilege)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM unnest($2::text[], $3::text[]) AS used(table_name, column_name)
|
||||
JOIN pg_attribute a ON a.attrelid = to_regclass(used.table_name) AND a.attname = used.column_name AND NOT a.attisdropped
|
||||
WHERE used.table_name = required.table_name
|
||||
AND NOT has_column_privilege(a.attrelid, a.attnum, required.privilege)
|
||||
)
|
||||
`,
|
||||
[
|
||||
requirements.tables,
|
||||
requirements.columns.map((required) => required.table),
|
||||
requirements.columns.map((required) => required.column),
|
||||
requirements.columns.map((required) => required.type),
|
||||
requirements.columns.map((required) => required.nullable),
|
||||
requirements.keys.map((required) => required.table),
|
||||
requirements.keys.map((required) => required.columns),
|
||||
privileges.map((required) => required.table),
|
||||
privileges.map((required) => required.privilege),
|
||||
]
|
||||
);
|
||||
const keyMet = requirements.keys.map((required, index) =>
|
||||
result.rows.some(
|
||||
(row) =>
|
||||
row.kind === "candidate" &&
|
||||
row.key === index + 1 &&
|
||||
samePredicate(declaredPredicates[index]!, row.predicate ?? "", required.columns.split(","), row.nulls_not_distinct === true)
|
||||
)
|
||||
);
|
||||
if (result.rows.some((row) => row.kind === "missing") || keyMet.includes(false)) {
|
||||
throw Object.assign(new Error("The Heimdall schema is not applied."), { code: "SCHEMA_MISSING" });
|
||||
}
|
||||
if (result.rows.some((row) => row.kind === "privilege")) {
|
||||
throw Object.assign(new Error("The Heimdall database role lacks a privilege the store needs."), {
|
||||
code: "SCHEMA_PRIVILEGES",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresStore implements HeimdallStore {
|
||||
constructor(private readonly pool: Pick<Pool, "query" | "end">) {}
|
||||
constructor(private readonly pool: Pick<Pool, "query" | "end" | "connect">) {}
|
||||
|
||||
async ensureSchema(): Promise<void> {
|
||||
await this.pool.query(CREATE_SCHEMA_SQL);
|
||||
}
|
||||
|
||||
/** Proves, before Heimdall serves, that the database can take its requests (checkSchemaRequirements). */
|
||||
async checkSchema(): Promise<void> {
|
||||
await checkSchemaRequirements(this.pool, HEIMDALL_SCHEMA_REQUIREMENTS);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
|
|
@ -456,6 +724,60 @@ export class PostgresStore implements HeimdallStore {
|
|||
return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity"));
|
||||
}
|
||||
|
||||
async updateLinkedIdentityCredential(
|
||||
id: string,
|
||||
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,
|
||||
): Promise<StoredLinkedIdentity | null> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
try {
|
||||
const locked = await client.query<LinkedIdentityRow>(
|
||||
"SELECT * FROM linked_identities WHERE id = $1 FOR UPDATE",
|
||||
[id]
|
||||
);
|
||||
if (!locked.rowCount) {
|
||||
await client.query("COMMIT");
|
||||
return null;
|
||||
}
|
||||
const row = mapLinkedIdentityRow(expectRow(locked.rows[0], "updateLinkedIdentityCredential"));
|
||||
const decision = await decide(row);
|
||||
let committed = row;
|
||||
if (decision.kind !== "keep") {
|
||||
const rotated = decision.kind === "rotate" ? decision : undefined;
|
||||
const written = await client.query<LinkedIdentityRow>(
|
||||
`
|
||||
UPDATE linked_identities
|
||||
SET access_token_encrypted = $2,
|
||||
refresh_token_encrypted = $3,
|
||||
token_expires_at = $4,
|
||||
scopes = $5,
|
||||
updated_at = $6
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
id,
|
||||
nullable(rotated?.accessTokenEncrypted),
|
||||
nullable(rotated?.refreshTokenEncrypted),
|
||||
nullable(rotated?.tokenExpiresAt),
|
||||
(rotated?.scopes ?? row.scopes).join(" "),
|
||||
decision.updatedAt,
|
||||
]
|
||||
);
|
||||
committed = mapLinkedIdentityRow(expectRow(written.rows[0], "updateLinkedIdentityCredential"));
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
return committed;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]> {
|
||||
const result = await this.pool.query<LinkedIdentityRow>(
|
||||
`
|
||||
|
|
@ -791,9 +1113,21 @@ export class PostgresStore implements HeimdallStore {
|
|||
}
|
||||
}
|
||||
|
||||
export function createPostgresStore(databaseUrl: string): HeimdallStore {
|
||||
export function createPostgresStore(databaseUrl: string): PostgresStore {
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
// An idle client whose connection ends (a database restart, an
|
||||
// administrator terminating the backend) is emitted here. Unheard, the
|
||||
// event kills the process and Node prints the pg Client, connection
|
||||
// parameters included. The pool has already dropped the dead client and
|
||||
// opens a new one on the next query, so this only says what happened, by
|
||||
// code.
|
||||
pool.on("error", (error) => {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
console.error(
|
||||
`Heimdall lost an idle Postgres connection (${typeof code === "string" ? code : "error"}); the pool reconnects on the next query.`
|
||||
);
|
||||
});
|
||||
return new PostgresStore(pool);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,3 +162,86 @@ CREATE TABLE IF NOT EXISTS audit_events (
|
|||
CREATE INDEX IF NOT EXISTS audit_events_lookup_idx
|
||||
ON audit_events(account_id, session_id, app_slug, created_at);
|
||||
`;
|
||||
|
||||
const schemaText = CREATE_SCHEMA_SQL.replace(/\r\n/g, "\n");
|
||||
const createdTables = [...schemaText.matchAll(/^CREATE TABLE IF NOT EXISTS (\w+) \(\n([\s\S]*?)\n\);/gm)].map((match) => ({
|
||||
table: match[1]!,
|
||||
lines: match[2]!.split("\n").map((line) => line.trim().replace(/,$/, "")),
|
||||
}));
|
||||
|
||||
/** Sorted and comma-separated, the form the schema check compares key columns in. */
|
||||
function columnList(text: string): string {
|
||||
return text.split(",").map((column) => column.trim()).sort().join(",");
|
||||
}
|
||||
|
||||
/** The columns of a `PRIMARY KEY (a, b)` or `UNIQUE(a, b)` line, as a column list. */
|
||||
function keyColumns(line: string, keyword: "PRIMARY KEY" | "UNIQUE"): string | undefined {
|
||||
const match = new RegExp(`^${keyword}\\s*\\(([^)]*)\\)`).exec(line);
|
||||
return match ? columnList(match[1]!) : undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
* What every start must find before it serves, read from the SQL above so the
|
||||
* two cannot drift. A start that applies the schema checks as well, because
|
||||
* CREATE ... IF NOT EXISTS leaves a damaged table as it is. Indexes that only
|
||||
* speed lookups are not required; the unique ones are, by shape, in
|
||||
* REQUIRED_KEYS.
|
||||
*
|
||||
* Out of scope, each able to make a write fail on a database that passes the
|
||||
* check: CHECK constraints; triggers; row-level security; unique indexes
|
||||
* beyond the required keys, or stricter than them, including the leftovers of
|
||||
* an interrupted concurrent index build (a valid extra one refuses rows the
|
||||
* store expects to write, and even an invalid one that is ready is enforced
|
||||
* on writes); NOT NULL columns the schema does not declare and that have no
|
||||
* default, which the store's INSERTs leave out; and SELECT granted column by
|
||||
* column when a statement reads `SELECT *` or `RETURNING *`, which needs
|
||||
* SELECT on every column the table has, not only the declared ones.
|
||||
*/
|
||||
|
||||
/** Every table the schema creates. */
|
||||
export const REQUIRED_TABLES = createdTables.map(({ table }) => table);
|
||||
|
||||
/**
|
||||
* Every column a created table declares and every column the schema adds to
|
||||
* an existing table, each with its declared type, and whether it is nullable:
|
||||
* the store reads and writes values as that type, and leaves a nullable
|
||||
* column null, so a column retyped or made NOT NULL under it fails its writes.
|
||||
*/
|
||||
export const REQUIRED_COLUMNS = [
|
||||
...createdTables.flatMap(({ table, lines }) =>
|
||||
lines
|
||||
.filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\b/.test(line))
|
||||
.map((line) => {
|
||||
const [, column, type] = /^(\w+) (\w+)/.exec(line)!;
|
||||
return { table, column: column!, type: type!, nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(line) };
|
||||
})
|
||||
),
|
||||
...[...schemaText.matchAll(/^ALTER TABLE (\w+) ADD COLUMN IF NOT EXISTS (\w+) (\w+)([^;]*);/gm)].map((match) => ({
|
||||
table: match[1]!,
|
||||
column: match[2]!,
|
||||
type: match[3]!,
|
||||
nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(match[4]!),
|
||||
})),
|
||||
];
|
||||
|
||||
/**
|
||||
* Every unique key the schema makes: the primary keys and unique constraints
|
||||
* of the created tables, and each unique index it creates, with that index's
|
||||
* predicate as the schema writes it (empty for none). Any index of the right
|
||||
* shape serves, whatever its name; PostgresStore.checkSchema says what the
|
||||
* right shape is.
|
||||
*/
|
||||
export const REQUIRED_KEYS = [
|
||||
...createdTables.flatMap(({ table, lines }) =>
|
||||
lines.flatMap((line) => {
|
||||
const columns =
|
||||
/^(\w+) .*\bPRIMARY KEY\b/.exec(line)?.[1] ?? keyColumns(line, "PRIMARY KEY") ?? keyColumns(line, "UNIQUE");
|
||||
return columns ? [{ table, columns, predicate: "" }] : [];
|
||||
})
|
||||
),
|
||||
...[...schemaText.matchAll(/^CREATE UNIQUE INDEX IF NOT EXISTS \w+\s+ON (\w+)\(([^)]*)\)(?:\s+WHERE ([^;]+))?;/gm)].map((match) => ({
|
||||
table: match[1]!,
|
||||
columns: columnList(match[2]!),
|
||||
predicate: (match[3] ?? "").replace(/\s+/g, " ").trim(),
|
||||
})),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -56,6 +56,23 @@ export interface UpsertLinkedIdentityInput {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the owner of a refresh decides after reading the row under its lock.
|
||||
* `rotate` and `revoke` write token columns, scopes and updated_at only; they
|
||||
* never touch account, profile or identity fields.
|
||||
*/
|
||||
export type CredentialDecision =
|
||||
| { kind: "keep" }
|
||||
| {
|
||||
kind: "rotate";
|
||||
accessTokenEncrypted: string;
|
||||
refreshTokenEncrypted: string;
|
||||
tokenExpiresAt: string;
|
||||
scopes: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
| { kind: "revoke"; updatedAt: string };
|
||||
|
||||
export interface StoredSession {
|
||||
id: string;
|
||||
accountId: string;
|
||||
|
|
@ -213,6 +230,17 @@ export interface HeimdallStore {
|
|||
findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null>;
|
||||
listStoredLinkedIdentitiesForAccount(accountId: string): Promise<StoredLinkedIdentity[]>;
|
||||
upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity>;
|
||||
/**
|
||||
* Serialise decisions about one row's credential. `decide` receives the row
|
||||
* as read under the lock (never a caller's snapshot) and the decision is
|
||||
* committed before this resolves, so a rotated pair is persisted before any
|
||||
* caller sees the new access token. Returns the committed row, or null when
|
||||
* no row has that id. A throw from `decide` writes nothing.
|
||||
*/
|
||||
updateLinkedIdentityCredential(
|
||||
id: string,
|
||||
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,
|
||||
): Promise<StoredLinkedIdentity | null>;
|
||||
listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]>;
|
||||
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;
|
||||
listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@
|
|||
"docs/implementation-plan.md"
|
||||
],
|
||||
"notes": "Current mainline. Repixelizer is the first auth-blank binding target; StreamPixels is the migration and boundary stress test."
|
||||
},
|
||||
{
|
||||
"id": "eureka/joystick-h1",
|
||||
"hypothesis": "One locked refresh primitive plus a Joystick OAuth provider in Heimdall lets StreamPixels ingest Joystick without ever presenting a provider refresh token.",
|
||||
"status": "accepted",
|
||||
"artifacts": [
|
||||
"state/map.yaml"
|
||||
],
|
||||
"notes": "Merged as PR GameCult/Heimdall#1 (d51fa6b), deployed to Yggdrasil 2026-10-19 15:51Z; gamecult-ops 210a1af. Campaign joystick:campaign:self."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,3 +35,5 @@
|
|||
{"ts":"2026-08-22T02:01:00+00:00","type":"deployment","status":"accepted","note":"Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9. Odin b4f9a2e95f0b41cebdeddc49223781d1d3c7b42a returns Heimdall's provider, command boundary, Eve plugin, and transport profile under unique catalog keys. Ghostlight b515ca90c25573005a616244143803b37f2d06ec accepted canonical auth.begin through the Odin-resolved boundary; only advertised Discord and Heimdall navigation origins reached the browser."}
|
||||
{"ts": "2026-09-06T22:12:03+00:00", "type": "design", "status": "accepted", "note": "App slugs are no longer compile-time. AppSlug is a string; membership is a registry lookup, or isAppSlug shape validation where no store is reachable (the offline verifier). Capability rules are data (anyOf over Heimdall's own fact vocabulary), replacing a per-profile evaluateSharedCapabilities function that could not be accepted from a stranger. POST /v1/apps registers in RFC 7591 shape, gated on GC_ACCESS_APP_REGISTRATION_SECRET and closed until it is set. Built-in slugs are reserved so a registration cannot shadow a shipped app's audience. Motivation: a creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server to add one slug."}
|
||||
{"ts": "2026-09-08T12:20:12+00:00", "type": "security", "status": "accepted", "note": "epiphany/heimdall-caller-identity, second Hands pass: closed the two Soul-proven holes. (1) auth-completions redeem code is now always store-minted (never handoff.attemptId); attemptId survives only as a separate correlation field on StoredAuthCompletion/CreateAuthCompletionInput, and a new store.consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets the private command plane's completeAuth redeem by the handle it already owns instead of forging an HTTP self-call with completionCode=handle. (2) startOAuthFlow now refuses any returnTo whose origin is not in AppProfile.allowedReturnOrigins (repixelizer/streampixels/bifrost -> <slug>.gamecult.org, ghostlight -> yggdrasil.gamecult.org per its docs/app-profiles doc and existing test fixtures; a registered app's allowlist is derived from its stored redirectUris origins). AppCaller is now branded with an unexported Symbol (app-caller.ts); private-command-plane.ts's two { appSlug } literals are gone, replaced by app-caller.ts's callerFromOpenedEnvelope. Deleted: validateAppRegistration, AppRegistrationRequest, RegistrationProblem, AppRegistrationError, HeimdallStore.registerApp (writer). Kept: findRegisteredApp/listRegisteredApps/resolveAppProfile fallback -- AppSlug is a plain string (contracts.ts), registered_apps is a real extension point for a slug Heimdall was never rebuilt for, and isBuiltInApp's only production call site (listAppProfiles) depends on that fallback existing; a row is now provisioned by direct SQL (or InMemoryStore.seedRegisteredApp in tests), not by any app-facing writer. Deleted the two appSlug===\"ghostlight\" branches in private-command-plane.ts; the requirement is now AppProfile.requiredEntitlementPolicyKind, checked generically through resolveAppProfile. tsc --noEmit clean; vitest 94 passed/1 pre-existing-skip, only pre-existing failure is tests/odin-publication.test.ts (Cannot find module 'cultcache-ts', present on main too).", "branch": "epiphany/heimdall-caller-identity"}
|
||||
{"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"}
|
||||
{"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."}
|
||||
|
|
|
|||
|
|
@ -59,13 +59,14 @@ data_flow:
|
|||
- reject per-request auth round-trip chatter
|
||||
|
||||
active_subgoals:
|
||||
- Pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries.
|
||||
- Joystick H1 is merged and live (PR GameCult/Heimdall#1, d51fa6b; campaign joystick:campaign:self). Open follow-ups by id: joystick:follow_up:h1-joystick-consent-round-trip (next), cross-process-failed-refresh-marker, connect-start-needs-app-auth, heimdall-idunn-onboarding, joystick-viewer-signin-public-bot. StreamPixels S0/S1 run in other sessions.
|
||||
- OPEN, behind the H1 live round-trips: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries.
|
||||
- Keep the shared architecture explicit and separate from app profiles.
|
||||
- Preserve the default boundary that app-domain data remains app-owned.
|
||||
- Align the plan to Heimdall as a Yggdrasil-hosted authority service.
|
||||
- Publish Heimdall daemon health and boundary state for Idunn/Odin without moving auth truth out of Heimdall.
|
||||
- Verify the Discord and Patreon Repixelizer auth paths with real provider credentials, stable signing, persistent storage, and live browser callback verification.
|
||||
- Wire StreamPixels through Heimdall for Twitch/YouTube viewer identity and managed creator-provider credentials without moving StreamPixels domain state into Heimdall.
|
||||
- Wire StreamPixels through Heimdall for Twitch/YouTube (Joystick joins under H1) viewer identity and managed creator-provider credentials without moving StreamPixels domain state into Heimdall.
|
||||
|
||||
accepted_design:
|
||||
- `gamecult.heimdall.access` owns access-gate representation and browser navigation semantics; it has no access-grant or provider-state authority.
|
||||
|
|
@ -94,7 +95,7 @@ accepted_design:
|
|||
|
||||
landed_baseline:
|
||||
- Heimdall publishes its redacted provider, command boundary, Eve plugin, and transport profile through Odin under globally unique record keys; Ghostlight resolves that boundary instead of owning a direct endpoint.
|
||||
- Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted.
|
||||
- Yggdrasil runs Heimdall d51fa6b (hand-deployed 2026-10-19 15:51Z; gamecult-ops 210a1af holds backup, rollback and provenance; prior 1086aee) with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted.
|
||||
- The Heimdall access plugin, persistent OAuth attempts, encrypted private CultNet commands, conformance fixtures, and browser reference adapter are implemented and locally verified.
|
||||
- Exact-session refresh validation and logout revocation are implemented; stale or racing refresh cannot resurrect a revoked session.
|
||||
- Shared architecture extracted into its own repo and renamed to Heimdall.
|
||||
|
|
@ -125,5 +126,5 @@ rejected_paths:
|
|||
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
|
||||
|
||||
current_status:
|
||||
summary: Yggdrasil runs the gamecult.heimdall.access plugin and encrypted loopback begin, complete, refresh, and logout plane at 1086aee01169bf60e8a492b2740db1c6f3e8cabf. Odin returns all four redacted discovery records, Ghostlight resolves the boundary through Odin, and live anonymous auth.begin is accepted without exposing claims or secrets.
|
||||
next_action: Run separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets.
|
||||
summary: Yggdrasil runs Heimdall release d51fa6b (hand-deployed 2026-10-19 15:51Z, gamecult-ops 210a1af), which includes Joystick H1 (PR #1). The plugin and encrypted loopback begin, complete, refresh and logout plane, the Odin discovery records and Ghostlight resolution were verified at 1086aee (2026-08-22) and are not re-verified at d51fa6b.
|
||||
next_action: Run joystick:follow_up:h1-joystick-consent-round-trip, then StreamPixels S0/S1 (other sessions). Still open behind that: separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets.
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ function testConfig(): HeimdallConfig {
|
|||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: {
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {},
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -65,6 +65,7 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ function testConfig(): HeimdallConfig {
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ function testConfig(): HeimdallConfig {
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
225
tests/config-secrets.test.ts
Normal file
225
tests/config-secrets.test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { loadConfig, readSecretInput } from "../src/config.js";
|
||||
import { appSlugs, providers } from "../src/contracts.js";
|
||||
import { providerCatalog, providerExpectedEnv } from "../src/providers.js";
|
||||
|
||||
// Secret inputs arrive as files named by `NAME_FILE` (systemd credentials under
|
||||
// Idunn). These tests pin the reader's rules and that every secret Heimdall
|
||||
// consumes goes through it.
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "heimdall-secret-input-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function secretFile(contents: string, name = "secret"): string {
|
||||
const path = join(tempDir(), name);
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
function thrownMessage(action: () => unknown): string {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
throw new Error("expected the action to throw");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("readSecretInput", () => {
|
||||
it("reads the value from the file named by NAME_FILE and strips exactly one trailing newline", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["from-the-file\n", "from-the-file"],
|
||||
["from-the-file\r\n", "from-the-file"],
|
||||
["two-newlines\n\n", "two-newlines\n"],
|
||||
["line-one\nline-two\n", "line-one\nline-two"],
|
||||
[" inner spaces kept \n", " inner spaces kept "],
|
||||
["no-newline", "no-newline"],
|
||||
];
|
||||
|
||||
for (const [contents, expected] of cases) {
|
||||
const env = { EXAMPLE_SECRET_FILE: secretFile(contents) };
|
||||
expect(readSecretInput(env, "EXAMPLE_SECRET")).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined when neither form is set", () => {
|
||||
expect(readSecretInput({}, "EXAMPLE_SECRET")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the plaintext name only while NAME_FILE is absent (legacy shim)", () => {
|
||||
expect(readSecretInput({ EXAMPLE_SECRET: "legacy-plaintext" }, "EXAMPLE_SECRET")).toBe("legacy-plaintext");
|
||||
});
|
||||
|
||||
it("refuses both forms at once, and the refusal carries neither value", () => {
|
||||
const fileCanary = "canary-file-7c1e0f";
|
||||
const plainCanary = "canary-plain-93ab2d";
|
||||
const env = {
|
||||
EXAMPLE_SECRET: plainCanary,
|
||||
EXAMPLE_SECRET_FILE: secretFile(fileCanary),
|
||||
};
|
||||
|
||||
const message = thrownMessage(() => readSecretInput(env, "EXAMPLE_SECRET"));
|
||||
expect(message).toContain("EXAMPLE_SECRET_FILE");
|
||||
expect(message).toMatch(/both set/);
|
||||
expect(message).not.toContain(fileCanary);
|
||||
expect(message).not.toContain(plainCanary);
|
||||
expect(message).not.toContain("canary");
|
||||
});
|
||||
|
||||
it("refuses both forms even when the plaintext form is empty", () => {
|
||||
const env = { EXAMPLE_SECRET: "", EXAMPLE_SECRET_FILE: secretFile("value") };
|
||||
expect(() => readSecretInput(env, "EXAMPLE_SECRET")).toThrow(/both set/);
|
||||
});
|
||||
|
||||
it("refuses an unreadable path naming only the variable and the code, not the path", () => {
|
||||
// A secret bound into a _FILE slot by mistake must not be printed back.
|
||||
const canaryPath = join(tempDir(), "canary-path-3b7e1d");
|
||||
const message = thrownMessage(() => readSecretInput({ EXAMPLE_SECRET_FILE: canaryPath }, "EXAMPLE_SECRET"));
|
||||
expect(message).toContain("EXAMPLE_SECRET_FILE");
|
||||
expect(message).toContain("ENOENT");
|
||||
expect(message).not.toContain("canary");
|
||||
});
|
||||
|
||||
it("refuses a _FILE value that is not an absolute path, without echoing it", () => {
|
||||
for (const value of ["postgres://heimdall:canary-pw-6f02aa@db/heimdall", "relative/canary-rel-0c9d", ""]) {
|
||||
const message = thrownMessage(() => readSecretInput({ EXAMPLE_SECRET_FILE: value }, "EXAMPLE_SECRET"));
|
||||
expect(message).toBe("EXAMPLE_SECRET_FILE is not an absolute path.");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat an empty _FILE as unset, even with no plaintext form", () => {
|
||||
expect(() => readSecretInput({ EXAMPLE_SECRET_FILE: "" }, "EXAMPLE_SECRET")).toThrow(/EXAMPLE_SECRET_FILE/);
|
||||
});
|
||||
|
||||
it("refuses an unreadable file, and carries no byte of what sits in or beside it", () => {
|
||||
const dir = tempDir();
|
||||
const canary = "canary-sibling-5d2f88";
|
||||
writeFileSync(join(dir, "sibling"), canary);
|
||||
const notAFile = join(dir, "credential");
|
||||
mkdirSync(notAFile);
|
||||
writeFileSync(join(notAFile, "inside"), canary);
|
||||
|
||||
const message = thrownMessage(() => readSecretInput({ EXAMPLE_SECRET_FILE: notAFile }, "EXAMPLE_SECRET"));
|
||||
expect(message).toBe("EXAMPLE_SECRET_FILE could not be read (EISDIR).");
|
||||
});
|
||||
|
||||
it("refuses a blank secret file rather than treating it as unset", () => {
|
||||
for (const contents of ["", "\n", " \n", "\t\r\n", " "]) {
|
||||
const env = { EXAMPLE_SECRET_FILE: secretFile(contents) };
|
||||
expect(thrownMessage(() => readSecretInput(env, "EXAMPLE_SECRET"))).toBe("EXAMPLE_SECRET_FILE is empty.");
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a blank plaintext secret too", () => {
|
||||
for (const value of ["", " ", "\n"]) {
|
||||
expect(thrownMessage(() => readSecretInput({ EXAMPLE_SECRET: value }, "EXAMPLE_SECRET"))).toBe(
|
||||
"EXAMPLE_SECRET is empty."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a value whose only whitespace is inside or around real content", () => {
|
||||
const env = { EXAMPLE_SECRET_FILE: secretFile(" padded value \n") };
|
||||
expect(readSecretInput(env, "EXAMPLE_SECRET")).toBe(" padded value ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig secret inputs", () => {
|
||||
it("configures every secret Heimdall consumes from its _FILE twin alone", () => {
|
||||
const env: Record<string, string> = {};
|
||||
const expected = new Map<string, string>();
|
||||
function bind(name: string): string {
|
||||
const value = `value-of-${name.toLowerCase()}`;
|
||||
env[`${name}_FILE`] = secretFile(`${value}\n`, name);
|
||||
expected.set(name, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
const databaseUrl = bind("GC_ACCESS_DATABASE_URL");
|
||||
const tokenKey = bind("GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64");
|
||||
const patronSecret = bind("GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET");
|
||||
const providerSecrets = Object.fromEntries(
|
||||
providers.map((provider) => [provider, bind(`GC_ACCESS_PROVIDER_${provider.toUpperCase()}_CLIENT_SECRET`)])
|
||||
);
|
||||
const appSecrets = Object.fromEntries(
|
||||
appSlugs.map((slug) => [slug, bind(`GC_ACCESS_APP_${slug.toUpperCase()}_SHARED_SECRET`)])
|
||||
);
|
||||
|
||||
const config = loadConfig(env, []);
|
||||
|
||||
expect(config.storage.databaseUrl).toBe(databaseUrl);
|
||||
expect(config.tokenEncryptionKeyBase64).toBe(tokenKey);
|
||||
expect(config.bifrostPatronSupportSecret).toBe(patronSecret);
|
||||
for (const provider of providers) {
|
||||
expect(config.providers[provider].clientSecret).toBe(providerSecrets[provider]);
|
||||
}
|
||||
expect(config.appSharedSecrets).toEqual(appSecrets);
|
||||
});
|
||||
|
||||
it("selects postgres storage from a database URL given only as a file", () => {
|
||||
const config = loadConfig(
|
||||
{ GC_ACCESS_DATABASE_URL_FILE: secretFile("postgres://heimdall@localhost/heimdall\n") },
|
||||
[]
|
||||
);
|
||||
expect(config.storage.backend).toBe("postgres");
|
||||
});
|
||||
|
||||
it("keeps memory storage when no database URL is bound, and postgres when it is asked for", () => {
|
||||
expect(loadConfig({}, []).storage.backend).toBe("memory");
|
||||
expect(loadConfig({ GC_ACCESS_STORAGE_BACKEND: "memory" }, []).storage.backend).toBe("memory");
|
||||
expect(loadConfig({ GC_ACCESS_STORAGE_BACKEND: "postgres" }, []).storage.backend).toBe("postgres");
|
||||
});
|
||||
|
||||
it("refuses to start when the database URL file is absent", () => {
|
||||
const missing = join(tempDir(), "database-url");
|
||||
const message = thrownMessage(() => loadConfig({ GC_ACCESS_DATABASE_URL_FILE: missing }, []));
|
||||
expect(message).toBe("GC_ACCESS_DATABASE_URL_FILE could not be read (ENOENT).");
|
||||
});
|
||||
|
||||
it("refuses to start on a blank database URL file instead of falling back to memory storage", () => {
|
||||
for (const contents of ["", "\n", " \n"]) {
|
||||
const env = { GC_ACCESS_DATABASE_URL_FILE: secretFile(contents) };
|
||||
expect(() => loadConfig(env, [])).toThrow("GC_ACCESS_DATABASE_URL_FILE is empty.");
|
||||
}
|
||||
});
|
||||
|
||||
// One per call site: a site that reads the plaintext name itself before the
|
||||
// reader would let the plaintext win silently instead of refusing.
|
||||
const bothSetSites: Array<[string, string]> = [
|
||||
["token encryption key", "GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64"],
|
||||
["provider client secret", "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET"],
|
||||
["app shared secret", "GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET"],
|
||||
["database URL", "GC_ACCESS_DATABASE_URL"],
|
||||
["patron support secret", "GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET"],
|
||||
];
|
||||
for (const [site, name] of bothSetSites) {
|
||||
it(`refuses the ${site} bound both ways at startup`, () => {
|
||||
const env = { [name]: "plain", [`${name}_FILE`]: secretFile("file") };
|
||||
expect(() => loadConfig(env, [])).toThrow(`${name} and ${name}_FILE are both set`);
|
||||
});
|
||||
}
|
||||
|
||||
it("publishes provider secret variable names that actually configure the secret", () => {
|
||||
for (const provider of providers) {
|
||||
const secretVariable = providerCatalog[provider].clientSecretEnv;
|
||||
expect(providerExpectedEnv(provider)).toContain(secretVariable);
|
||||
|
||||
const config = loadConfig({ [secretVariable]: secretFile(`${provider}-secret\n`) }, []);
|
||||
expect(config.providers[provider].clientSecret).toBe(`${provider}-secret`);
|
||||
}
|
||||
});
|
||||
});
|
||||
253
tests/entrypoint-secrets.test.ts
Normal file
253
tests/entrypoint-secrets.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
// A secret must never reach a terminal. Under Idunn, Heimdall's stderr goes to
|
||||
// the journal and the install script tails the journal to the operator's
|
||||
// terminal, so the layer that matters is the process's own output, not an
|
||||
// error's message. These cases run the real entrypoint with canary values in
|
||||
// the places a misbinding puts secrets, and fail on any byte of the canary
|
||||
// anywhere on stdout or stderr: in a message, an error cause, an error
|
||||
// property, or anything else Node chooses to print.
|
||||
|
||||
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
|
||||
const scratch = mkdtempSync(join(tmpdir(), "heimdall-entrypoint-"));
|
||||
const CANARY = "CANARY";
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function file(name: string, contents: string): string {
|
||||
const path = join(scratch, name);
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
output: string;
|
||||
}
|
||||
|
||||
interface Identity {
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who can be denied a read. Root reads a mode-000 file, so a read failure is
|
||||
* only reachable as another user: the process itself when it is not root, or
|
||||
* else the checkout's owner, who can still load the entrypoint. A string is
|
||||
* the reason no such user exists here.
|
||||
*/
|
||||
function nonRootReader(): Identity | "self" | string {
|
||||
if (process.platform === "win32") return "file modes do not deny reads on Windows";
|
||||
if (process.getuid?.() !== 0) return "self";
|
||||
const owner = statSync(repoRoot);
|
||||
if (owner.uid === 0) return "running as root in a root-owned checkout, so no other user can load the entrypoint";
|
||||
return { uid: owner.uid, gid: owner.gid };
|
||||
}
|
||||
|
||||
/** Runs `tsx src/index.ts` with only the given environment, merging stdout and stderr. */
|
||||
function runEntrypoint(name: string, env: Record<string, string>, identity?: Identity): Promise<Run> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [tsxCli, "src/index.ts"], {
|
||||
cwd: repoRoot,
|
||||
...identity,
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: scratch,
|
||||
GC_ACCESS_DATA_ROOT: join(scratch, `data-${name}`),
|
||||
PORT: "0",
|
||||
GC_ACCESS_PRIVATE_COMMAND_PORT: "0",
|
||||
...env,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
|
||||
child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
|
||||
// A case that fails to fail would start serving; stop it rather than hang.
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 30_000);
|
||||
child.on("error", reject);
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, signal, output });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** A base64 value that decodes to exactly 32 bytes and still carries the canary. */
|
||||
function canaryTokenKey(tag: string): string {
|
||||
return `${CANARY}${tag}`.padEnd(43, "A") + "=";
|
||||
}
|
||||
|
||||
interface Case {
|
||||
name: string;
|
||||
env: () => Record<string, string>;
|
||||
/** Something the failure must say, so a case cannot pass by never reaching its path. */
|
||||
expect: RegExp;
|
||||
/** Run the entrypoint as a user a file mode can refuse. */
|
||||
nonRoot?: true;
|
||||
}
|
||||
|
||||
const cases: Case[] = [
|
||||
{
|
||||
name: "a URL bound into an absolute _FILE slot",
|
||||
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: `/postgres://heimdall:${CANARY}url@db/heimdall` }),
|
||||
expect: /GC_ACCESS_DATABASE_URL_FILE could not be read \(ENOENT\)/,
|
||||
},
|
||||
{
|
||||
name: "a _FILE path under a regular file",
|
||||
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: join(file("plain", "x"), `${CANARY}notdir`) }),
|
||||
expect: /GC_ACCESS_DATABASE_URL_FILE could not be read \(ENOTDIR\)/,
|
||||
},
|
||||
{
|
||||
name: "a secret bound into a relative _FILE slot",
|
||||
env: () => ({ GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE: `${CANARY}relative-secret` }),
|
||||
expect: /GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE is not an absolute path/,
|
||||
},
|
||||
{
|
||||
name: "a secret bound both ways",
|
||||
env: () => ({
|
||||
GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET: `${CANARY}plain`,
|
||||
GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET_FILE: file("both", `${CANARY}file\n`),
|
||||
}),
|
||||
expect: /GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET and GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET_FILE are both set/,
|
||||
},
|
||||
{
|
||||
name: "a blank secret file",
|
||||
env: () => ({ GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE: join(scratch, "blank") }),
|
||||
expect: /GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE is empty/,
|
||||
},
|
||||
{
|
||||
name: "a token key that decodes short",
|
||||
env: () => ({ GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE: file("short-key", `${CANARY}shortkey\n`) }),
|
||||
expect: /GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE must decode to exactly 32 bytes/,
|
||||
},
|
||||
{
|
||||
name: "a database URL with an unusable port",
|
||||
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-port", `postgres://heimdall:${CANARY}port@db:notaport/heimdall\n`) }),
|
||||
expect: /Postgres storage could not be prepared \(ERR_INVALID_URL\)/,
|
||||
},
|
||||
{
|
||||
name: "a database URL that does not parse",
|
||||
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-parse", `postgres://heimdall:${CANARY} parse@[bad/heimdall\n`) }),
|
||||
expect: /Postgres storage could not be prepared \(E[A-Z_]+\)/,
|
||||
},
|
||||
{
|
||||
name: "a database URL with a broken percent escape",
|
||||
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-percent", `postgres://heimdall:${CANARY}%zz@127.0.0.1:1/heimdall\n`) }),
|
||||
expect: /Postgres storage could not be prepared \(ECONNREFUSED\)/,
|
||||
},
|
||||
{
|
||||
name: "a database that refuses the connection",
|
||||
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-refused", `postgres://heimdall:${CANARY}refused@127.0.0.1:1/heimdall\n`) }),
|
||||
expect: /Postgres storage could not be prepared \(ECONNREFUSED\)/,
|
||||
},
|
||||
{
|
||||
// A password holding an unescaped delimiter moves part of it into the
|
||||
// host, and the resolver's error names the host.
|
||||
name: "a database host that does not resolve",
|
||||
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-host", `postgres://heimdall:pw@${CANARY}host.invalid/heimdall\n`) }),
|
||||
expect: /Postgres storage could not be prepared \(E[A-Z_]+\)/,
|
||||
},
|
||||
{
|
||||
// A start that does not apply the schema still connects, so a misbound URL
|
||||
// fails at startup rather than in a request's error answer.
|
||||
name: "a database host that does not resolve, on a start that does not apply the schema",
|
||||
env: () => ({
|
||||
GC_ACCESS_APPLY_SCHEMA_ON_STARTUP: "0",
|
||||
GC_ACCESS_DATABASE_URL_FILE: file("db-host-noapply", `postgres://heimdall:pw@${CANARY}host.invalid/heimdall\n`),
|
||||
}),
|
||||
expect: /Postgres storage could not be prepared \(E[A-Z_]+\)/,
|
||||
},
|
||||
{
|
||||
name: "private key text bound to the signing key path",
|
||||
env: () => ({
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: `-----BEGIN PRIVATE KEY-----\n${CANARY}pem\n-----END PRIVATE KEY-----\n`,
|
||||
}),
|
||||
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH is not an absolute path/,
|
||||
},
|
||||
{
|
||||
name: "a signing key path that names no file",
|
||||
env: () => ({ GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: join(scratch, `${CANARY}missing-key`) }),
|
||||
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH names no file \(ENOENT\)/,
|
||||
},
|
||||
{
|
||||
name: "a signing key file that is not a key",
|
||||
env: () => ({ GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: file("not-a-key", `${CANARY}notakey\n`) }),
|
||||
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH does not hold a usable private key/,
|
||||
},
|
||||
{
|
||||
name: "a signing key file its reader may not read",
|
||||
env: () => {
|
||||
const key = file(`${CANARY}locked-key`, `${CANARY}pem\n`);
|
||||
chmodSync(key, 0o000);
|
||||
const dataRoot = join(scratch, "locked-key-data");
|
||||
mkdirSync(dataRoot, { recursive: true });
|
||||
chmodSync(dataRoot, 0o777);
|
||||
return { GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: key, GC_ACCESS_DATA_ROOT: dataRoot };
|
||||
},
|
||||
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH could not be read \(EACCES\)/,
|
||||
nonRoot: true,
|
||||
},
|
||||
{
|
||||
name: "a signing key path that is a directory",
|
||||
env: () => {
|
||||
const dir = join(scratch, "key-dir");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return { GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: dir };
|
||||
},
|
||||
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH could not be read \(EISDIR\)/,
|
||||
},
|
||||
{
|
||||
name: "a bootstrapped signing key whose directory cannot be created",
|
||||
env: () => ({
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: join(file("key-parent", "x"), `${CANARY}dir`, "key.pem"),
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP: "1",
|
||||
}),
|
||||
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH could not be created \(ENOTDIR\)/,
|
||||
},
|
||||
{
|
||||
// Every secret is configured and valid, and startup fails afterwards, in
|
||||
// the entrypoint's own catch: the one place that holds the whole config.
|
||||
name: "a listen failure with every secret configured",
|
||||
env: () => ({
|
||||
HOST: "203.0.113.1",
|
||||
GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE: file("listen-key", `${canaryTokenKey("key")}\n`),
|
||||
GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE: file("listen-patron", `${CANARY}patron\n`),
|
||||
GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE: file("listen-github", `${CANARY}github\n`),
|
||||
GC_ACCESS_APP_BIFROST_SHARED_SECRET_FILE: file("listen-bifrost", `${CANARY}bifrost\n`),
|
||||
}),
|
||||
expect: /Heimdall failed to start/,
|
||||
},
|
||||
];
|
||||
|
||||
describe("the entrypoint never prints a secret", () => {
|
||||
writeFileSync(join(scratch, "blank"), " \n");
|
||||
// mkdtemp makes the scratch directory private to its creator; a non-root
|
||||
// reader must still reach the files a case names inside it.
|
||||
chmodSync(scratch, 0o755);
|
||||
const reader = nonRootReader();
|
||||
|
||||
for (const testCase of cases) {
|
||||
const skipReason = testCase.nonRoot && typeof reader === "string" && reader !== "self" ? reader : undefined;
|
||||
const title = `refuses ${testCase.name} without printing the canary${skipReason ? ` (skipped: ${skipReason})` : ""}`;
|
||||
it.skipIf(skipReason !== undefined)(title, async () => {
|
||||
const identity = testCase.nonRoot && typeof reader === "object" ? reader : undefined;
|
||||
const run = await runEntrypoint(testCase.name.replace(/\W+/g, "-"), testCase.env(), identity);
|
||||
|
||||
expect(run.signal, "the process must stop by itself").toBeNull();
|
||||
expect(run.code, "startup must fail").not.toBe(0);
|
||||
expect(run.output).toMatch(testCase.expect);
|
||||
expect(run.output.includes(CANARY), "no byte of the canary may reach the terminal").toBe(false);
|
||||
}, 45_000);
|
||||
}
|
||||
});
|
||||
47
tests/fastify-malformed-url.test.ts
Normal file
47
tests/fastify-malformed-url.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// GHSA-p68q-wchp-6fh7 (fastify < 5.12.2): a malformed request target under one
|
||||
// plugin prefix, with a method that has no route in the main router, was
|
||||
// dispatched to the not-found handler of a sibling plugin, skipping that
|
||||
// handler's preHandler. This pins the framework fix; it is not about a Heimdall
|
||||
// route.
|
||||
describe("fastify malformed URL under sibling not-found handlers", () => {
|
||||
const apps: Array<ReturnType<typeof Fastify>> = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
async function build() {
|
||||
const app = Fastify();
|
||||
apps.push(app);
|
||||
await app.register(async (pub) => {
|
||||
pub.get("/ping", async () => "pong");
|
||||
}, { prefix: "/public" });
|
||||
await app.register(async (priv) => {
|
||||
priv.setNotFoundHandler(
|
||||
{
|
||||
preHandler: async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!request.headers.authorization) return reply.code(401).send({ error: "auth" });
|
||||
},
|
||||
},
|
||||
(_request: FastifyRequest, reply: FastifyReply) => reply.code(404).send({ secret: "PRIVATE-TENANT-DATA" }),
|
||||
);
|
||||
priv.get("/x", async () => "x");
|
||||
}, { prefix: "/private" });
|
||||
return app;
|
||||
}
|
||||
|
||||
it("fails closed with 400 and never reaches another prefix's not-found handler", async () => {
|
||||
const app = await build();
|
||||
const response = await app.inject({ method: "DELETE", url: "/public/%zz" });
|
||||
expect(response.body).not.toContain("PRIVATE-TENANT-DATA");
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("still runs the authenticated not-found preHandler for a well-formed URL", async () => {
|
||||
const app = await build();
|
||||
const response = await app.inject({ method: "DELETE", url: "/private/nothing" });
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
64
tests/fixtures/fake-idunn-rudp.ts
vendored
Normal file
64
tests/fixtures/fake-idunn-rudp.ts
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* A stand-in for Idunn's health endpoint: it accepts each RUDP connect, acks
|
||||
* the frame that follows and keeps every delivered frame. Heimdall opens a
|
||||
* fresh socket and session per publication, so sessions are kept per remote.
|
||||
*/
|
||||
import dgram from "node:dgram";
|
||||
import {
|
||||
CultNetRudpSession,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
type CultNetTransportFrame,
|
||||
} from "cultnet-ts";
|
||||
|
||||
export interface FakeIdunnRudp {
|
||||
port: number;
|
||||
frames: CultNetTransportFrame[];
|
||||
/** Resolves with the nth delivered frame (0-based) once it has arrived. */
|
||||
frame(index: number): Promise<CultNetTransportFrame>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export async function startFakeIdunnRudp(): Promise<FakeIdunnRudp> {
|
||||
const server = dgram.createSocket("udp4");
|
||||
const sessions = new Map<string, CultNetRudpSession>();
|
||||
const frames: CultNetTransportFrame[] = [];
|
||||
const waiting: Array<{ index: number; resolve: (frame: CultNetTransportFrame) => void }> = [];
|
||||
|
||||
server.on("message", (wire, remote) => {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
const key = `${remote.address}:${remote.port}`;
|
||||
if (packet.packetType === "connect") {
|
||||
const session = new CultNetRudpSession({
|
||||
connectionId: 0x1d0d0001,
|
||||
initialSequence: 1,
|
||||
resendDelayMs: 100,
|
||||
});
|
||||
sessions.set(key, session);
|
||||
server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address);
|
||||
return;
|
||||
}
|
||||
const session = sessions.get(key);
|
||||
if (!session) return;
|
||||
const result = session.receive(packet, Date.now());
|
||||
if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address);
|
||||
for (const frame of result.delivered) {
|
||||
frames.push(frame);
|
||||
for (const waiter of waiting.filter((entry) => entry.index === frames.length - 1)) waiter.resolve(frame);
|
||||
}
|
||||
if (result.delivered.length > 0) {
|
||||
server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address);
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
|
||||
|
||||
return {
|
||||
port: server.address().port,
|
||||
frames,
|
||||
frame: (index) =>
|
||||
frames[index]
|
||||
? Promise.resolve(frames[index]!)
|
||||
: new Promise((resolve) => waiting.push({ index, resolve })),
|
||||
close: () => server.close(),
|
||||
};
|
||||
}
|
||||
1
tests/fixtures/idunn-runtime/.gitattributes
vendored
Normal file
1
tests/fixtures/idunn-runtime/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* -text
|
||||
11
tests/fixtures/idunn-runtime/README.md
vendored
Normal file
11
tests/fixtures/idunn-runtime/README.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Idunn runtime fixtures
|
||||
|
||||
Written by the Rust owner, not by Heimdall's tests. Copied byte for byte from CultLib
|
||||
`217a9480`, `packages/cultnet-ts/test/fixtures/idunn-runtime/web` (`expected.cc`,
|
||||
`activation.cc`, `activation-key.seed`, `provider-identity.credential`) and that directory's
|
||||
`machine-id`. CultLib regenerates them with `packages/cultnet-rs/examples/idunn_runtime_fixture.rs`
|
||||
(its own README has the Yggdrasil command); the provider identity is bound to `machine-id`, so a
|
||||
test that decodes it supplies that machine id instead of `/etc/machine-id`.
|
||||
|
||||
Every key here is a test key. `provider-identity.credential` is the shape Idunn passes on the
|
||||
presence-identity descriptor: key fields are 32-integer arrays, which only the Rust owner writes.
|
||||
1
tests/fixtures/idunn-runtime/activation-key.seed
vendored
Normal file
1
tests/fixtures/idunn-runtime/activation-key.seed
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
¯ÂOd×f¯l*oµ‘ÀFªv‰n<E280B0>hLûÑpšS´p
|
||||
BIN
tests/fixtures/idunn-runtime/activation.cc
vendored
Normal file
BIN
tests/fixtures/idunn-runtime/activation.cc
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/idunn-runtime/expected.cc
vendored
Normal file
BIN
tests/fixtures/idunn-runtime/expected.cc
vendored
Normal file
Binary file not shown.
1
tests/fixtures/idunn-runtime/machine-id
vendored
Normal file
1
tests/fixtures/idunn-runtime/machine-id
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1
|
||||
BIN
tests/fixtures/idunn-runtime/provider-identity.credential
vendored
Normal file
BIN
tests/fixtures/idunn-runtime/provider-identity.credential
vendored
Normal file
Binary file not shown.
61
tests/fixtures/presence-descriptors.ts
vendored
Normal file
61
tests/fixtures/presence-descriptors.ts
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* What Idunn hands a generation for runtime presence: the bundle directory, a
|
||||
* parent-only identity store and an activation seed, the last two passed as
|
||||
* descriptors named in LISTEN_FDNAMES. The bytes are the Rust owner's, copied
|
||||
* from CultLib (see idunn-runtime/README.md); every key in them is a test key.
|
||||
*/
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { chmod, copyFile, mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME } from "cultnet-ts";
|
||||
|
||||
export const idunnRuntimeFixtures = path.join(import.meta.dirname, "idunn-runtime");
|
||||
|
||||
const FIXTURE_FILES = [
|
||||
"expected.cc",
|
||||
"activation.cc",
|
||||
"activation-key.seed",
|
||||
"provider-identity.credential",
|
||||
"machine-id",
|
||||
] as const;
|
||||
|
||||
/** The machine id the fixture identity is bound to, in place of /etc/machine-id. */
|
||||
export const fixtureMachineId = async (dir: string = idunnRuntimeFixtures): Promise<string> =>
|
||||
(await readFile(path.join(dir, "machine-id"), "utf8")).trim();
|
||||
|
||||
/** Copy the fixtures into `dir`, readable by any uid (the children run as others). */
|
||||
export async function writePresenceFixtures(dir: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await chmod(dir, 0o755);
|
||||
for (const name of FIXTURE_FILES) {
|
||||
const target = path.join(dir, name);
|
||||
await copyFile(path.join(idunnRuntimeFixtures, name), target);
|
||||
await chmod(target, 0o644);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open both descriptors and name them the way systemd's LISTEN_FDNAMES does. */
|
||||
export function openPresenceDescriptors(dir: string): { env: NodeJS.ProcessEnv; close: () => void } {
|
||||
const fds = [
|
||||
[openSync(path.join(dir, "provider-identity.credential"), "r"), "gamecult-runtime-presence-identity"],
|
||||
[openSync(path.join(dir, "activation-key.seed"), "r"), IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME],
|
||||
] as const;
|
||||
const names: string[] = [];
|
||||
for (const [fd, name] of fds) {
|
||||
while (names.length < fd - 3) names.push(`pad-${names.length}`);
|
||||
names[fd - 3] = name;
|
||||
}
|
||||
return {
|
||||
env: { LISTEN_FDNAMES: names.join(":") },
|
||||
// The authority closes each descriptor it reads; this releases only what it did not.
|
||||
close: () =>
|
||||
fds.forEach(([fd]) => {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
// already closed by the reader
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
57
tests/fixtures/state-root-generation-child.ts
vendored
Normal file
57
tests/fixtures/state-root-generation-child.ts
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* One Idunn generation, as a process: it runs as the uid and gid its parent
|
||||
* gave it, under UMask=0007, and does what Heimdall does to a state root on a
|
||||
* pulse. The parent test spawns two of these under different uids that share
|
||||
* a group.
|
||||
*
|
||||
* argv: <role: first|next> <stateRoot> <fixtureDir>
|
||||
*/
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { loadConfig } from "../../src/config.js";
|
||||
import { openPresenceAuthority } from "../../src/runtime-presence.js";
|
||||
import { fixtureMachineId, openPresenceDescriptors } from "./presence-descriptors.js";
|
||||
import { createHeimdallRuntimePulse, publishHeimdallVerseState } from "../../src/verse-state.js";
|
||||
|
||||
const [role, stateRoot, fixtureDir] = process.argv.slice(2) as [string, string, string];
|
||||
process.umask(0o007);
|
||||
|
||||
const config = loadConfig({
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
|
||||
GC_ACCESS_DATA_ROOT: stateRoot,
|
||||
});
|
||||
|
||||
async function publishPresence(): Promise<void> {
|
||||
const descriptors = openPresenceDescriptors(fixtureDir);
|
||||
try {
|
||||
const authority = await openPresenceAuthority(
|
||||
{
|
||||
idunnRuntimeBundlePath: fixtureDir,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
providerHealthIdentityPath: path.join(stateRoot, "provider-health-identity.cc"),
|
||||
},
|
||||
descriptors.env,
|
||||
() => fixtureMachineId(fixtureDir),
|
||||
);
|
||||
authority.sign("warming", "generation probe", Date.now());
|
||||
} finally {
|
||||
descriptors.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (role === "next") {
|
||||
// Generation N+1 reads what N left before it replaces it.
|
||||
const previous = await readFile(config.cultCachePath);
|
||||
if (previous.length === 0) throw new Error("previous generation's snapshot is empty");
|
||||
}
|
||||
|
||||
await publishHeimdallVerseState(config, createHeimdallRuntimePulse(config));
|
||||
await publishPresence();
|
||||
|
||||
if (role === "first") {
|
||||
// The 0600 counter the 2026-09-07 generation left behind. Nothing reads it
|
||||
// now; it stays as the stale fixture the next generation must live beside.
|
||||
await writeFile(path.join(stateRoot, "runtime-presence-sequence"), "41\n", { mode: 0o600 });
|
||||
}
|
||||
320
tests/idunn-boot.test.ts
Normal file
320
tests/idunn-boot.test.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import { createPublicKey, createPrivateKey, verify } from "node:crypto";
|
||||
import dgram from "node:dgram";
|
||||
import { chmod, copyFile, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import {
|
||||
RUNTIME_PRESENCE_SLOT,
|
||||
parseCultNetMessage,
|
||||
runtimePresenceActivationSigningMessage,
|
||||
runtimePresenceProviderSigningMessage,
|
||||
} from "cultnet-ts";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { startFakeIdunnRudp, type FakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js";
|
||||
import { idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
const GENERATION_UID = 61184;
|
||||
const STATE_GID = 4242;
|
||||
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
|
||||
// The launch needs root (to pick the uid and bind a machine-id) and a mount
|
||||
// namespace (so the fixture machine-id does not replace the host's). The
|
||||
// stopgap's node container has both; anywhere else the test steps aside.
|
||||
const canLaunch =
|
||||
process.getuid?.() === 0 &&
|
||||
(await run("unshare", ["--mount", "--", "true"]).then(
|
||||
() => true,
|
||||
() => false
|
||||
));
|
||||
|
||||
/**
|
||||
* The artifact under test. By default the esbuild bundle `pnpm bundle` makes,
|
||||
* run by this process's node; a runbook proves a sealed release by naming its
|
||||
* bundle and node here instead.
|
||||
*/
|
||||
const bundleOverride = process.env.HEIMDALL_BOOT_BUNDLE;
|
||||
const nodeOverride = process.env.HEIMDALL_BOOT_NODE;
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = net.createServer();
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => {
|
||||
const { port } = probe.address() as net.AddressInfo;
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface Launch {
|
||||
child: ChildProcess;
|
||||
stderr: () => string;
|
||||
exited: Promise<number | null>;
|
||||
candidatePort: number;
|
||||
}
|
||||
|
||||
describe.skipIf(!canLaunch)("the bundle booted as Idunn launches it", () => {
|
||||
let base: string;
|
||||
let bundle: string;
|
||||
let node: string;
|
||||
let idunn: FakeIdunnRudp;
|
||||
let odin: dgram.Socket;
|
||||
let odinPort: number;
|
||||
const launched: ChildProcess[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
base = await mkdtemp(path.join(os.tmpdir(), "heimdall-boot-"));
|
||||
await chmod(base, 0o755);
|
||||
if (bundleOverride) {
|
||||
bundle = bundleOverride;
|
||||
} else {
|
||||
await run("corepack", ["pnpm", "bundle"], { cwd: path.join(import.meta.dirname, "..") });
|
||||
bundle = path.join(base, "heimdall.mjs");
|
||||
await copyFile(path.join(import.meta.dirname, "..", "dist", "heimdall"), bundle);
|
||||
await chmod(bundle, 0o644);
|
||||
}
|
||||
node = nodeOverride ?? process.execPath;
|
||||
idunn = await startFakeIdunnRudp();
|
||||
odin = dgram.createSocket("udp4");
|
||||
await new Promise<void>((resolve) => odin.bind(0, "127.0.0.1", resolve));
|
||||
odinPort = odin.address().port;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
for (const child of launched) child.kill("SIGKILL");
|
||||
idunn?.close();
|
||||
odin?.close();
|
||||
});
|
||||
|
||||
/** Credential files as Idunn holds them: readable only by the parent. */
|
||||
async function credentials(dir: string, identity: Buffer | undefined) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
const seed = path.join(dir, "activation-key.seed");
|
||||
await copyFile(path.join(idunnRuntimeFixtures, "activation-key.seed"), seed);
|
||||
await chmod(seed, 0o400);
|
||||
let identityFile: string | undefined;
|
||||
if (identity) {
|
||||
identityFile = path.join(dir, "provider-identity.credential");
|
||||
await writeFile(identityFile, identity, { mode: 0o400 });
|
||||
}
|
||||
return { seed, identityFile };
|
||||
}
|
||||
|
||||
/** Start the bundle as the generation unit does, with the fixture machine-id bound over the host's. */
|
||||
async function launch(label: string, identity: Buffer | undefined): Promise<Launch> {
|
||||
const dir = path.join(base, label);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await chmod(dir, 0o755);
|
||||
|
||||
// Idunn's runtime bundle: the two records, read-only.
|
||||
const bundleDir = path.join(dir, "bundle");
|
||||
await mkdir(bundleDir);
|
||||
for (const name of ["expected.cc", "activation.cc"]) {
|
||||
await copyFile(path.join(idunnRuntimeFixtures, name), path.join(bundleDir, name));
|
||||
await chmod(path.join(bundleDir, name), 0o444);
|
||||
}
|
||||
await chmod(bundleDir, 0o555);
|
||||
|
||||
const machineId = path.join(dir, "machine-id");
|
||||
await copyFile(path.join(idunnRuntimeFixtures, "machine-id"), machineId);
|
||||
await chmod(machineId, 0o444);
|
||||
|
||||
const stateRoot = path.join(dir, "state");
|
||||
await mkdir(stateRoot);
|
||||
await run("chown", [`0:${STATE_GID}`, stateRoot]);
|
||||
await chmod(stateRoot, 0o2770);
|
||||
|
||||
const heimdall = path.join(dir, "heimdall.mjs");
|
||||
await copyFile(bundle, heimdall);
|
||||
await chmod(heimdall, 0o444);
|
||||
|
||||
const files = await credentials(path.join(dir, "credentials"), identity);
|
||||
const candidatePort = await freePort();
|
||||
|
||||
const fds = [openSync(files.seed, "r")];
|
||||
const names = ["gamecult-idunn-runtime-activation-key"];
|
||||
if (files.identityFile) {
|
||||
fds.push(openSync(files.identityFile, "r"));
|
||||
names.push("gamecult-runtime-presence-identity");
|
||||
}
|
||||
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
LISTEN_FDS: String(fds.length),
|
||||
LISTEN_FDNAMES: names.join(":"),
|
||||
GAMECULT_IDUNN_CANDIDATE_BIND: `127.0.0.1:${candidatePort}`,
|
||||
GAMECULT_IDUNN_RUNTIME_BUNDLE: bundleDir,
|
||||
GAMECULT_IDUNN_PROCESS_WRITE_LEASE: path.join(dir, "process-write-lease.cc"),
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.test",
|
||||
GC_ACCESS_ISSUER: "https://heimdall.test",
|
||||
GC_ACCESS_STORAGE_BACKEND: "memory",
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${idunn.port}`,
|
||||
GC_ACCESS_IDUNN_HEALTH_CONTRACT: "heimdall.cultnet-rudp-provider-health",
|
||||
GC_ACCESS_ODIN_CULTMESH_URI: `rudp://127.0.0.1:${odinPort}`,
|
||||
CULTMESH_URI_ODIN_RUDP: `rudp://127.0.0.1:${odinPort}`,
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP: "1",
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: path.join(stateRoot, "signing.pem"),
|
||||
GC_ACCESS_SIGNING_KEY_ID: "boot-test",
|
||||
};
|
||||
|
||||
// uid, gid and umask as Idunn's DynamicUser unit gives them. The fixture
|
||||
// machine-id is bound only inside this mount namespace.
|
||||
const script =
|
||||
`mount --bind ${machineId} /etc/machine-id && ` +
|
||||
`exec setpriv --reuid ${GENERATION_UID} --regid ${STATE_GID} --clear-groups ` +
|
||||
`sh -c 'umask 0007; cd ${dir}; exec ${node} heimdall.mjs --state-root ${stateRoot}'`;
|
||||
const child = spawn("unshare", ["--mount", "--", "sh", "-c", script], {
|
||||
env,
|
||||
stdio: ["ignore", "ignore", "pipe", ...fds],
|
||||
});
|
||||
launched.push(child);
|
||||
fds.forEach((fd) => closeSync(fd));
|
||||
|
||||
let stderr = "";
|
||||
child.stderr!.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const exited = new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code)));
|
||||
return { child, stderr: () => stderr, exited, candidatePort };
|
||||
}
|
||||
|
||||
async function answers(port: number): Promise<number | undefined> {
|
||||
return fetch(`http://127.0.0.1:${port}/.well-known/jwks.json`).then(
|
||||
(response) => response.status,
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
it("signs warming presence on every pulse and answers HTTP", async () => {
|
||||
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
|
||||
const generation = await launch("signs", credential);
|
||||
|
||||
// Startup publication, then one per 10 s pulse. A publication fault is
|
||||
// reported as it is logged rather than after the frames never arrive.
|
||||
const publicationFault = new Promise<never>((_resolve, reject) => {
|
||||
const watch = setInterval(() => {
|
||||
if (generation.stderr().includes(" failed on ")) {
|
||||
clearInterval(watch);
|
||||
reject(new Error(`publication failed; stderr: ${generation.stderr()}`));
|
||||
}
|
||||
}, 200);
|
||||
void idunn.frame(2).then(() => clearInterval(watch));
|
||||
});
|
||||
const exitedEarly = generation.exited.then((code) => {
|
||||
throw new Error(`bundle exited ${code}; stderr: ${generation.stderr()}`);
|
||||
});
|
||||
// The first statement is the startup publication, which follows the listen
|
||||
// directly; the 10 s pulse loop has not yet fired.
|
||||
await Promise.race([
|
||||
idunn.frame(0),
|
||||
publicationFault,
|
||||
exitedEarly,
|
||||
new Promise<never>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error(`no presence at startup; stderr: ${generation.stderr()}`)), 8_000)
|
||||
),
|
||||
]);
|
||||
await Promise.race([idunn.frame(2), publicationFault, exitedEarly]);
|
||||
|
||||
expect(await answers(generation.candidatePort)).toBe(200);
|
||||
|
||||
// Public keys of the two proofs, from the fixtures' test keys.
|
||||
const store = decode(credential) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
const providerKey = createPublicKey({
|
||||
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(record[2] as number[])]),
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
const activationKey = createPublicKey(
|
||||
createPrivateKey({
|
||||
key: Buffer.concat([
|
||||
PKCS8_ED25519_SEED_PREFIX,
|
||||
await readFile(path.join(idunnRuntimeFixtures, "activation-key.seed")),
|
||||
]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
})
|
||||
);
|
||||
|
||||
let previousSequence = 0;
|
||||
for (const frame of idunn.frames.slice(0, 3)) {
|
||||
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") throw new Error("not a raw document put");
|
||||
const fields = decode(message.document.payload) as unknown[];
|
||||
expect(fields[RUNTIME_PRESENCE_SLOT.state]).toBe("warming");
|
||||
|
||||
const unsigned = [...fields];
|
||||
unsigned[RUNTIME_PRESENCE_SLOT.signature] = new Uint8Array();
|
||||
unsigned[RUNTIME_PRESENCE_SLOT.activationSignature] = new Uint8Array();
|
||||
const proof = encode(unsigned);
|
||||
expect(
|
||||
verify(
|
||||
null,
|
||||
runtimePresenceProviderSigningMessage(proof),
|
||||
providerKey,
|
||||
fields[RUNTIME_PRESENCE_SLOT.signature] as Uint8Array
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
verify(
|
||||
null,
|
||||
runtimePresenceActivationSigningMessage(proof),
|
||||
activationKey,
|
||||
fields[RUNTIME_PRESENCE_SLOT.activationSignature] as Uint8Array
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const sequence = fields[RUNTIME_PRESENCE_SLOT.publisherSequence] as number;
|
||||
expect(sequence).toBeGreaterThan(previousSequence);
|
||||
previousSequence = sequence;
|
||||
}
|
||||
|
||||
expect(generation.stderr()).not.toContain("failed on startup");
|
||||
expect(generation.stderr()).not.toContain("failed on interval");
|
||||
generation.child.kill("SIGKILL");
|
||||
}, 90_000);
|
||||
|
||||
it.each([
|
||||
["the identity descriptor is not passed", undefined, "gamecult-runtime-presence-identity"],
|
||||
["the identity is damaged", "damaged", "identity id check"],
|
||||
])("exits before listening when %s", async (_name, damage, expected) => {
|
||||
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
|
||||
const store = decode(credential) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
const publicKey = [...(record[2] as number[])];
|
||||
const original = { publicKeyHex: Buffer.from(publicKey).toString("hex"), id: record[1] as string, binding: record[5] as string };
|
||||
let identity: Buffer | undefined;
|
||||
if (damage) {
|
||||
publicKey[0] = publicKey[0]! ^ 1;
|
||||
record[2] = publicKey;
|
||||
identity = Buffer.from(encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
|
||||
}
|
||||
const started = Date.now();
|
||||
const generation = await launch(damage ? "damaged" : "no-identity", identity);
|
||||
|
||||
const code = await Promise.race([
|
||||
generation.exited,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
|
||||
]);
|
||||
|
||||
expect(code, generation.stderr()).not.toBe("timeout");
|
||||
expect(code).not.toBe(0);
|
||||
expect(Date.now() - started).toBeLessThan(6_000);
|
||||
expect(await answers(generation.candidatePort)).toBeUndefined();
|
||||
expect(generation.stderr()).toContain(expected);
|
||||
// No byte of the credential: not the key bytes, not the id, not the binding.
|
||||
expect(generation.stderr()).not.toContain(original.publicKeyHex);
|
||||
expect(generation.stderr()).not.toContain(original.id.slice(0, 16));
|
||||
expect(generation.stderr()).not.toContain(original.binding.slice(-16));
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -1,18 +1,12 @@
|
|||
import dgram from "node:dgram";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import {
|
||||
CultNetRudpSession,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
parseCultNetMessage,
|
||||
type CultNetTransportFrame,
|
||||
} from "cultnet-ts";
|
||||
import { parseCultNetMessage } from "cultnet-ts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { publishIdunnRudpHealth } from "../src/idunn-rudp-health.js";
|
||||
import { startFakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js";
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs/promises")>();
|
||||
|
|
@ -27,37 +21,12 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
|||
|
||||
describe("Heimdall Idunn health publication", () => {
|
||||
it("completes the canonical RUDP handshake before publishing its signed frame", async () => {
|
||||
const server = dgram.createSocket("udp4");
|
||||
const session = new CultNetRudpSession({
|
||||
connectionId: 0x1d0d0001,
|
||||
initialSequence: 1,
|
||||
resendDelayMs: 100,
|
||||
});
|
||||
let resolveFrame!: (frame: CultNetTransportFrame) => void;
|
||||
const frameReceived = new Promise<CultNetTransportFrame>((resolve) => {
|
||||
resolveFrame = resolve;
|
||||
});
|
||||
|
||||
server.on("message", (wire, remote) => {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
if (packet.packetType === "connect") {
|
||||
server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address);
|
||||
return;
|
||||
}
|
||||
const result = session.receive(packet, Date.now());
|
||||
if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address);
|
||||
for (const frame of result.delivered) resolveFrame(frame);
|
||||
if (result.delivered.length > 0) {
|
||||
server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address);
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
|
||||
const server = await startFakeIdunnRudp();
|
||||
|
||||
try {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "heimdall-idunn-rudp-"));
|
||||
const address = server.address();
|
||||
const config = loadConfig({
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${address.port}`,
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${server.port}`,
|
||||
GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH: path.join(tmp, "provider-health-identity.cc"),
|
||||
});
|
||||
const observedAt = "2026-08-22T00:45:00.000Z";
|
||||
|
|
@ -78,7 +47,7 @@ describe("Heimdall Idunn health publication", () => {
|
|||
observedAt,
|
||||
presence,
|
||||
});
|
||||
const frame = await frameReceived;
|
||||
const frame = await server.frame(0);
|
||||
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
|
||||
|
||||
expect(frame.channelId).toBe("schema");
|
||||
|
|
|
|||
1117
tests/managed-credential.test.ts
Normal file
1117
tests/managed-credential.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,18 +1,17 @@
|
|||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { openSync, closeSync } from "node:fs";
|
||||
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { openSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
openOrEnrollProviderHealthIdentity,
|
||||
openProviderHealthIdentity,
|
||||
passedDescriptor,
|
||||
providerHealthPublicIdentity,
|
||||
} from "../src/provider-health-identity.js";
|
||||
import { fixtureMachineId, idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
||||
() => true,
|
||||
|
|
@ -23,59 +22,79 @@ const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
|||
* The identity is machine-id bound, so enrolment only works where a machine-id
|
||||
* exists. These run on Linux and are skipped elsewhere.
|
||||
*/
|
||||
describe.skipIf(!machineIdAvailable)("presence identity from a passed descriptor", () => {
|
||||
it("reads an Idunn-written store, not a cultcache-ts one", async () => {
|
||||
/** Name the presence descriptor so it lands on whatever fd open() returned. */
|
||||
function presenceEnv(fd: number): NodeJS.ProcessEnv {
|
||||
// The lookup maps a name index onto 3 + index, so the name list is padded
|
||||
// until the presence entry lands on the descriptor open() actually returned.
|
||||
// Assuming it is 3 is how this passes on one machine and reads the wrong
|
||||
// descriptor on another.
|
||||
const names = [
|
||||
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
|
||||
"gamecult-runtime-presence-identity",
|
||||
].join(":");
|
||||
return { LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) };
|
||||
}
|
||||
|
||||
describe("presence identity from a passed descriptor", () => {
|
||||
it("decodes the Rust owner's record, whose key fields are integer arrays", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-"));
|
||||
const fd = openSync(path.join(idunnRuntimeFixtures, "provider-identity.credential"), "r");
|
||||
|
||||
// Enrol with the TypeScript client to get a genuine machine-bound payload,
|
||||
// then re-wrap that payload the way cultcache-rs writes a store: a bare
|
||||
// array of positional [key, type, payload, stored_at, schema_id] envelopes.
|
||||
// Idunn writes with the Rust client, and cultcache-ts cannot read that
|
||||
// layout, so wrapping it here is what makes this test resemble reality.
|
||||
const tsStore = path.join(dir, "ts-enrolled.cc");
|
||||
const enrolled = await openOrEnrollProviderHealthIdentity(tsStore);
|
||||
const records = await new SingleFileMessagePackBackingStore(tsStore).pullAll();
|
||||
expect(records).toHaveLength(1);
|
||||
|
||||
const idunnStore = path.join(dir, "runtime-presence-identity.cc");
|
||||
await writeFile(
|
||||
idunnStore,
|
||||
encode([
|
||||
[
|
||||
"gamecult-provider-health-identity",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
records[0]!.payload,
|
||||
"2026-09-06T00:00:00.000Z",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
],
|
||||
])
|
||||
const opened = await openProviderHealthIdentity(
|
||||
path.join(dir, "would-be-self-enrolled.cc"),
|
||||
presenceEnv(fd),
|
||||
fixtureMachineId
|
||||
);
|
||||
|
||||
const fd = openSync(idunnStore, "r");
|
||||
try {
|
||||
// The lookup maps a name index onto 3 + index, so the name list is padded
|
||||
// until the presence entry lands on whatever descriptor open() actually
|
||||
// returned. Assuming it is 3 is how this passes on one machine and reads
|
||||
// the wrong descriptor on another.
|
||||
const names = [
|
||||
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
|
||||
"gamecult-runtime-presence-identity",
|
||||
].join(":");
|
||||
// The fixture's own public key means it came from the descriptor. A fresh
|
||||
// self-enrolment would produce a different key, which is exactly the failure
|
||||
// that makes Idunn refuse this daemon's health and stall promotion.
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
|
||||
Buffer.from(record[2] as number[]).toString("hex")
|
||||
);
|
||||
await expect(access(path.join(dir, "would-be-self-enrolled.cc"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
const opened = await openProviderHealthIdentity(
|
||||
path.join(dir, "would-be-self-enrolled.cc"),
|
||||
{ LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) }
|
||||
);
|
||||
it("refuses a bin-shaped record on the descriptor path, naming the key-shape check and no value", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-bin-"));
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
// What this process's own enroller writes: the same record with bin key fields.
|
||||
const binRecord = record.map((field, index) =>
|
||||
index === 2 || index === 3 ? Uint8Array.from(field as number[]) : field
|
||||
);
|
||||
const binStore = path.join(dir, "bin-shaped.cc");
|
||||
await writeFile(binStore, encode([[store[0]![0], store[0]![1], encode(binRecord), store[0]![3], store[0]![4]]]));
|
||||
const fd = openSync(binStore, "r");
|
||||
|
||||
// Same key means it came from the descriptor. A fresh self-enrolment
|
||||
// would produce a different key -- which is exactly the failure that
|
||||
// makes Idunn refuse this daemon's health and stall promotion.
|
||||
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
|
||||
providerHealthPublicIdentity(enrolled).publicKeyHex
|
||||
);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
const failure = await openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId).then(
|
||||
() => undefined,
|
||||
(error: Error) => error
|
||||
);
|
||||
|
||||
expect(failure?.message).toContain("key shape check");
|
||||
expect(failure?.message).not.toContain((record[1] as string).slice(0, 16));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["schema", 0, "bogus", "schema check"],
|
||||
["protector", 4, "bogus", "protector check"],
|
||||
["binding", 5, "bogus", "binding check"],
|
||||
["version", 6, "v9", "version check"],
|
||||
])("a record failing only the %s check is refused by that check", async (_name, index, replacement, check) => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-check-"));
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
record[index] = replacement;
|
||||
const damaged = path.join(dir, "damaged.cc");
|
||||
await writeFile(damaged, encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
|
||||
const fd = openSync(damaged, "r");
|
||||
|
||||
await expect(
|
||||
openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId)
|
||||
).rejects.toThrow(check);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -112,3 +131,29 @@ describe("presence identity descriptor selection", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("an Idunn generation without the presence identity descriptor", () => {
|
||||
it("refuses, naming the descriptor, and enrolls nothing", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodescriptor-"));
|
||||
const identityPath = path.join(dir, "provider-health-identity.cc");
|
||||
|
||||
await expect(
|
||||
openProviderHealthIdentity(identityPath, {
|
||||
GAMECULT_IDUNN_RUNTIME_BUNDLE: "/run/idunn/bundle",
|
||||
LISTEN_FDNAMES: "some-other-descriptor",
|
||||
})
|
||||
).rejects.toThrow("gamecult-runtime-presence-identity");
|
||||
|
||||
await expect(access(identityPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("still self-enrolls outside Idunn", async () => {
|
||||
if (!machineIdAvailable) return;
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-outside-idunn-"));
|
||||
const identityPath = path.join(dir, "provider-health-identity.cc");
|
||||
|
||||
await openProviderHealthIdentity(identityPath, {});
|
||||
|
||||
await expect(access(identityPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
150
tests/private-command-diagnostics.test.ts
Normal file
150
tests/private-command-diagnostics.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// A private command's reply travels partly outside its sealed envelope: the
|
||||
// status, the failure payload and the diagnostics are plaintext, and the
|
||||
// calling app logs the diagnostics. Soul pass 4 found a provider's refresh
|
||||
// token there: a 2xx refresh answer that is not JSON made JSON.parse quote the
|
||||
// text around the fault, and the plane returned that message as a diagnostic.
|
||||
// These tests drive the real Discord runtime with only fetch stubbed.
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { invokeCultNetOperation } from "cultnet-ts";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
|
||||
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
|
||||
const secret = "ghostlight-private-command-secret";
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await Promise.all(resources.splice(0).reverse().map((r) => r.close()));
|
||||
});
|
||||
|
||||
function config(): HeimdallConfig {
|
||||
return {
|
||||
serviceName: "heimdall", host: "127.0.0.1", port: 4100, privateCommandHost: "127.0.0.1", privateCommandPort: 0,
|
||||
workspaceRoot: "/tmp", dataRoot: "/tmp/.hd", cultCachePath: "/tmp/.hd/t.cc",
|
||||
publicBaseUrl: "https://heimdall.gamecult.org", issuer: "https://heimdall.gamecult.org", daemonId: "yggdrasil-heimdall",
|
||||
idunnRudpHealth: undefined, idunnHealthContract: "heimdall.cultnet-rudp-provider-health", providerHealthIdentityPath: "/tmp/.hd/p.cc",
|
||||
sessionTtlSeconds: 3600, refreshTtlSeconds: 3600, stateTtlSeconds: 600, completionTtlSeconds: 300,
|
||||
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
|
||||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} },
|
||||
} as HeimdallConfig;
|
||||
}
|
||||
|
||||
async function command(endpoint: unknown, operation: string, contentSchema: string, key: string, payload: Record<string, unknown>) {
|
||||
const envelope = sealPrivateEnvelope({ appSlug: "ghostlight", operation, contentSchema, idempotencyKey: key, secret, payload });
|
||||
return invokeCultNetOperation(endpoint as never, {
|
||||
schemaVersion: "cultnet.operation_request.v0", messageId: `m-${key}`, serviceId: "heimdall.private.commands", operation,
|
||||
payloadSchema: "heimdall.private_command_envelope.v1", payloadEncoding: "messagepack-base64",
|
||||
payload: Buffer.from(encode(envelope)).toString("base64"), sourceRuntimeId: "yggdrasil-ghostlight",
|
||||
} as never, { runtimeId: "yggdrasil-ghostlight" });
|
||||
}
|
||||
const open = (reply: { payload: string }) =>
|
||||
openPrivateEnvelope(decode(Buffer.from(reply.payload, "base64")) as HeimdallPrivateEnvelope, secret);
|
||||
|
||||
/** A signed-in Ghostlight session, then a refresh whose provider answer is `refreshAnswer`. */
|
||||
async function refreshAgainst(refreshAnswer: () => Response) {
|
||||
let refreshing = false;
|
||||
vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith("/oauth2/token")) {
|
||||
if (new URLSearchParams(String(init?.body)).get("grant_type") === "refresh_token") {
|
||||
refreshing = true;
|
||||
return refreshAnswer();
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ access_token: "first-access", refresh_token: "first-refresh", token_type: "Bearer", expires_in: 60, scope: "identify" }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (u.endsWith("/users/@me")) return new Response(JSON.stringify({ id: "u1", username: "u1" }), { status: 200 });
|
||||
if (u.includes("/member")) return new Response(JSON.stringify({ roles: ["r"] }), { status: 200 });
|
||||
return new Response("{}", { status: 404 });
|
||||
});
|
||||
const cfg = config();
|
||||
const app = await buildApp({ config: cfg, store: new InMemoryStore() });
|
||||
resources.push(app);
|
||||
const plane = await startHeimdallPrivateCommandPlane(app, cfg);
|
||||
resources.push(plane);
|
||||
const policy = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] };
|
||||
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1",
|
||||
{ provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy }));
|
||||
const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
expect(callback.statusCode, callback.body).toBe(201);
|
||||
const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));
|
||||
expect(done.status).toBe("authenticated");
|
||||
const reply = await command(plane.endpoint, "heimdall.auth.refresh", "heimdall.auth_refresh_command.v1", "k3",
|
||||
{ refreshToken: done.refreshToken, entitlementPolicy: policy });
|
||||
expect(refreshing).toBe(true);
|
||||
return reply;
|
||||
}
|
||||
|
||||
/** Every plaintext byte of a reply: the message itself and its decoded failure payload. */
|
||||
function plaintext(reply: { payload: string }): string {
|
||||
return JSON.stringify(reply) + JSON.stringify(decode(Buffer.from(reply.payload, "base64")));
|
||||
}
|
||||
|
||||
it("keeps a provider's malformed 2xx refresh body out of every plaintext part of the reply", async () => {
|
||||
const reply = await refreshAgainst(
|
||||
() => new Response('{"access_token":"CANARYaccess0123456789","refresh_token":CANARYrefresh0123456789,"token_type":"Bearer"}', { status: 200 })
|
||||
);
|
||||
|
||||
expect(reply.status).toBe("denied");
|
||||
expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ManagedCredentialRefreshFailed, managed_credential_refresh_failed)."]);
|
||||
expect(plaintext(reply)).not.toContain("CANARY");
|
||||
});
|
||||
|
||||
it("keeps a provider's refusal body out of every plaintext part of the reply", async () => {
|
||||
const reply = await refreshAgainst(() => new Response('CANARYrefused {"error":"invalid_grant"}', { status: 400 }));
|
||||
|
||||
expect(reply.status).toBe("denied");
|
||||
expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ManagedCredentialRefreshFailed, managed_credential_refresh_failed)."]);
|
||||
expect(plaintext(reply)).not.toContain("CANARY");
|
||||
});
|
||||
|
||||
it("keeps the completion code out of the audit trail when the plane redeems by attempt", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string | URL) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith("/oauth2/token")) {
|
||||
return new Response(
|
||||
JSON.stringify({ access_token: "a", refresh_token: "r", token_type: "Bearer", expires_in: 60, scope: "identify" }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (u.endsWith("/users/@me")) return new Response(JSON.stringify({ id: "u1", username: "u1" }), { status: 200 });
|
||||
if (u.includes("/member")) return new Response(JSON.stringify({ roles: ["r"] }), { status: 200 });
|
||||
return new Response("{}", { status: 404 });
|
||||
});
|
||||
const cfg = config();
|
||||
const store = new InMemoryStore();
|
||||
const app = await buildApp({ config: cfg, store });
|
||||
resources.push(app);
|
||||
const plane = await startHeimdallPrivateCommandPlane(app, cfg);
|
||||
resources.push(plane);
|
||||
const policy = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] };
|
||||
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1",
|
||||
{ provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy }));
|
||||
const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const code = (callback.json().completion as { code: string }).code;
|
||||
const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));
|
||||
expect(done.status).toBe("authenticated");
|
||||
|
||||
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
||||
expect(events.filter((event) => event.eventType === "auth_completion_redeemed").map((event) => event.eventPayloadJson)).toEqual([
|
||||
{ provider: "discord", mode: "sign_in" },
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toContain(code);
|
||||
});
|
||||
307
tests/provider-token-request.test.ts
Normal file
307
tests/provider-token-request.test.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import { inspect } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { providers, type Provider } from "../src/contracts.js";
|
||||
import { createOAuthRuntimeRegistry, requestProviderTokens } from "../src/oauth.js";
|
||||
import { providerCatalog } from "../src/providers.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function configFor(): HeimdallConfig {
|
||||
return {
|
||||
providers: Object.fromEntries(providers.map((p) => [p, { clientId: `${p}-client`, clientSecret: `${p}-secret` }])),
|
||||
} as unknown as HeimdallConfig;
|
||||
}
|
||||
|
||||
interface Captured {
|
||||
url: string;
|
||||
method: string | undefined;
|
||||
headers: Record<string, string>;
|
||||
body: URLSearchParams;
|
||||
}
|
||||
|
||||
function capture(answer: () => Response): { calls: Captured[] } {
|
||||
const calls: Captured[] = [];
|
||||
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
headers: init?.headers as Record<string, string>,
|
||||
body: init?.body as URLSearchParams,
|
||||
});
|
||||
return answer();
|
||||
}) as typeof fetch;
|
||||
return { calls };
|
||||
}
|
||||
|
||||
const ok = () =>
|
||||
new Response(JSON.stringify({ access_token: "a", refresh_token: "r", token_type: "Bearer", expires_in: 3600, scope: "x y" }), {
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const requestable: Provider[] = [...providers];
|
||||
|
||||
describe("requestProviderTokens follows the provider descriptor", () => {
|
||||
it.each(requestable.flatMap((p) => [[p, "authorization_code"] as const, [p, "refresh_token"] as const]))(
|
||||
"%s %s hits its token endpoint with its client-auth method only",
|
||||
async (provider, grantType) => {
|
||||
const { calls } = capture(ok);
|
||||
const descriptor = providerCatalog[provider];
|
||||
const grant =
|
||||
grantType === "authorization_code"
|
||||
? ({ grant_type: "authorization_code", code: "the-code", redirect_uri: "https://h/cb" } as const)
|
||||
: ({ grant_type: "refresh_token", refresh_token: "the-refresh" } as const);
|
||||
|
||||
const tokens = await requestProviderTokens(provider, configFor(), grant);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
const call = calls[0]!;
|
||||
expect(call.url).toBe(descriptor.tokenEndpoint);
|
||||
expect(call.method).toBe("POST");
|
||||
expect(call.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
||||
expect(call.headers.Accept).toBe("application/json");
|
||||
expect(call.body.get("grant_type")).toBe(grantType);
|
||||
if (descriptor.tokenEndpointAuthMethod === "client_secret_basic") {
|
||||
expect(call.headers.Authorization).toBe(`Basic ${Buffer.from(`${provider}-client:${provider}-secret`).toString("base64")}`);
|
||||
expect(call.body.has("client_id")).toBe(false);
|
||||
expect(call.body.has("client_secret")).toBe(false);
|
||||
} else {
|
||||
expect(call.headers.Authorization).toBeUndefined();
|
||||
expect(call.body.get("client_id")).toBe(`${provider}-client`);
|
||||
expect(call.body.get("client_secret")).toBe(`${provider}-secret`);
|
||||
}
|
||||
expect(tokens.accessToken).toBe("a");
|
||||
expect(tokens.refreshToken).toBe("r");
|
||||
expect(tokens.scope).toEqual(["x", "y"]);
|
||||
expect(Date.parse(tokens.expiresAt ?? "")).toBeGreaterThan(Date.now());
|
||||
}
|
||||
);
|
||||
|
||||
it("sends the code and redirect uri on exchange and the refresh token on refresh", async () => {
|
||||
const { calls } = capture(ok);
|
||||
await requestProviderTokens("spotify", configFor(), { grant_type: "authorization_code", code: "C", redirect_uri: "https://h/cb" });
|
||||
await requestProviderTokens("spotify", configFor(), { grant_type: "refresh_token", refresh_token: "R" });
|
||||
expect(calls[0]!.body.get("code")).toBe("C");
|
||||
expect(calls[0]!.body.get("redirect_uri")).toBe("https://h/cb");
|
||||
expect(calls[1]!.body.get("refresh_token")).toBe("R");
|
||||
expect(calls[1]!.body.has("code")).toBe(false);
|
||||
});
|
||||
|
||||
const everyProvider = Object.keys(providerCatalog) as Provider[];
|
||||
|
||||
it("gives every provider a default access-token lifetime of 3600 seconds", () => {
|
||||
for (const provider of everyProvider) {
|
||||
expect(providerCatalog[provider].defaultAccessTokenLifetimeSeconds).toBe(3600);
|
||||
}
|
||||
});
|
||||
|
||||
// expires_in is RECOMMENDED (RFC 6749 5.1): unusable means the default, never an unknown expiry.
|
||||
it.each([
|
||||
["no expires_in", undefined, 3600],
|
||||
["a zero expires_in", 0, 3600],
|
||||
["a negative expires_in", -5, 3600],
|
||||
["a null expires_in", null, 3600],
|
||||
["an object expires_in", {}, 3600],
|
||||
["a non-numeric string", "abc", 3600],
|
||||
["an empty string", "", 3600],
|
||||
["a negative string", "-1", 3600],
|
||||
["a zero string", "0", 3600],
|
||||
["a fractional string", "12.5", 3600],
|
||||
["an exponent string", "1e3", 3600],
|
||||
["a numeric string", "7200", 7200],
|
||||
["a padded numeric string", " 7200 ", 7200],
|
||||
["a number", 7200, 7200],
|
||||
])("stores now plus the right lifetime for %s, on every provider and grant", async (_n, expiresIn, seconds) => {
|
||||
for (const provider of everyProvider) {
|
||||
for (const grant of [
|
||||
{ grant_type: "authorization_code", code: "c", redirect_uri: "u" } as const,
|
||||
{ grant_type: "refresh_token", refresh_token: "R" } as const,
|
||||
]) {
|
||||
capture(() => new Response(JSON.stringify({ access_token: "a", ...(expiresIn === undefined ? {} : { expires_in: expiresIn }) }), { status: 200 }));
|
||||
const before = Date.now();
|
||||
const tokens = await requestProviderTokens(provider, configFor(), grant);
|
||||
const stored = Date.parse(tokens.expiresAt ?? "");
|
||||
expect(stored - before).toBeGreaterThanOrEqual(seconds * 1000);
|
||||
expect(stored - before).toBeLessThan(seconds * 1000 + 5_000);
|
||||
expect(tokens.refreshToken).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("takes the default lifetime for an expires_in too large to be a date", async () => {
|
||||
capture(() => new Response('{"access_token":"a","expires_in":1e999}', { status: 200 }));
|
||||
const before = Date.now();
|
||||
const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" });
|
||||
expect(Date.parse(tokens.expiresAt ?? "") - before).toBeLessThan(3600 * 1000 + 5_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["exactly a year", 31_536_000, 31_536_000],
|
||||
["a second over a year", 31_536_001, 3600],
|
||||
["3e11", 3e11, 3600],
|
||||
["3e11 as a string", "300000000000", 3600],
|
||||
])("takes an expires_in of %s as %i seconds' worth of lifetime", async (_n, expiresIn, seconds) => {
|
||||
capture(() => new Response(JSON.stringify({ access_token: "a", expires_in: expiresIn }), { status: 200 }));
|
||||
const before = Date.now();
|
||||
const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" });
|
||||
expect(Date.parse(tokens.expiresAt ?? "") - before).toBeGreaterThanOrEqual(seconds * 1000 - 1000);
|
||||
expect(Date.parse(tokens.expiresAt ?? "") - before).toBeLessThan(seconds * 1000 + 5_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no refresh_token", {}],
|
||||
["an empty refresh_token", { refresh_token: "" }],
|
||||
])("a rotating provider's code exchange with %s is a typed malformed token set; a non-rotating one is not", async (_n, extra) => {
|
||||
capture(() => new Response(JSON.stringify({ access_token: "a", expires_in: 3600, ...extra }), { status: 200 }));
|
||||
const runtimes = createOAuthRuntimeRegistry();
|
||||
const exchange = { config: configFor(), code: "c", redirectUri: "https://h/cb" };
|
||||
await expect(runtimes.joystick.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
||||
name: "ProviderResponseError",
|
||||
code: "provider_body_not_token_set",
|
||||
});
|
||||
expect((await runtimes.twitch.exchangeAuthorizationCode(exchange)).refreshToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a number", 42],
|
||||
["an object", { a: 1 }],
|
||||
["an array", ["r"]],
|
||||
["true", true],
|
||||
["null", null],
|
||||
])("refuses a 2xx whose refresh_token is %s as a malformed token set", async (_n, refreshToken) => {
|
||||
capture(() => new Response(JSON.stringify({ access_token: "a", refresh_token: refreshToken, expires_in: 3600 }), { status: 200 }));
|
||||
await expect(
|
||||
requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" })
|
||||
).rejects.toMatchObject({ code: "provider_body_not_token_set" });
|
||||
});
|
||||
|
||||
it("reads an empty-string refresh_token as no refresh token", async () => {
|
||||
capture(() => new Response(JSON.stringify({ access_token: "a", refresh_token: "", expires_in: 3600 }), { status: 200 }));
|
||||
const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" });
|
||||
expect(tokens.refreshToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["authorization_code", { grant_type: "authorization_code", code: "c", redirect_uri: "u" } as const, "exchange"],
|
||||
["refresh_token", { grant_type: "refresh_token", refresh_token: "r" } as const, "refresh"],
|
||||
])("keeps the failure label for %s", async (_g, grant, word) => {
|
||||
capture(() => new Response("nope", { status: 500 }));
|
||||
await expect(requestProviderTokens("youtube", configFor(), grant)).rejects.toThrow(
|
||||
`YouTube token ${word} failed (status 500, provider_status).`
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the abort signal to the provider call", async () => {
|
||||
let seen: AbortSignal | null | undefined;
|
||||
globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => {
|
||||
seen = init?.signal;
|
||||
return ok();
|
||||
}) as typeof fetch;
|
||||
const controller = new AbortController();
|
||||
await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }, controller.signal);
|
||||
expect(seen).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it("keeps the provider's token type and defaults a missing one to Bearer", async () => {
|
||||
capture(() => new Response(JSON.stringify({ access_token: "a", token_type: "bearer" }), { status: 200 }));
|
||||
expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).tokenType).toBe("bearer");
|
||||
capture(() => new Response(JSON.stringify({ access_token: "a" }), { status: 200 }));
|
||||
expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).tokenType).toBe("Bearer");
|
||||
});
|
||||
|
||||
it("trims scope entries and drops empty ones", async () => {
|
||||
capture(() => new Response(JSON.stringify({ access_token: "a", scope: [" a ", "", "b"] }), { status: 200 }));
|
||||
expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).scope).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty body", ""],
|
||||
["an empty object", "{}"],
|
||||
["JSON null", "null"],
|
||||
["a JSON array", "[]"],
|
||||
["an error beside an access token", JSON.stringify({ access_token: "a", error: "invalid_grant" })],
|
||||
["an empty access token", JSON.stringify({ access_token: "" })],
|
||||
["a numeric access token", JSON.stringify({ access_token: 5 })],
|
||||
])("refuses a 2xx with %s as not a token set, carrying nothing of the body", async (_name, body) => {
|
||||
capture(() => new Response(body, { status: 200 }));
|
||||
const error = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }).then(
|
||||
() => {
|
||||
throw new Error("expected a refusal");
|
||||
},
|
||||
(e: unknown) => e as Error & { code?: string; status?: number; oauthError?: unknown }
|
||||
);
|
||||
expect(error.name).toBe("ProviderResponseError");
|
||||
expect(error.code).toBe("provider_body_not_token_set");
|
||||
expect(error.status).toBe(200);
|
||||
expect(error.oauthError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets no refreshToken key when the provider sent none", async () => {
|
||||
capture(() => new Response(JSON.stringify({ access_token: "a" }), { status: 200 }));
|
||||
const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" });
|
||||
expect("refreshToken" in tokens).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["only the secret missing", { clientId: "id" }],
|
||||
["only the id missing", { clientSecret: "secret" }],
|
||||
])("refuses a provider with %s", async (_n, partial) => {
|
||||
const config = { providers: { twitch: partial } } as unknown as HeimdallConfig;
|
||||
await expect(requestProviderTokens("twitch", config, { grant_type: "refresh_token", refresh_token: "r" })).rejects.toThrow(
|
||||
"Twitch OAuth is not fully configured."
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an unconfigured provider by display name", async () => {
|
||||
const config = { providers: { patreon: {} } } as unknown as HeimdallConfig;
|
||||
await expect(
|
||||
requestProviderTokens("patreon", config, { grant_type: "refresh_token", refresh_token: "r" })
|
||||
).rejects.toThrow("Patreon OAuth is not fully configured.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthError is a closed set", () => {
|
||||
const refresh = { grant_type: "refresh_token", refresh_token: "r" } as const;
|
||||
async function failure(body: string, status = 400) {
|
||||
capture(() => new Response(body, { status }));
|
||||
return requestProviderTokens("twitch", configFor(), refresh).then(
|
||||
() => {
|
||||
throw new Error("expected a refusal");
|
||||
},
|
||||
(error: unknown) => error as Error & { oauthError?: string }
|
||||
);
|
||||
}
|
||||
|
||||
it("takes invalid_grant from the body and carries nothing else of it", async () => {
|
||||
const error = await failure(JSON.stringify({ error: "invalid_grant", error_description: "CANARY" }));
|
||||
expect(error.oauthError).toBe("invalid_grant");
|
||||
expect(`${error.message} ${error.stack} ${inspect(error, { depth: 5, showHidden: true })} ${JSON.stringify(error)}`).not.toContain("CANARY");
|
||||
});
|
||||
|
||||
it.each([401, 403, 429, 500, 502, 503])("takes no oauthError from a %i, whatever its body says", async (status) => {
|
||||
const error = await failure(JSON.stringify({ error: "invalid_grant" }), status);
|
||||
expect(error.oauthError).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["invalid_grant_extra", "not_invalid_grant", "INVALID_GRANT", " invalid_grant", "invalid_grant "])(
|
||||
"takes the code by exact match only: %j is no code",
|
||||
async (code) => {
|
||||
const error = await failure(JSON.stringify({ error: code }));
|
||||
expect(error.oauthError).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
["an error outside the RFC set", JSON.stringify({ error: "CANARY_unknown" })],
|
||||
["a non-string error", JSON.stringify({ error: { code: "invalid_grant" } })],
|
||||
["a body that is not JSON", "CANARY <html>"],
|
||||
["JSON null", "null"],
|
||||
])("leaves oauthError undefined for %s", async (_name, body) => {
|
||||
const error = await failure(body);
|
||||
expect(error.oauthError).toBeUndefined();
|
||||
expect(`${error.message} ${inspect(error, { depth: 5, showHidden: true })}`).not.toContain("CANARY");
|
||||
});
|
||||
});
|
||||
83
tests/publisher-sequence.test.ts
Normal file
83
tests/publisher-sequence.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { PublisherSequence, openPresenceAuthority } from "../src/runtime-presence.js";
|
||||
import { fixtureMachineId, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
describe("PublisherSequence", () => {
|
||||
it("starts at the observed millis", () => {
|
||||
expect(new PublisherSequence().next(1_700_000_000_000)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("strictly increases for equal, earlier and later observations", () => {
|
||||
const sequence = new PublisherSequence();
|
||||
const first = sequence.next(1_700_000_000_000);
|
||||
const equal = sequence.next(1_700_000_000_000);
|
||||
const earlier = sequence.next(1_699_999_990_000);
|
||||
const later = sequence.next(1_700_000_050_000);
|
||||
expect([first, equal, earlier, later]).toEqual([
|
||||
1_700_000_000_000,
|
||||
1_700_000_000_001,
|
||||
1_700_000_000_002,
|
||||
1_700_000_050_000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("never falls below the observed millis once the clock moves ahead", () => {
|
||||
const sequence = new PublisherSequence();
|
||||
sequence.next(1_700_000_000_000);
|
||||
sequence.next(1_700_000_000_000);
|
||||
sequence.next(1_700_000_000_000);
|
||||
expect(sequence.next(1_700_000_000_002)).toBe(1_700_000_000_003);
|
||||
expect(sequence.next(1_700_000_000_010)).toBe(1_700_000_000_010);
|
||||
});
|
||||
|
||||
it("refuses a value that is not a safe integer and keeps its place", () => {
|
||||
const sequence = new PublisherSequence();
|
||||
expect(sequence.next(10)).toBe(10);
|
||||
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5, 2 ** 60]) {
|
||||
expect(() => sequence.next(bad)).toThrow("observedAtUnixMillis");
|
||||
}
|
||||
expect(sequence.next(5)).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the presence authority", () => {
|
||||
it("opens once, touches no path under dataRoot, and signs every pulse after its descriptors are gone", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodataroot-"));
|
||||
const fixtures = path.join(dir, "fixtures");
|
||||
await writePresenceFixtures(fixtures);
|
||||
// A regular file as dataRoot: opening anything beneath it fails with
|
||||
// ENOTDIR for every uid, root included.
|
||||
const dataRoot = path.join(dir, "not-a-directory");
|
||||
await writeFile(dataRoot, "");
|
||||
const config = loadConfig({
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
|
||||
GC_ACCESS_DATA_ROOT: dataRoot,
|
||||
});
|
||||
|
||||
const descriptors = openPresenceDescriptors(fixtures);
|
||||
try {
|
||||
const authority = await openPresenceAuthority(
|
||||
{ ...config, idunnRuntimeBundlePath: fixtures },
|
||||
descriptors.env,
|
||||
fixtureMachineId,
|
||||
);
|
||||
// The descriptors were read whole and closed: a second read would find
|
||||
// nothing, so signing again proves the authority holds what it decoded.
|
||||
const observedAtUnixMillis = Date.parse("2026-10-23T12:00:00.000Z");
|
||||
const first = authority.sign("warming", "probe", observedAtUnixMillis);
|
||||
const second = authority.sign("warming", "probe", observedAtUnixMillis);
|
||||
expect(first.target).toBe("streampixels-web");
|
||||
expect(second.target).toBe("streampixels-web");
|
||||
expect(Buffer.from(second.payload).equals(Buffer.from(first.payload))).toBe(false);
|
||||
} finally {
|
||||
descriptors.close();
|
||||
}
|
||||
expect((await readdir(dir)).sort()).toEqual(["fixtures", "not-a-directory"]);
|
||||
});
|
||||
});
|
||||
228
tests/recipe-secrets.test.ts
Normal file
228
tests/recipe-secrets.test.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { appProfiles } from "../src/app-profiles.js";
|
||||
import { appSlugs, providers } from "../src/contracts.js";
|
||||
import { providerCatalog } from "../src/providers.js";
|
||||
|
||||
// The Idunn recipe decides which environment names a binding may set, and
|
||||
// Idunn refuses any other. So the recipe is where "a secret is only ever a
|
||||
// file" is enforced for a deployment: a plaintext secret name declared here
|
||||
// can carry the secret into the binding, the unit and every plan.
|
||||
|
||||
const recipe = readFileSync(new URL("../deployment/idunn/recipe.toml", import.meta.url), "utf8");
|
||||
|
||||
/** The names in one `KEY = [ "A", "B" ]` array of the given TOML text. */
|
||||
function stringArray(text: string, key: string): string[] {
|
||||
const match = new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m").exec(text);
|
||||
return match ? [...match[1]!.matchAll(/"([^"]+)"/g)].map((name) => name[1]!) : [];
|
||||
}
|
||||
|
||||
const serviceSection = /^\[service\]\n([\s\S]*?)(?=^\[)/m.exec(recipe.replace(/\r\n/g, "\n"))?.[1] ?? "";
|
||||
const serviceNames = [
|
||||
...stringArray(serviceSection, "optional_environment"),
|
||||
...stringArray(serviceSection, "required_environment"),
|
||||
];
|
||||
const everyDeclaredName = [...recipe.replace(/\r\n/g, "\n").matchAll(/^\w+_environment\s*=\s*\[([^\]]*)\]/gm)].flatMap(
|
||||
(match) => [...match[1]!.matchAll(/"([^"]+)"/g)].map((name) => name[1]!)
|
||||
);
|
||||
|
||||
// The secrets Heimdall reads are observed, not read from the source: the real
|
||||
// loadConfig runs against an environment that records every name it looks
|
||||
// up. readSecretInput looks up `NAME_FILE` for each secret, so every `_FILE`
|
||||
// lookup names one, however the call is spelled and whatever list computes
|
||||
// the name. The recorder also stands in for process.env, both while config.ts
|
||||
// is imported (freshly, so a copy of process.env taken at module load is the
|
||||
// recorder too) and during every call.
|
||||
//
|
||||
// A read made from a copy of the environment ({ ...env }, Object.entries)
|
||||
// cannot be seen, so copying or enumerating it is recorded and fails below.
|
||||
//
|
||||
// A secret read only on a branch some value opens is reached by running
|
||||
// loadConfig 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 probe value (a few shapes, plus every string literal in config.ts);
|
||||
// then every name at once, each set to a value loadConfig accepted for it
|
||||
// alone; and every combined run again as Idunn starts Heimdall, with the
|
||||
// recipe's service arguments and a candidate socket with its base URL, a
|
||||
// pair loadConfig refuses one name at a time. A run that throws still counts
|
||||
// the reads it made first.
|
||||
//
|
||||
// Known limit: a read gated on a value that is none of the probe values and
|
||||
// not accepted alone in combination with the others stays unseen.
|
||||
const configSource = readFileSync(new URL("../src/config.ts", import.meta.url), "utf8");
|
||||
const probeValues = [
|
||||
...new Set(["1", "true", "/probe/path", "127.0.0.1:4100", "https://probe.test", ...[...configSource.matchAll(/"([^"\\\r\n]+)"/g)].map((m) => m[1]!)]),
|
||||
];
|
||||
|
||||
const recorder = { values: {} as Record<string, string>, names: new Set<string>(), enumerated: false };
|
||||
const recordingEnv = new Proxy({} as NodeJS.ProcessEnv, {
|
||||
get(_target, name) {
|
||||
if (typeof name !== "string") return undefined;
|
||||
recorder.names.add(name);
|
||||
return recorder.values[name];
|
||||
},
|
||||
has(_target, name) {
|
||||
if (typeof name !== "string") return false;
|
||||
recorder.names.add(name);
|
||||
return name in recorder.values;
|
||||
},
|
||||
ownKeys() {
|
||||
recorder.enumerated = true;
|
||||
return [];
|
||||
},
|
||||
getOwnPropertyDescriptor() {
|
||||
recorder.enumerated = true;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
async function withRecordingProcessEnv<T>(run: () => T | Promise<T>): Promise<T> {
|
||||
const processEnv = process.env;
|
||||
process.env = recordingEnv;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
process.env = processEnv;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How Idunn starts Heimdall: the recipe's [service] arguments after the
|
||||
* entry script, each binding given a probe value, and the environment an
|
||||
* Idunn candidate always has (a loopback socket, which requires the base URL).
|
||||
*/
|
||||
const recipeArguments = [...(/^\[service\][\s\S]*?^arguments = \[([\s\S]*?)^\]/m.exec(recipe.replace(/\r\n/g, "\n"))?.[1] ?? "").matchAll(
|
||||
/\{ kind = "(literal|binding)", (?:value|name) = "([^"]+)" \}/g
|
||||
)].map((match) => (match[1] === "literal" ? match[2]! : "/probe/idunn-binding"));
|
||||
const idunnArgv = recipeArguments.slice(1);
|
||||
const idunnEnv = { GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:4100", GC_ACCESS_BASE_URL: "https://probe.test" };
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await withRecordingProcessEnv(() => import("../src/config.js"));
|
||||
|
||||
/** The names one loadConfig run reads under `values`, and whether it accepted them. */
|
||||
async function readsOf(
|
||||
values: Readonly<Record<string, string>>,
|
||||
argv: readonly string[] = []
|
||||
): Promise<{ names: Set<string>; accepted: boolean }> {
|
||||
recorder.values = { ...values };
|
||||
recorder.names = new Set();
|
||||
let accepted = true;
|
||||
await withRecordingProcessEnv(() => {
|
||||
try {
|
||||
loadConfig(recordingEnv, argv);
|
||||
} catch {
|
||||
accepted = false; // the reads before the refusal still count
|
||||
}
|
||||
});
|
||||
return { names: recorder.names, accepted };
|
||||
}
|
||||
|
||||
async function exploreLoadConfig(): Promise<{ names: Set<string>; enumerated: boolean }> {
|
||||
const names = new Set<string>();
|
||||
recorder.enumerated = false;
|
||||
const secretsOf = () => [...names].filter((name) => name.endsWith("_FILE")).map((name) => name.slice(0, -"_FILE".length));
|
||||
for (let grew = true; grew; ) {
|
||||
const before = names.size;
|
||||
const secrets = Object.fromEntries(secretsOf().map((name) => [name, "probe-secret"]));
|
||||
const settable = [...names].filter((name) => !name.endsWith("_FILE") && !(name in secrets));
|
||||
const accepted = new Map<string, string[]>();
|
||||
const keep = (run: { names: Set<string> }) => run.names.forEach((name) => names.add(name));
|
||||
keep(await readsOf({}));
|
||||
keep(await readsOf(secrets));
|
||||
keep(await readsOf({ ...secrets, ...idunnEnv }, idunnArgv));
|
||||
for (const name of settable) {
|
||||
for (const value of probeValues) {
|
||||
const run = await readsOf({ ...secrets, [name]: value });
|
||||
keep(run);
|
||||
if (run.accepted) accepted.set(name, [...(accepted.get(name) ?? []), value]);
|
||||
}
|
||||
}
|
||||
const widest = Math.max(0, ...[...accepted.values()].map((values) => values.length));
|
||||
for (let round = 0; round < widest; round += 1) {
|
||||
const together = Object.fromEntries([...accepted].map(([name, values]) => [name, values[round % values.length]!]));
|
||||
keep(await readsOf({ ...secrets, ...together }));
|
||||
keep(await readsOf({ ...secrets, ...together, ...idunnEnv }, idunnArgv));
|
||||
}
|
||||
grew = names.size > before;
|
||||
}
|
||||
return { names, enumerated: recorder.enumerated };
|
||||
}
|
||||
|
||||
const explored = await exploreLoadConfig();
|
||||
|
||||
/** Secrets Heimdall reads through readSecretInput, by their plaintext name. */
|
||||
const secretNames = [...explored.names]
|
||||
.filter((name) => name.endsWith("_FILE"))
|
||||
.map((name) => name.slice(0, -"_FILE".length));
|
||||
|
||||
const secretShaped =
|
||||
/SECRET|PASSWORD|PASSPHRASE|TOKEN|API_KEY|PRIVATE_KEY|ENCRYPTION_KEY|SALT|CREDENTIAL|DATABASE_URL|_DSN|_PEM|HMAC/;
|
||||
|
||||
/** Secret-shaped names that hold no secret, each for its own reason. */
|
||||
const notSecrets = new Set([
|
||||
// A path to the signing key file, which Heimdall reads (or creates) itself.
|
||||
"GC_ACCESS_SIGNING_PRIVATE_KEY_PATH",
|
||||
// A flag: create that file on first boot.
|
||||
"GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP",
|
||||
]);
|
||||
|
||||
describe("Idunn recipe secret declarations", () => {
|
||||
it("reads the service's declared environment", () => {
|
||||
expect(serviceNames).toContain("GC_ACCESS_DATABASE_URL_FILE");
|
||||
expect(serviceNames).toContain("GC_ACCESS_BASE_URL");
|
||||
});
|
||||
|
||||
it("declares no secret-shaped name except as a _FILE credential path", () => {
|
||||
const plaintext = everyDeclaredName.filter(
|
||||
(name) => secretShaped.test(name) && !name.endsWith("_FILE") && !notSecrets.has(name)
|
||||
);
|
||||
expect(plaintext).toEqual([]);
|
||||
});
|
||||
|
||||
it("starts the exploration the way the recipe starts Heimdall", async () => {
|
||||
expect(idunnArgv).toEqual(["--state-root", "/probe/idunn-binding"]);
|
||||
expect(serviceNames).toEqual(expect.arrayContaining(Object.keys(idunnEnv)));
|
||||
expect((await readsOf({ ...idunnEnv }, idunnArgv)).accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the environment only by name, never by copying or enumerating it", () => {
|
||||
expect(explored.enumerated).toBe(false);
|
||||
});
|
||||
|
||||
it("sees every secret loadConfig reads, across the provider and app lists", () => {
|
||||
expect(secretNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
"GC_ACCESS_DATABASE_URL",
|
||||
"GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64",
|
||||
"GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET",
|
||||
...providers.map((provider) => `GC_ACCESS_PROVIDER_${provider.toUpperCase()}_CLIENT_SECRET`),
|
||||
...appSlugs.map((appSlug) => `GC_ACCESS_APP_${appSlug.toUpperCase()}_SHARED_SECRET`),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("declares none of the secrets Heimdall reads under its plaintext name", () => {
|
||||
for (const name of secretNames) {
|
||||
expect(everyDeclaredName).not.toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares every provider client-secret file the witness publishes", () => {
|
||||
for (const provider of providers) {
|
||||
expect(serviceNames).toContain(providerCatalog[provider].clientSecretEnv);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares the shared-secret file of every app profile the witness publishes", () => {
|
||||
for (const profile of Object.values(appProfiles)) {
|
||||
expect(serviceNames).toContain(`GC_ACCESS_APP_${profile.slug.toUpperCase()}_SHARED_SECRET_FILE`);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares the _FILE of every secret Heimdall reads", () => {
|
||||
for (const name of secretNames) {
|
||||
expect(serviceNames).toContain(`${name}_FILE`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -38,6 +38,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -64,6 +65,22 @@ describe("Heimdall security helpers", () => {
|
|||
expect(custody.decrypt(encrypted)).toBe("discord-access-token");
|
||||
});
|
||||
|
||||
it("refuses a token key that does not decode to 32 bytes, without echoing it", () => {
|
||||
for (const length of [16, 31, 33]) {
|
||||
const key = Buffer.alloc(length, 7).toString("base64");
|
||||
const message = (() => {
|
||||
try {
|
||||
createTokenCustody({ ...createTestConfig(), tokenEncryptionKeyBase64: key });
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
throw new Error(`a ${length}-byte key was accepted`);
|
||||
})();
|
||||
expect(message).toMatch(/GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE must decode to exactly 32 bytes/);
|
||||
expect(message).not.toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses persistent provider-token storage without a stable encryption key", () => {
|
||||
expect(() =>
|
||||
createTokenCustody(
|
||||
|
|
|
|||
98
tests/state-root-generations.test.ts
Normal file
98
tests/state-root-generations.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { chmod, chown, mkdir, mkdtemp, readdir, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
const STATE_GID = 4242;
|
||||
const GENERATION_N = 61184;
|
||||
const GENERATION_N_PLUS_1 = 61185;
|
||||
|
||||
// Creating uids and groups needs root; the stopgap's node container runs as root.
|
||||
const isRoot = process.getuid?.() === 0;
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
found.push(full);
|
||||
if (entry.isDirectory()) found.push(...(await walk(full)));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idunn gives every generation a fresh DynamicUser uid and one shared group,
|
||||
* UMask=0007, and a 2770 setgid state root. Generation N+1 runs beside or after
|
||||
* N and must be able to read and replace everything N left there.
|
||||
*/
|
||||
describe.skipIf(!isRoot)("Idunn state root across generations", () => {
|
||||
it("generation N+1, a different uid in the same group, reads and replaces generation N's state", async () => {
|
||||
const base = await mkdtemp(path.join(os.tmpdir(), "heimdall-generations-"));
|
||||
await chmod(base, 0o755);
|
||||
|
||||
// The fixtures stand in for what Idunn passes as descriptors.
|
||||
const fixtures = path.join(base, "fixtures");
|
||||
await writePresenceFixtures(fixtures);
|
||||
|
||||
const stateRoot = path.join(base, "state");
|
||||
await mkdir(stateRoot);
|
||||
await chown(stateRoot, 0, STATE_GID);
|
||||
await chmod(stateRoot, 0o2770);
|
||||
|
||||
// The generations run as uids that cannot read the checkout, so each runs
|
||||
// one self-contained bundle built into the world-readable scratch root.
|
||||
const child = path.join(base, "generation.mjs");
|
||||
await build({
|
||||
entryPoints: [path.join(import.meta.dirname, "fixtures", "state-root-generation-child.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node24",
|
||||
outfile: child,
|
||||
external: ["pg-native"],
|
||||
banner: {
|
||||
js: "import{createRequire}from'node:module';const require=createRequire(import.meta.url);",
|
||||
},
|
||||
logLevel: "silent",
|
||||
});
|
||||
await chmod(child, 0o644);
|
||||
const generation = (role: "first" | "next", uid: number) =>
|
||||
run(process.execPath, [child, role, stateRoot, fixtures], {
|
||||
uid,
|
||||
gid: STATE_GID,
|
||||
cwd: base,
|
||||
env: { PATH: process.env.PATH ?? "", HOME: fixtures },
|
||||
});
|
||||
|
||||
await generation("first", GENERATION_N);
|
||||
|
||||
const left = await walk(stateRoot);
|
||||
expect(left.map((entry) => path.relative(stateRoot, entry)).sort()).toEqual([
|
||||
"cultcache",
|
||||
path.join("cultcache", "heimdall.service.cc"),
|
||||
"runtime-presence-sequence",
|
||||
]);
|
||||
|
||||
await generation("next", GENERATION_N_PLUS_1);
|
||||
|
||||
// Generation N+1 replaced the snapshot, and no owner-only file is left
|
||||
// behind for generation N+2: everything but the stale 0600 fixture is
|
||||
// readable and writable by the state group.
|
||||
const snapshot = await stat(path.join(stateRoot, "cultcache", "heimdall.service.cc"));
|
||||
expect(snapshot.uid).toBe(GENERATION_N_PLUS_1);
|
||||
expect(snapshot.gid).toBe(STATE_GID);
|
||||
for (const entry of await walk(stateRoot)) {
|
||||
if (path.basename(entry) === "runtime-presence-sequence") continue;
|
||||
expect((await stat(entry)).mode & 0o060, path.relative(stateRoot, entry)).toBe(0o060);
|
||||
}
|
||||
expect((await readdir(stateRoot)).sort()).toEqual(["cultcache", "runtime-presence-sequence"]);
|
||||
}, 60_000);
|
||||
});
|
||||
|
|
@ -1,6 +1,20 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Pool } from "pg";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { newDb } from "pg-mem";
|
||||
import { PostgresStore } from "../src/store/postgres.js";
|
||||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { createStore } from "../src/store/index.js";
|
||||
import {
|
||||
createPostgresStore,
|
||||
PostgresStore,
|
||||
checkSchemaRequirements,
|
||||
HEIMDALL_SCHEMA_REQUIREMENTS,
|
||||
predicateConditions,
|
||||
printedDeclaredPredicates,
|
||||
samePredicate,
|
||||
STORE_TABLE_PRIVILEGES,
|
||||
} from "../src/store/postgres.js";
|
||||
import { REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js";
|
||||
|
||||
describe("PostgresStore", () => {
|
||||
it("round-trips core auth records through postgres", async () => {
|
||||
|
|
@ -183,3 +197,522 @@ describe("PostgresStore", () => {
|
|||
await store.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPostgresStore", () => {
|
||||
// pg emits an idle client's connection loss on the pool. With no listener
|
||||
// that event kills the process, and Node prints the pg Client with its
|
||||
// connection parameters.
|
||||
it("survives an idle connection's loss and says so by code alone", async () => {
|
||||
const store = createPostgresStore("postgres://heimdall:CANARYpw@127.0.0.1:1/heimdall");
|
||||
const pool = (store as unknown as { pool: { emit(event: string, ...args: unknown[]): boolean } }).pool;
|
||||
const logged: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => void logged.push(args.map(String).join(" "));
|
||||
try {
|
||||
const terminated = Object.assign(new Error("terminating connection due to administrator command CANARY"), {
|
||||
code: "57P01",
|
||||
client: { connectionParameters: { password: "CANARYpw" } },
|
||||
});
|
||||
expect(() => pool.emit("error", terminated, terminated.client)).not.toThrow();
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
await store.close();
|
||||
}
|
||||
expect(logged).toEqual([
|
||||
"Heimdall lost an idle Postgres connection (57P01); the pool reconnects on the next query.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PostgresStore.checkSchema", () => {
|
||||
it("derives every table, typed column and unique key from the schema", () => {
|
||||
expect(REQUIRED_TABLES).toEqual([
|
||||
"accounts",
|
||||
"linked_identities",
|
||||
"sessions",
|
||||
"auth_attempts",
|
||||
"private_command_receipts",
|
||||
"auth_completions",
|
||||
"capability_grants",
|
||||
"entitlement_snapshots",
|
||||
"audit_events",
|
||||
]);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(85);
|
||||
expect(REQUIRED_COLUMNS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
|
||||
{ table: "sessions", column: "access_revision", type: "INTEGER", nullable: false },
|
||||
{ table: "linked_identities", column: "profile_json", type: "JSONB", nullable: false },
|
||||
{ table: "linked_identities", column: "username", type: "TEXT", nullable: true },
|
||||
{ table: "entitlement_snapshots", column: "is_allowed", type: "BOOLEAN", nullable: false },
|
||||
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
|
||||
])
|
||||
);
|
||||
expect(REQUIRED_KEYS).toHaveLength(12);
|
||||
expect(REQUIRED_KEYS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
|
||||
{ table: "private_command_receipts", columns: "app_slug,idempotency_key", predicate: "" },
|
||||
{ table: "entitlement_snapshots", columns: "account_id,provider,scope", predicate: "" },
|
||||
{ table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" },
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
// Soul pass 6: stripping parentheses and case merged predicates of
|
||||
// different meaning. These are Postgres's own printed forms.
|
||||
it("splits a printed predicate only at its top-level ANDs", () => {
|
||||
expect(predicateConditions("((a OR b) AND c)")).toEqual(["a OR b", "c"]);
|
||||
expect(predicateConditions("(a OR (b AND c))")).toEqual(["a OR (b AND c)"]);
|
||||
expect(predicateConditions("((attempt_id IS NOT NULL) AND (consumed_at IS NULL))")).toEqual([
|
||||
"attempt_id IS NOT NULL",
|
||||
"consumed_at IS NULL",
|
||||
]);
|
||||
expect(predicateConditions("(s = 'x AND (y'::text)")).toEqual(["s = 'x AND (y'::text"]);
|
||||
expect(predicateConditions('(("odd AND (name" IS NULL) AND b)')).toEqual(['"odd AND (name" IS NULL', "b"]);
|
||||
expect(predicateConditions("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches predicates by their conditions, never by case or by dropping structure", () => {
|
||||
const keys = ["app_slug", "attempt_id"];
|
||||
expect(samePredicate("((a OR b) AND c)", "(a OR (b AND c))", keys, false)).toBe(false);
|
||||
expect(samePredicate("(s = 'Pending'::text)", "(s = 'pending'::text)", keys, false)).toBe(false);
|
||||
expect(samePredicate("((a) AND (b))", "((b) AND (a))", keys, false)).toBe(true);
|
||||
expect(samePredicate("((a) AND (b))", "((a) AND (b) AND (c))", keys, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a key column's IS NOT NULL be absent only where NULL keys cannot collide", () => {
|
||||
const keys = ["app_slug", "attempt_id"];
|
||||
const declared = "((attempt_id IS NOT NULL) AND (consumed_at IS NULL))";
|
||||
expect(samePredicate(declared, "(consumed_at IS NULL)", keys, false)).toBe(true);
|
||||
expect(samePredicate(declared, "(consumed_at IS NULL)", keys, true)).toBe(false);
|
||||
expect(samePredicate(declared, "((consumed_at IS NULL) AND (other IS NOT NULL))", keys, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a key without a predicate to have none", () => {
|
||||
expect(samePredicate("", "", ["id"], false)).toBe(true);
|
||||
expect(samePredicate("", "(id IS NOT NULL)", ["id"], false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every operation the store offers, once, in an order that makes each one
|
||||
* meaningful. Returns the names it called, so a test can prove it covered the
|
||||
* store. Throws on the first operation the database refuses.
|
||||
*/
|
||||
async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
|
||||
const called = new Set<string>();
|
||||
const s = new Proxy(store, {
|
||||
get(target, name, receiver) {
|
||||
const value = Reflect.get(target, name, receiver) as unknown;
|
||||
if (typeof value !== "function") return value;
|
||||
return (...args: unknown[]) => {
|
||||
called.add(String(name));
|
||||
return (value as (...a: unknown[]) => unknown).apply(target, args);
|
||||
};
|
||||
},
|
||||
});
|
||||
const now = "2026-04-26T12:00:00.000Z";
|
||||
const later = "2999-01-01T00:00:00.000Z";
|
||||
const account = await s.createAccount({ createdAt: now, lastSeenAt: now, displayName: "Probe" });
|
||||
await s.touchAccount(account.id, now, { displayName: "Probe 2" });
|
||||
await s.findAccountById(account.id);
|
||||
const identity = {
|
||||
accountId: account.id,
|
||||
provider: "discord" as const,
|
||||
providerUserId: "u1",
|
||||
scopes: ["identify"],
|
||||
profileJson: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const linked = await s.upsertLinkedIdentity(identity);
|
||||
await s.updateLinkedIdentityCredential(linked.id, async () => ({ kind: "keep" }));
|
||||
await s.upsertLinkedIdentity({ ...identity, username: "again" });
|
||||
await s.findAccountByLinkedIdentity("discord", "u1");
|
||||
await s.findStoredLinkedIdentity("discord", "u1");
|
||||
await s.findStoredLinkedIdentityForAccount(account.id, "discord");
|
||||
await s.listStoredLinkedIdentitiesForAccount(account.id);
|
||||
await s.listLinkedIdentitiesForAccount(account.id);
|
||||
await s.createCapabilityGrant({
|
||||
accountId: account.id,
|
||||
scopeType: "app",
|
||||
scopeId: "ghostlight",
|
||||
capability: "app_access",
|
||||
source: "probe",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await s.listActiveGrants(account.id, "ghostlight", now);
|
||||
const session = { id: randomUUID(), accountId: account.id, appSlug: "ghostlight" as const, createdAt: now, lastSeenAt: now, expiresAt: later, accessRevision: 1, claimsJson: {} };
|
||||
await s.createSession(session);
|
||||
await s.createSession({ ...session, lastSeenAt: later });
|
||||
await s.findSession("ghostlight", session.id);
|
||||
await s.revokeSession("ghostlight", session.id, account.id, 1, now);
|
||||
const attempt = await s.createAuthAttempt({ appSlug: "ghostlight", provider: "discord", mode: "sign_in", returnTo: "https://x.test/", createdAt: now, expiresAt: later });
|
||||
await s.findAuthAttempt("ghostlight", attempt.handle);
|
||||
await s.updateAuthAttempt("ghostlight", attempt.handle, { status: "completed", at: now });
|
||||
const receipt = { appSlug: "ghostlight" as const, idempotencyKey: "k", requestFingerprint: "f", status: "accepted", contentSchema: "c", envelopeBase64: "e", createdAt: now, expiresAt: later };
|
||||
await s.createPrivateCommandReceipt(receipt);
|
||||
await s.createPrivateCommandReceipt(receipt);
|
||||
await s.findPrivateCommandReceipt("ghostlight", "k");
|
||||
const completion = { appSlug: "ghostlight" as const, provider: "discord" as const, mode: "sign_in" as const, accountId: account.id, sessionId: session.id, returnTo: "https://x.test/", createdAt: now, expiresAt: later, payloadJson: {} };
|
||||
const byCode = await s.createAuthCompletion(completion);
|
||||
await s.consumeAuthCompletion("ghostlight", byCode.code, now);
|
||||
await s.createAuthCompletion({ ...completion, attemptId: attempt.handle });
|
||||
await s.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, now);
|
||||
const snapshot = { accountId: account.id, provider: "discord" as const, scope: "ghostlight:probe", evaluatedAt: now, isAllowed: true, reasonCode: "ok", rawSummaryJson: {} };
|
||||
await s.upsertEntitlementSnapshot(snapshot);
|
||||
await s.upsertEntitlementSnapshot({ ...snapshot, isAllowed: false });
|
||||
await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now });
|
||||
return called;
|
||||
}
|
||||
|
||||
// Against a real Postgres, through the startup path itself: createStore, with
|
||||
// and without applying the schema. HEIMDALL_TEST_PG_ADMIN_URL names a role
|
||||
// that may create databases and roles. Each case clones a template database
|
||||
// that already holds the schema, and runs as its own login role.
|
||||
const adminUrl = process.env.HEIMDALL_TEST_PG_ADMIN_URL;
|
||||
describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_ADMIN_URL)", { timeout: 120_000 }, () => {
|
||||
const refusal = (code: string) => `Postgres storage could not be prepared (${code}); check GC_ACCESS_DATABASE_URL_FILE.`;
|
||||
const suffix = randomUUID().replace(/-/g, "").slice(0, 12);
|
||||
const template = `heimdall_template_${suffix}`;
|
||||
const role = `heimdall_app_${suffix}`;
|
||||
const rolePassword = "CANARYrolepw";
|
||||
const admin = adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined;
|
||||
const cleanup: Array<() => Promise<void>> = [];
|
||||
|
||||
/**
|
||||
* A pool the test itself uses. The forced drops end its connections,
|
||||
* possibly while pg is still closing them after end() resolved; that is
|
||||
* cleanup, not a finding, so it is not left to become an unhandled error.
|
||||
*/
|
||||
function fixturePool(connectionString: string): Pool {
|
||||
const pool = new Pool({ connectionString, max: 2 });
|
||||
pool.on("error", () => undefined);
|
||||
return pool;
|
||||
}
|
||||
|
||||
function urlFor(database: string, user?: string, password?: string): string {
|
||||
const url = new URL(adminUrl!);
|
||||
url.pathname = `/${database}`;
|
||||
if (user) url.username = user;
|
||||
if (password) url.password = password;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await admin!.query(`CREATE DATABASE ${template}`);
|
||||
const pool = fixturePool(urlFor(template));
|
||||
await new PostgresStore(pool).ensureSchema();
|
||||
await pool.end();
|
||||
await admin!.query(`CREATE ROLE ${role} LOGIN PASSWORD '${rolePassword}'`);
|
||||
}, 120_000);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const step of cleanup.splice(0).reverse()) await step();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await admin!.query(`DROP DATABASE IF EXISTS ${template}`);
|
||||
await admin!.query(`DROP ROLE IF EXISTS ${role}`);
|
||||
await admin!.end();
|
||||
}, 120_000);
|
||||
|
||||
/** A clone of the applied template, and a pool on it as its owner (the admin role). */
|
||||
async function appliedDatabase(): Promise<{ name: string; owner: Pool }> {
|
||||
const name = `heimdall_check_${randomUUID().replace(/-/g, "")}`;
|
||||
await admin!.query(`CREATE DATABASE ${name} TEMPLATE ${template}`);
|
||||
const owner = fixturePool(urlFor(name));
|
||||
cleanup.push(async () => {
|
||||
await owner.end();
|
||||
await admin!.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
|
||||
});
|
||||
return { name, owner };
|
||||
}
|
||||
|
||||
/** Grants the app role exactly `privileges`, per table, and nothing else. */
|
||||
async function grant(owner: Pool, privileges: Readonly<Record<string, readonly string[]>>): Promise<void> {
|
||||
await owner.query(`GRANT USAGE ON SCHEMA public TO ${role}`);
|
||||
for (const [table, wanted] of Object.entries(privileges)) {
|
||||
if (wanted.length > 0) await owner.query(`GRANT ${wanted.join(", ")} ON ${table} TO ${role}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts Heimdall's store on `url` and reports "started" or the refusal's
|
||||
* message. A refusal must also leave no session of its own behind: the
|
||||
* refused store's pool is closed before the error is thrown.
|
||||
*/
|
||||
async function start(url: string, applySchemaOnStartup: boolean): Promise<string> {
|
||||
const tagged = new URL(url);
|
||||
const tag = `heimdall_start_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
||||
tagged.searchParams.set("application_name", tag);
|
||||
try {
|
||||
const store = await createStore({
|
||||
storage: { backend: "postgres", databaseUrl: tagged.toString(), applySchemaOnStartup },
|
||||
} as HeimdallConfig);
|
||||
await store.close();
|
||||
return "started";
|
||||
} catch (error) {
|
||||
let sessions = -1;
|
||||
for (let attempt = 0; attempt < 50 && sessions !== 0; attempt += 1) {
|
||||
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const result = await admin!.query<{ n: number }>(
|
||||
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE application_name = $1",
|
||||
[tag]
|
||||
);
|
||||
sessions = result.rows[0]!.n;
|
||||
}
|
||||
expect(sessions, "a refused store left its connection open").toBe(0);
|
||||
return (error as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
it("starts on an applied database as its owner, applying or not", async () => {
|
||||
const { name } = await appliedDatabase();
|
||||
expect(await start(urlFor(name), false)).toBe("started");
|
||||
expect(await start(urlFor(name), true)).toBe("started");
|
||||
});
|
||||
|
||||
it("serves every store operation with exactly the privileges it checks for", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, STORE_TABLE_PRIVILEGES);
|
||||
|
||||
expect(await start(urlFor(name, role, rolePassword), false)).toBe("started");
|
||||
const pool = fixturePool(urlFor(name, role, rolePassword));
|
||||
cleanup.push(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
const called = await exerciseStore(new PostgresStore(pool));
|
||||
const offered = Object.getOwnPropertyNames(PostgresStore.prototype).filter(
|
||||
(method) => !["constructor", "ensureSchema", "checkSchema", "close"].includes(method)
|
||||
);
|
||||
expect([...called].sort()).toEqual(offered.sort());
|
||||
});
|
||||
|
||||
it("refuses each privilege taken away, and the store needs each one", async () => {
|
||||
const pairs = Object.entries(STORE_TABLE_PRIVILEGES).flatMap(([table, wanted]) => wanted.map((privilege) => ({ table, privilege })));
|
||||
expect(Object.keys(STORE_TABLE_PRIVILEGES).sort()).toEqual([...REQUIRED_TABLES].sort());
|
||||
for (const { table, privilege } of pairs) {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, { ...STORE_TABLE_PRIVILEGES, [table]: STORE_TABLE_PRIVILEGES[table]!.filter((p) => p !== privilege) });
|
||||
|
||||
const message = await start(urlFor(name, role, rolePassword), false);
|
||||
expect(message, `${table} without ${privilege}`).toBe(refusal("SCHEMA_PRIVILEGES"));
|
||||
expect(message).not.toContain("CANARY");
|
||||
|
||||
const pool = fixturePool(urlFor(name, role, rolePassword));
|
||||
const failure = await exerciseStore(new PostgresStore(pool)).then(
|
||||
() => undefined,
|
||||
(error: unknown) => (error as { code?: string }).code
|
||||
);
|
||||
await pool.end();
|
||||
expect(failure, `the store runs without ${privilege} on ${table}, so the check need not ask for it`).toBe("42501");
|
||||
for (const step of cleanup.splice(0).reverse()) await step();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to apply the schema as a role that may not, and closes the store", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, STORE_TABLE_PRIVILEGES);
|
||||
|
||||
expect(await start(urlFor(name, role, rolePassword), true)).toBe(refusal("42501"));
|
||||
});
|
||||
|
||||
it("prints each declared predicate exactly as Postgres prints the applied index's", async () => {
|
||||
const { owner } = await appliedDatabase();
|
||||
const printed = await printedDeclaredPredicates(owner, HEIMDALL_SCHEMA_REQUIREMENTS);
|
||||
const applied = await owner.query<{ predicate: string }>(
|
||||
"SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'auth_completions_attempt_unconsumed_unique_idx'::regclass"
|
||||
);
|
||||
const attemptKey = REQUIRED_KEYS.findIndex((key) => key.predicate !== "");
|
||||
expect(printed[attemptKey]).toBe(applied.rows[0]!.predicate);
|
||||
expect(printed.filter((predicate) => predicate !== "")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses a table whose primary key was dropped, even after applying the schema", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await owner.query("ALTER TABLE auth_completions DROP CONSTRAINT auth_completions_pkey");
|
||||
|
||||
expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING"));
|
||||
expect(await start(urlFor(name), true)).toBe(refusal("SCHEMA_MISSING"));
|
||||
});
|
||||
|
||||
it("serves every store operation with each privilege granted column by column", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await owner.query(`GRANT USAGE ON SCHEMA public TO ${role}`);
|
||||
for (const [table, wanted] of Object.entries(STORE_TABLE_PRIVILEGES)) {
|
||||
const columns = REQUIRED_COLUMNS.filter((column) => column.table === table).map((column) => column.column).join(", ");
|
||||
for (const privilege of wanted) await owner.query(`GRANT ${privilege} (${columns}) ON ${table} TO ${role}`);
|
||||
}
|
||||
|
||||
expect(await start(urlFor(name, role, rolePassword), false)).toBe("started");
|
||||
const pool = fixturePool(urlFor(name, role, rolePassword));
|
||||
cleanup.push(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
await exerciseStore(new PostgresStore(pool));
|
||||
});
|
||||
|
||||
it("refuses a privilege missing on one column of another table", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, { ...STORE_TABLE_PRIVILEGES, auth_completions: [] });
|
||||
const columns = REQUIRED_COLUMNS.filter((column) => column.table === "auth_completions" && column.column !== "attempt_id")
|
||||
.map((column) => column.column)
|
||||
.join(", ");
|
||||
for (const privilege of STORE_TABLE_PRIVILEGES.auth_completions!) {
|
||||
await owner.query(`GRANT ${privilege} (${columns}) ON auth_completions TO ${role}`);
|
||||
}
|
||||
|
||||
expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES"));
|
||||
});
|
||||
|
||||
it("names the TEMPORARY privilege when the role may not create temporary tables", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, STORE_TABLE_PRIVILEGES);
|
||||
await owner.query(`REVOKE TEMPORARY ON DATABASE ${name} FROM PUBLIC`);
|
||||
|
||||
expect(await start(urlFor(name, role, rolePassword), false)).toBe(
|
||||
"Postgres storage could not be prepared (SCHEMA_TEMP_PRIVILEGE); grant the database role TEMPORARY on its database."
|
||||
);
|
||||
});
|
||||
|
||||
// The schema's own predicate splits into the same conditions raw or
|
||||
// printed, so only a predicate whose printed form differs (a literal gains
|
||||
// its cast) shows that the check compares Postgres's printing.
|
||||
it("matches a declared predicate that Postgres prints differently from how it is written", async () => {
|
||||
const { owner } = await appliedDatabase();
|
||||
await owner.query("CREATE TABLE predicate_probe (id INTEGER NOT NULL, state TEXT, attempts INTEGER)");
|
||||
await owner.query("CREATE UNIQUE INDEX predicate_probe_live ON predicate_probe (id) WHERE state = 'live' AND attempts > 0");
|
||||
const requirements = {
|
||||
tables: ["predicate_probe"],
|
||||
columns: [
|
||||
{ table: "predicate_probe", column: "id", type: "INTEGER", nullable: false },
|
||||
{ table: "predicate_probe", column: "state", type: "TEXT", nullable: true },
|
||||
{ table: "predicate_probe", column: "attempts", type: "INTEGER", nullable: true },
|
||||
],
|
||||
keys: [{ table: "predicate_probe", columns: "id", predicate: "state = 'live' AND attempts > 0" }],
|
||||
privileges: {},
|
||||
};
|
||||
|
||||
expect((await printedDeclaredPredicates(owner, requirements))[0]).toBe("((state = 'live'::text) AND (attempts > 0))");
|
||||
await expect(checkSchemaRequirements(owner, requirements)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a privilege held on only some of a table's columns", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, { ...STORE_TABLE_PRIVILEGES, audit_events: [] });
|
||||
await owner.query(`GRANT INSERT (id) ON audit_events TO ${role}`);
|
||||
|
||||
expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES"));
|
||||
});
|
||||
|
||||
// Each damage below leaves a database whose shape still resembles the
|
||||
// schema but on which a store write fails or R21.1 no longer holds. A step
|
||||
// starting with "!" is expected to fail and leave its mark (a failed
|
||||
// CREATE INDEX CONCURRENTLY leaves an invalid index behind).
|
||||
const seedDuplicates = [
|
||||
"INSERT INTO accounts VALUES ('acc', now(), now(), null, null)",
|
||||
"INSERT INTO sessions VALUES ('s', 'acc', 'ghostlight', now(), now(), now() + interval '1 hour', '{}', 1)",
|
||||
];
|
||||
it.each([
|
||||
["a key made DEFERRABLE (ON CONFLICT refuses it as an arbiter)",
|
||||
["ALTER TABLE private_command_receipts DROP CONSTRAINT private_command_receipts_pkey", "ALTER TABLE private_command_receipts ADD PRIMARY KEY (app_slug, idempotency_key) DEFERRABLE INITIALLY IMMEDIATE"]],
|
||||
["a key narrowed to one column, the other only INCLUDEd",
|
||||
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE UNIQUE INDEX li_include ON linked_identities(provider) INCLUDE (provider_user_id)"]],
|
||||
["a key widened by an expression column",
|
||||
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE UNIQUE INDEX li_expr ON linked_identities(provider, provider_user_id, lower(username))"]],
|
||||
["a key replaced by a partial one",
|
||||
["ALTER TABLE entitlement_snapshots DROP CONSTRAINT entitlement_snapshots_account_id_provider_scope_key", "CREATE UNIQUE INDEX es_partial ON entitlement_snapshots(account_id, provider, scope) WHERE is_allowed"]],
|
||||
["a key replaced by a plain index on the same columns",
|
||||
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE INDEX li_plain ON linked_identities(provider, provider_user_id)"]],
|
||||
["a key replaced by one on other columns of the same count",
|
||||
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE UNIQUE INDEX li_other ON linked_identities(provider, username)"]],
|
||||
["a key left only as an invalid index by a failed concurrent build",
|
||||
[...seedDuplicates,
|
||||
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
||||
"INSERT INTO linked_identities (id, account_id, provider, provider_user_id, created_at, updated_at) VALUES ('l1', 'acc', 'youtube', 'dup', now(), now()), ('l2', 'acc', 'youtube', 'dup', now(), now())",
|
||||
"!CREATE UNIQUE INDEX CONCURRENTLY li_ccnew ON linked_identities(provider, provider_user_id)",
|
||||
"DELETE FROM linked_identities WHERE id = 'l2'"]],
|
||||
["the attempt index left invalid over duplicate attempts by a failed concurrent build",
|
||||
[...seedDuplicates,
|
||||
"DROP INDEX auth_completions_attempt_unconsumed_unique_idx",
|
||||
"INSERT INTO auth_completions (code, attempt_id, app_slug, provider, mode, account_id, session_id, return_to, payload_json, created_at, expires_at) VALUES ('c1', 'att', 'ghostlight', 'discord', 'sign_in', 'acc', 's', 'https://x/', '{}', now(), now() + interval '1 hour'), ('c2', 'att', 'ghostlight', 'discord', 'sign_in', 'acc', 's', 'https://x/', '{}', now(), now() + interval '1 hour')",
|
||||
"!CREATE UNIQUE INDEX CONCURRENTLY auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL"]],
|
||||
// An interrupted DROP INDEX CONCURRENTLY leaves an index ready but not
|
||||
// valid; the fixture sets that state directly (the admin role may).
|
||||
["a key left only as an index that is ready but not valid",
|
||||
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
||||
"CREATE UNIQUE INDEX li_half_dropped ON linked_identities(provider, provider_user_id)",
|
||||
"UPDATE pg_index SET indisvalid = false WHERE indexrelid = 'li_half_dropped'::regclass"]],
|
||||
["a key's first column compared under a case-insensitive collation",
|
||||
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
|
||||
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
||||
"CREATE UNIQUE INDEX li_ci_first ON linked_identities(provider COLLATE heimdall_ci, provider_user_id)"]],
|
||||
// Soul pass 7: the key index compares under "C", but the column itself is
|
||||
// case-insensitive, so the store's own lookups by provider_user_id match
|
||||
// other users' ids.
|
||||
["a column declared under a case-insensitive collation, its key index under C",
|
||||
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
|
||||
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
||||
"ALTER TABLE linked_identities ALTER COLUMN provider_user_id TYPE text COLLATE heimdall_ci",
|
||||
'CREATE UNIQUE INDEX li_c ON linked_identities(provider, provider_user_id COLLATE "C")']],
|
||||
["a key compared under a case-insensitive collation (two users' ids become one row)",
|
||||
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
|
||||
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
||||
"CREATE UNIQUE INDEX li_ci ON linked_identities(provider, provider_user_id COLLATE heimdall_ci)"]],
|
||||
["the attempt index replaced by a plain one of the same name",
|
||||
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id)"]],
|
||||
["the attempt index made unique on every row (no predicate)",
|
||||
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id)"]],
|
||||
["the attempt index given another predicate",
|
||||
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL"]],
|
||||
["the attempt index narrowed by an extra condition",
|
||||
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL AND app_slug <> 'ghostlight'"]],
|
||||
["the attempt index with a condition ORed into its predicate",
|
||||
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE (attempt_id IS NOT NULL OR app_slug = 'x') AND consumed_at IS NULL"]],
|
||||
["the attempt index made NULLS NOT DISTINCT without excluding NULL attempts",
|
||||
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) NULLS NOT DISTINCT WHERE consumed_at IS NULL"]],
|
||||
["a column retyped", ["ALTER TABLE sessions ALTER COLUMN access_revision TYPE TEXT"]],
|
||||
["a created table's column dropped", ["ALTER TABLE auth_completions DROP COLUMN mode"]],
|
||||
["NOT NULL added to a column the store leaves null", ["ALTER TABLE auth_completions ALTER COLUMN attempt_id SET NOT NULL"]],
|
||||
["NOT NULL added to a nullable column of another table",
|
||||
["UPDATE linked_identities SET username = ''", "ALTER TABLE linked_identities ALTER COLUMN username SET NOT NULL"]],
|
||||
])("refuses %s", async (_damage, steps) => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
for (const step of steps) {
|
||||
if (step.startsWith("!")) await expect(owner.query(step.slice(1))).rejects.toBeDefined();
|
||||
else await owner.query(step);
|
||||
}
|
||||
|
||||
expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING"));
|
||||
});
|
||||
|
||||
// Each change below leaves a database the store serves exactly as before.
|
||||
it.each([
|
||||
["a key index that carries extra INCLUDE columns",
|
||||
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE UNIQUE INDEX li_covering ON linked_identities(provider, provider_user_id) INCLUDE (username)"]],
|
||||
["the attempt index under another name", ["ALTER INDEX auth_completions_attempt_unconsumed_unique_idx RENAME TO attempt_idx_renamed"]],
|
||||
["the attempt index's conditions in the other order",
|
||||
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX attempt_reordered ON auth_completions(app_slug, attempt_id) WHERE consumed_at IS NULL AND attempt_id IS NOT NULL"]],
|
||||
// NULL attempt ids never collide in a unique index that treats NULLs as
|
||||
// distinct (the default), so dropping that condition keeps the same rows unique.
|
||||
["the attempt index without its redundant attempt_id IS NOT NULL",
|
||||
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX attempt_unconsumed ON auth_completions(app_slug, attempt_id) WHERE consumed_at IS NULL"]],
|
||||
["a lookup index dropped", ["DROP INDEX audit_events_lookup_idx", "DROP INDEX auth_completions_lookup_idx"]],
|
||||
// Only a key's own columns decide which rows collide; an INCLUDEd column's
|
||||
// collation does not, whatever it is.
|
||||
["a key index that INCLUDEs an undeclared case-insensitive column",
|
||||
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
|
||||
"ALTER TABLE linked_identities ADD COLUMN operator_note text COLLATE heimdall_ci",
|
||||
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
||||
"CREATE UNIQUE INDEX li_note ON linked_identities(provider, provider_user_id) INCLUDE (operator_note)"]],
|
||||
])("accepts %s", async (_change, steps) => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
for (const step of steps) await owner.query(step);
|
||||
|
||||
expect(await start(urlFor(name), false)).toBe("started");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => {
|
|||
expect(bySchema.get("idunn.daemon_health")).toEqual([
|
||||
"yggdrasil-heimdall",
|
||||
"active",
|
||||
"Heimdall auth runtime active; storage=postgres; providers=2/6; apps=4; healthTransport=CultNet/RUDP",
|
||||
"Heimdall auth runtime active; storage=postgres; providers=2/7; apps=4; healthTransport=CultNet/RUDP",
|
||||
"2026-06-16T17:10:00.000Z",
|
||||
"heimdall.cultnet-rudp-provider-health",
|
||||
"daemon-published",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ describe("Heimdall Verse witness advertisement", () => {
|
|||
"twitch",
|
||||
"youtube",
|
||||
"spotify",
|
||||
"joystick",
|
||||
]);
|
||||
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
|
||||
"repixelizer",
|
||||
|
|
|
|||
Loading…
Reference in a new issue