Land Heimdall service skeleton
This commit is contained in:
parent
49fb467950
commit
58a12a2835
20 changed files with 3001 additions and 33 deletions
19
.env.example
Normal file
19
.env.example
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=4100
|
||||||
|
GC_ACCESS_BASE_URL=http://127.0.0.1:4100
|
||||||
|
GC_ACCESS_ISSUER=http://127.0.0.1:4100
|
||||||
|
GC_ACCESS_SESSION_TTL_SECONDS=3600
|
||||||
|
GC_ACCESS_STATE_TTL_SECONDS=600
|
||||||
|
GC_ACCESS_SIGNING_PRIVATE_KEY_PEM=
|
||||||
|
GC_ACCESS_SIGNING_KEY_ID=
|
||||||
|
|
||||||
|
GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=
|
||||||
|
GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=
|
||||||
|
GC_ACCESS_PROVIDER_PATREON_CLIENT_ID=
|
||||||
|
GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET=
|
||||||
|
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=
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
||||||
.venv/
|
node_modules/
|
||||||
__pycache__/
|
dist/
|
||||||
*.pyc
|
coverage/
|
||||||
|
.pnpm-store/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -13,18 +13,42 @@ The point is painfully simple:
|
||||||
|
|
||||||
## Current Shape
|
## Current Shape
|
||||||
|
|
||||||
This repo is still planning and service-contract work, not a landed auth
|
This repo now has an initial standalone service skeleton.
|
||||||
service yet.
|
|
||||||
|
Landed right now:
|
||||||
|
|
||||||
|
- a TypeScript/Fastify service scaffold under `src/`
|
||||||
|
- Ed25519 JWT signing plus `/.well-known/jwks.json`
|
||||||
|
- signed OAuth start-state scaffolding for Discord, Patreon, GitHub, Twitch,
|
||||||
|
and YouTube
|
||||||
|
- app profile surfaces for Repixelizer and StreamPixels
|
||||||
|
- signed claim issuance for app-local verification experiments
|
||||||
|
|
||||||
|
Still not landed:
|
||||||
|
|
||||||
|
- provider token exchange on callback
|
||||||
|
- durable account/session/grant storage
|
||||||
|
- end-to-end app integrations
|
||||||
|
|
||||||
Canonical docs:
|
Canonical docs:
|
||||||
|
|
||||||
- `docs/architecture.md`
|
- `docs/architecture.md`
|
||||||
- `docs/implementation-plan.md`
|
- `docs/implementation-plan.md`
|
||||||
|
- `docs/service-contract.md`
|
||||||
- `docs/app-profiles/repixelizer.md`
|
- `docs/app-profiles/repixelizer.md`
|
||||||
- `docs/app-profiles/streampixels.md`
|
- `docs/app-profiles/streampixels.md`
|
||||||
- `state/map.yaml`
|
- `state/map.yaml`
|
||||||
- `notes/fresh-workspace-handoff.md`
|
- `notes/fresh-workspace-handoff.md`
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
## Working Loop
|
## Working Loop
|
||||||
|
|
||||||
1. Rehydrate from canonical state.
|
1. Rehydrate from canonical state.
|
||||||
|
|
|
||||||
|
|
@ -420,6 +420,10 @@ Suggested generic env surface:
|
||||||
- `GC_ACCESS_BASE_URL=https://heimdall.gamecult.org`
|
- `GC_ACCESS_BASE_URL=https://heimdall.gamecult.org`
|
||||||
- `GC_ACCESS_INTERNAL_URL=http://127.0.0.1:4100`
|
- `GC_ACCESS_INTERNAL_URL=http://127.0.0.1:4100`
|
||||||
- `GC_ACCESS_SESSION_SECRET=...`
|
- `GC_ACCESS_SESSION_SECRET=...`
|
||||||
|
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM=...`
|
||||||
|
- `GC_ACCESS_SIGNING_KEY_ID=...`
|
||||||
|
- `GC_ACCESS_SESSION_TTL_SECONDS=3600`
|
||||||
|
- `GC_ACCESS_STATE_TTL_SECONDS=600`
|
||||||
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=...`
|
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=...`
|
||||||
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=...`
|
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=...`
|
||||||
- `GC_ACCESS_PROVIDER_DISCORD_BOT_TOKEN=...`
|
- `GC_ACCESS_PROVIDER_DISCORD_BOT_TOKEN=...`
|
||||||
|
|
@ -438,6 +442,10 @@ Suggested generic env surface:
|
||||||
|
|
||||||
Only configure the providers Heimdall actually owns for a given deployment.
|
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 an ephemeral dev signing key. That is acceptable for local iteration
|
||||||
|
and absolutely not acceptable for real deployment.
|
||||||
|
|
||||||
Per-app binding should live in code or profile config, not in cloned provider
|
Per-app binding should live in code or profile config, not in cloned provider
|
||||||
env namespaces. App-local binding and runtime config such as creator
|
env namespaces. App-local binding and runtime config such as creator
|
||||||
associations, webhook subscriptions, sync toggles, or workspace/project
|
associations, webhook subscriptions, sync toggles, or workspace/project
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,19 @@ and the architecture note first, then fix this file.
|
||||||
|
|
||||||
## Current machine
|
## Current machine
|
||||||
|
|
||||||
There is no landed auth machine here yet.
|
There is now a first landed Heimdall skeleton.
|
||||||
|
|
||||||
What exists right now is:
|
What exists right now is:
|
||||||
|
|
||||||
- the shared architecture note
|
- the shared architecture note
|
||||||
|
- the concrete service contract in `docs/service-contract.md`
|
||||||
- app-binding notes for Repixelizer and StreamPixels
|
- app-binding notes for Repixelizer and StreamPixels
|
||||||
- explicit state/doctrine scaffolding so the plan does not immediately turn
|
- a standalone TypeScript/Fastify service skeleton under `src/`
|
||||||
into soup
|
- Ed25519 JWT signing plus JWKS/discovery exposure
|
||||||
|
- OAuth start/callback contract surfaces with signed `state`
|
||||||
|
- app-profile-driven claim issuance for Repixelizer and StreamPixels
|
||||||
|
- explicit state/doctrine scaffolding so the plan does not immediately turn into
|
||||||
|
soup
|
||||||
|
|
||||||
## Primary objective
|
## Primary objective
|
||||||
|
|
||||||
|
|
@ -80,21 +85,33 @@ Goals:
|
||||||
|
|
||||||
### Phase 1: Contract and schema scaffold
|
### Phase 1: Contract and schema scaffold
|
||||||
|
|
||||||
- define:
|
Landed in the first pass:
|
||||||
- signed claim/session payloads
|
|
||||||
- JWKS or equivalent verification surface
|
- signed claim/session payload shape
|
||||||
- OAuth `state` and callback contracts
|
- Ed25519 JWKS verification surface
|
||||||
- grant / entitlement / audit record shapes
|
- OAuth `state` and callback contract surfaces
|
||||||
- app profile / capability rule inputs and outputs
|
- app profile / capability rule inputs and outputs
|
||||||
|
|
||||||
|
Still to do in this phase:
|
||||||
|
|
||||||
|
- durable grant / entitlement / audit record shapes
|
||||||
|
- persistence-backed account and linked-identity schema details
|
||||||
|
|
||||||
### Phase 2: Heimdall service skeleton
|
### Phase 2: Heimdall service skeleton
|
||||||
|
|
||||||
- create the first real service layout for:
|
Landed in the first pass:
|
||||||
- provider OAuth start/callback routes
|
|
||||||
- account / linked-identity persistence
|
- provider OAuth start route scaffolding
|
||||||
- session issuance and refresh
|
- callback route scaffolding with signed-state validation
|
||||||
- capability evaluation
|
- session/claim issuance contract
|
||||||
- audit logging
|
- capability evaluation for shared app capabilities
|
||||||
|
|
||||||
|
Next in this phase:
|
||||||
|
|
||||||
|
- account / linked-identity persistence
|
||||||
|
- provider token exchange and refresh
|
||||||
|
- audit logging
|
||||||
|
- non-ephemeral signing key handling
|
||||||
|
|
||||||
### Phase 3: Repixelizer binding
|
### Phase 3: Repixelizer binding
|
||||||
|
|
||||||
|
|
|
||||||
272
docs/service-contract.md
Normal file
272
docs/service-contract.md
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
# Heimdall Service Contract
|
||||||
|
|
||||||
|
This file describes the first landed Heimdall service skeleton.
|
||||||
|
|
||||||
|
It is the concrete HTTP/JWKS contract that now exists in the repo. It is not a
|
||||||
|
claim that provider token exchange, persistence, or app bindings are fully
|
||||||
|
implemented yet.
|
||||||
|
|
||||||
|
## Current stack
|
||||||
|
|
||||||
|
- runtime: Node.js `24+`
|
||||||
|
- service framework: Fastify
|
||||||
|
- language: TypeScript
|
||||||
|
- signing: Ed25519 JWTs with JWKS exposure
|
||||||
|
|
||||||
|
## Current routes
|
||||||
|
|
||||||
|
### `GET /healthz`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- basic liveness probe
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"service": "heimdall",
|
||||||
|
"issuer": "https://heimdall.gamecult.org",
|
||||||
|
"now": "2026-04-26T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /.well-known/jwks.json`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- publish the public signing key that app backends use to verify Heimdall JWTs
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- current skeleton signs with Ed25519
|
||||||
|
- if `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` is absent, the service generates an
|
||||||
|
ephemeral dev key at startup
|
||||||
|
- production should use a persisted private key and stable `kid`
|
||||||
|
|
||||||
|
### `GET /.well-known/heimdall-configuration`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- publish discovery metadata for app backends and integration code
|
||||||
|
|
||||||
|
Current shape includes:
|
||||||
|
|
||||||
|
- `issuer`
|
||||||
|
- `jwksUri`
|
||||||
|
- `configurationUri`
|
||||||
|
- `oauthStartEndpoint`
|
||||||
|
- `oauthCallbackEndpoint`
|
||||||
|
- `claimIssueEndpoint`
|
||||||
|
- `supportedProviders`
|
||||||
|
- `apps`
|
||||||
|
|
||||||
|
### `GET /v1/apps`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- list known app profiles and their capability surfaces
|
||||||
|
|
||||||
|
### `GET /v1/apps/{appSlug}`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- return one app profile
|
||||||
|
|
||||||
|
Current app slugs:
|
||||||
|
|
||||||
|
- `repixelizer`
|
||||||
|
- `streampixels`
|
||||||
|
|
||||||
|
Profile fields:
|
||||||
|
|
||||||
|
- `slug`
|
||||||
|
- `displayName`
|
||||||
|
- `profileVersion`
|
||||||
|
- `identityProviders`
|
||||||
|
- `entitlementSources`
|
||||||
|
- `managedConnectionProviders`
|
||||||
|
- `capabilities`
|
||||||
|
|
||||||
|
Capability modes:
|
||||||
|
|
||||||
|
- `shared`
|
||||||
|
fully evaluated by Heimdall from signed facts/grants
|
||||||
|
- `hybrid`
|
||||||
|
requires app-local state after claim verification
|
||||||
|
|
||||||
|
### `POST /v1/oauth/{provider}/start`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- begin a provider OAuth flow
|
||||||
|
- mint signed OAuth `state`
|
||||||
|
- return the provider authorization URL
|
||||||
|
|
||||||
|
Supported providers in the skeleton:
|
||||||
|
|
||||||
|
- `discord`
|
||||||
|
- `patreon`
|
||||||
|
- `github`
|
||||||
|
- `twitch`
|
||||||
|
- `youtube`
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appSlug": "repixelizer",
|
||||||
|
"mode": "sign_in",
|
||||||
|
"returnTo": "https://repixelizer.gamecult.org/app/",
|
||||||
|
"connection": {
|
||||||
|
"kind": "creator",
|
||||||
|
"targetId": "creator:alpha",
|
||||||
|
"summary": "Optional app-local binding hint"
|
||||||
|
},
|
||||||
|
"requestedScopes": ["identify", "guilds"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider": "discord",
|
||||||
|
"appSlug": "repixelizer",
|
||||||
|
"mode": "sign_in",
|
||||||
|
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
|
||||||
|
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
|
||||||
|
"stateToken": "<signed-jwt>",
|
||||||
|
"stateExpiresAt": "2026-04-26T12:10:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Failure mode right now:
|
||||||
|
|
||||||
|
- `503 provider_not_configured` when the provider client id is missing
|
||||||
|
|
||||||
|
### `GET /v1/oauth/{provider}/callback`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- validate signed OAuth `state`
|
||||||
|
- serve as the landing surface for future code/token exchange
|
||||||
|
|
||||||
|
Current status:
|
||||||
|
|
||||||
|
- validates signed `state`
|
||||||
|
- returns provider errors cleanly
|
||||||
|
- returns `501 token_exchange_not_implemented` after successful state/code
|
||||||
|
validation
|
||||||
|
|
||||||
|
That is deliberate. The contract is landed; provider token exchange and account
|
||||||
|
persistence are next.
|
||||||
|
|
||||||
|
### `POST /v1/apps/{appSlug}/claims/issue`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- issue a signed Heimdall access claim for one app after identity/grant facts
|
||||||
|
have already been resolved
|
||||||
|
|
||||||
|
This is currently the concrete claim contract and integration seam for early app
|
||||||
|
binding work.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accountId": "acct_repixelizer_001",
|
||||||
|
"displayName": "Meta",
|
||||||
|
"facts": ["discord.allowed_role", "grant.operator"],
|
||||||
|
"linkedIdentities": [
|
||||||
|
{
|
||||||
|
"provider": "discord",
|
||||||
|
"providerUserId": "123456789",
|
||||||
|
"username": "meta"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"accessRevision": 1,
|
||||||
|
"ttlSeconds": 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session": {
|
||||||
|
"accountId": "acct_repixelizer_001",
|
||||||
|
"sessionId": "uuid",
|
||||||
|
"appSlug": "repixelizer",
|
||||||
|
"accessRevision": 1,
|
||||||
|
"expiresAt": "2026-04-26T13:00:00.000Z"
|
||||||
|
},
|
||||||
|
"accessToken": "<signed-jwt>",
|
||||||
|
"claimSet": {},
|
||||||
|
"verification": {
|
||||||
|
"issuer": "https://heimdall.gamecult.org",
|
||||||
|
"jwksUri": "https://heimdall.gamecult.org/.well-known/jwks.json",
|
||||||
|
"alg": "EdDSA",
|
||||||
|
"kid": "ed25519-..."
|
||||||
|
},
|
||||||
|
"sharedCapabilities": ["app_access", "queue_submit", "admin_access"],
|
||||||
|
"hybridCapabilities": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access claim shape
|
||||||
|
|
||||||
|
Current claim payload fields:
|
||||||
|
|
||||||
|
- `iss`
|
||||||
|
- `aud`
|
||||||
|
- `sub`
|
||||||
|
- `sid`
|
||||||
|
- `jti`
|
||||||
|
- `iat`
|
||||||
|
- `nbf`
|
||||||
|
- `exp`
|
||||||
|
- `typ`
|
||||||
|
- `account_id`
|
||||||
|
- `access_revision`
|
||||||
|
- `display_name` optional
|
||||||
|
- `app.slug`
|
||||||
|
- `app.profile_version`
|
||||||
|
- `facts`
|
||||||
|
- `capabilities`
|
||||||
|
- `identities`
|
||||||
|
|
||||||
|
Important current rule:
|
||||||
|
|
||||||
|
- Heimdall only grants fully shared capabilities directly
|
||||||
|
- hybrid capabilities still require app-local checks after claim verification
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Repixelizer `app_access` and `queue_submit` are shared
|
||||||
|
- Repixelizer `job_read_own` still needs local ownership
|
||||||
|
- StreamPixels `viewer_access` can be shared
|
||||||
|
- StreamPixels `creator_access` still needs app-local creator membership
|
||||||
|
|
||||||
|
## Current gaps
|
||||||
|
|
||||||
|
The following are not landed yet:
|
||||||
|
|
||||||
|
- provider token exchange on callback
|
||||||
|
- durable account/session/grant storage
|
||||||
|
- linked-identity persistence
|
||||||
|
- entitlement snapshot persistence
|
||||||
|
- refresh/revocation flows
|
||||||
|
- app-facing middleware packages or reference verifiers
|
||||||
|
|
||||||
|
## Next implementation move
|
||||||
|
|
||||||
|
Build durable storage plus the first real provider path on top of this
|
||||||
|
skeleton:
|
||||||
|
|
||||||
|
- persisted signing key material
|
||||||
|
- accounts and linked identities
|
||||||
|
- real callback exchange for one provider slice
|
||||||
|
- first end-to-end Repixelizer binding
|
||||||
|
|
@ -27,16 +27,22 @@ Do not trust this file for the exact live HEAD. Always check git.
|
||||||
|
|
||||||
## Current Orientation
|
## Current Orientation
|
||||||
|
|
||||||
- this repo is still planning and service-contract work, not a landed auth
|
- this repo now has an initial standalone Heimdall service skeleton
|
||||||
service yet
|
|
||||||
- the canonical shared architecture lives in `docs/architecture.md`
|
- the canonical shared architecture lives in `docs/architecture.md`
|
||||||
|
- the concrete HTTP/JWKS contract lives in `docs/service-contract.md`
|
||||||
- app profiles live under `docs/app-profiles/`
|
- app profiles live under `docs/app-profiles/`
|
||||||
- the key boundary is explicit:
|
- the key boundary is explicit:
|
||||||
- Heimdall owns OAuth, linking, grants, entitlement refresh, and signed claim
|
- Heimdall owns OAuth, linking, managed provider credential custody, grants,
|
||||||
issuance
|
entitlement refresh, and signed claim issuance
|
||||||
- app-domain data stays app-owned by default
|
- app-domain data stays app-owned by default
|
||||||
- host apps verify signed claims locally for routine auth instead of calling
|
- host apps verify signed claims locally for routine auth instead of calling
|
||||||
Heimdall on every request
|
Heimdall on every request
|
||||||
|
- the landed skeleton already exposes:
|
||||||
|
- `/.well-known/jwks.json`
|
||||||
|
- `/.well-known/heimdall-configuration`
|
||||||
|
- `/v1/oauth/{provider}/start`
|
||||||
|
- `/v1/oauth/{provider}/callback`
|
||||||
|
- `/v1/apps/{appSlug}/claims/issue`
|
||||||
- Repixelizer is the first auth-blank binding target
|
- Repixelizer is the first auth-blank binding target
|
||||||
- StreamPixels is the migration target with useful existing auth seams that
|
- StreamPixels is the migration target with useful existing auth seams that
|
||||||
should not be flattened into mush
|
should not be flattened into mush
|
||||||
|
|
@ -58,13 +64,12 @@ Do not trust this file for the exact live HEAD. Always check git.
|
||||||
Do not continue implementation automatically from a rehydrate-only request.
|
Do not continue implementation automatically from a rehydrate-only request.
|
||||||
|
|
||||||
If the user asks to continue, the current next move is to define the first
|
If the user asks to continue, the current next move is to define the first
|
||||||
Heimdall service contract and skeleton:
|
real provider/storage slice on top of the landed skeleton:
|
||||||
|
|
||||||
- OAuth start/callback surfaces
|
- durable account / linked-identity / grant persistence
|
||||||
- signed session / claim shape
|
- persisted signing key material
|
||||||
- claim verification contract for app backends
|
- first real provider callback/token exchange path
|
||||||
- entitlement/grant persistence boundaries
|
- first end-to-end Repixelizer binding
|
||||||
- Repixelizer and StreamPixels integration seams
|
|
||||||
|
|
||||||
without dragging app-domain data into the shared layer and without forcing
|
without dragging app-domain data into the shared layer and without forcing
|
||||||
per-request auth round-trips.
|
per-request auth round-trips.
|
||||||
|
|
|
||||||
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "heimdall",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"fastify": "^5.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1434
pnpm-lock.yaml
generated
Normal file
1434
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
172
src/app-profiles.ts
Normal file
172
src/app-profiles.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { type AppSlug, type LinkedIdentityInput, type Provider } from "./contracts.js";
|
||||||
|
|
||||||
|
export type CapabilityMode = "shared" | "hybrid";
|
||||||
|
|
||||||
|
export interface CapabilityDefinition {
|
||||||
|
key: string;
|
||||||
|
mode: CapabilityMode;
|
||||||
|
summary: string;
|
||||||
|
sharedRule?: string;
|
||||||
|
localRequirement?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimEvaluationContext {
|
||||||
|
accountId: string;
|
||||||
|
facts: Set<string>;
|
||||||
|
identities: LinkedIdentityInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppProfile {
|
||||||
|
slug: AppSlug;
|
||||||
|
displayName: string;
|
||||||
|
profileVersion: string;
|
||||||
|
identityProviders: Provider[];
|
||||||
|
entitlementSources: Provider[];
|
||||||
|
managedConnectionProviders: Provider[];
|
||||||
|
capabilities: CapabilityDefinition[];
|
||||||
|
evaluateSharedCapabilities(context: ClaimEvaluationContext): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyFact(facts: Set<string>, values: readonly string[]): boolean {
|
||||||
|
return values.some((value) => facts.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const repixelizerProfile: AppProfile = {
|
||||||
|
slug: "repixelizer",
|
||||||
|
displayName: "Repixelizer",
|
||||||
|
profileVersion: "2026-04-26",
|
||||||
|
identityProviders: ["discord", "patreon"],
|
||||||
|
entitlementSources: ["discord", "patreon"],
|
||||||
|
managedConnectionProviders: [],
|
||||||
|
capabilities: [
|
||||||
|
{
|
||||||
|
key: "app_access",
|
||||||
|
mode: "shared",
|
||||||
|
summary: "May load the protected hosted GUI.",
|
||||||
|
sharedRule:
|
||||||
|
"discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "queue_submit",
|
||||||
|
mode: "shared",
|
||||||
|
summary: "May create a repixelizer job.",
|
||||||
|
sharedRule: "app_access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "job_read_own",
|
||||||
|
mode: "hybrid",
|
||||||
|
summary: "May read own job state, event stream, and final output.",
|
||||||
|
localRequirement: "Host app must combine app_access with job ownership.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "job_cancel_own",
|
||||||
|
mode: "hybrid",
|
||||||
|
summary: "May cancel own queued or running job.",
|
||||||
|
localRequirement: "Host app must combine app_access with job ownership.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_access",
|
||||||
|
mode: "shared",
|
||||||
|
summary: "May inspect grant/admin surfaces.",
|
||||||
|
sharedRule: "grant.operator || grant.admin_access",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
evaluateSharedCapabilities(context) {
|
||||||
|
const capabilities: string[] = [];
|
||||||
|
const appAccess = hasAnyFact(context.facts, [
|
||||||
|
"discord.allowed_role",
|
||||||
|
"patreon.allowed_tier",
|
||||||
|
"grant.global_member",
|
||||||
|
"grant.app_access",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (appAccess) {
|
||||||
|
capabilities.push("app_access", "queue_submit");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAnyFact(context.facts, ["grant.operator", "grant.admin_access"])) {
|
||||||
|
capabilities.push("admin_access");
|
||||||
|
}
|
||||||
|
|
||||||
|
return capabilities;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const streampixelsProfile: AppProfile = {
|
||||||
|
slug: "streampixels",
|
||||||
|
displayName: "StreamPixels",
|
||||||
|
profileVersion: "2026-04-26",
|
||||||
|
identityProviders: ["twitch", "youtube"],
|
||||||
|
entitlementSources: [],
|
||||||
|
managedConnectionProviders: ["twitch", "youtube"],
|
||||||
|
capabilities: [
|
||||||
|
{
|
||||||
|
key: "viewer_access",
|
||||||
|
mode: "shared",
|
||||||
|
summary: "Authenticated viewer session for control-plane surfaces.",
|
||||||
|
sharedRule: "identity.authenticated",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "creator_access",
|
||||||
|
mode: "hybrid",
|
||||||
|
summary: "Creator-scoped route access.",
|
||||||
|
localRequirement:
|
||||||
|
"Host app must combine authenticated session claims with local creator membership.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "creator_admin",
|
||||||
|
mode: "hybrid",
|
||||||
|
summary: "Creator-scoped write/admin access.",
|
||||||
|
localRequirement:
|
||||||
|
"Host app must combine authenticated session claims with local creator admin membership.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "operator_access",
|
||||||
|
mode: "shared",
|
||||||
|
summary: "Global operator access.",
|
||||||
|
sharedRule: "grant.operator",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
evaluateSharedCapabilities(context) {
|
||||||
|
const capabilities: string[] = [];
|
||||||
|
|
||||||
|
if (context.facts.has("identity.authenticated")) {
|
||||||
|
capabilities.push("viewer_access");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.facts.has("grant.operator")) {
|
||||||
|
capabilities.push("operator_access");
|
||||||
|
}
|
||||||
|
|
||||||
|
return capabilities;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appProfiles: Record<AppSlug, AppProfile> = {
|
||||||
|
repixelizer: repixelizerProfile,
|
||||||
|
streampixels: streampixelsProfile,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAppProfile(appSlug: AppSlug): AppProfile {
|
||||||
|
return appProfiles[appSlug];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAppProfile(profile: AppProfile): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
slug: profile.slug,
|
||||||
|
displayName: profile.displayName,
|
||||||
|
profileVersion: profile.profileVersion,
|
||||||
|
identityProviders: profile.identityProviders,
|
||||||
|
entitlementSources: profile.entitlementSources,
|
||||||
|
managedConnectionProviders: profile.managedConnectionProviders,
|
||||||
|
capabilities: profile.capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsProvider(profile: AppProfile, provider: Provider): boolean {
|
||||||
|
return (
|
||||||
|
profile.identityProviders.includes(provider) ||
|
||||||
|
profile.managedConnectionProviders.includes(provider) ||
|
||||||
|
profile.entitlementSources.includes(provider)
|
||||||
|
);
|
||||||
|
}
|
||||||
418
src/app.ts
Normal file
418
src/app.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
appSlugs,
|
||||||
|
connectionKinds,
|
||||||
|
oauthModes,
|
||||||
|
providers,
|
||||||
|
type AccessClaimPayload,
|
||||||
|
type AppSlug,
|
||||||
|
type IssueClaimRequest,
|
||||||
|
type OAuthConnectionBinding,
|
||||||
|
type OAuthStartRequest,
|
||||||
|
type Provider,
|
||||||
|
} from "./contracts.js";
|
||||||
|
import { getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
|
||||||
|
import { type HeimdallConfig, loadConfig } from "./config.js";
|
||||||
|
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
|
||||||
|
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
|
||||||
|
|
||||||
|
interface BuildAppOptions {
|
||||||
|
config?: HeimdallConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowEpochSeconds(): number {
|
||||||
|
return Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampTtlSeconds(value: number, fallback: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(60, Math.min(86_400, Math.trunc(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFacts(request: IssueClaimRequest): string[] {
|
||||||
|
const facts = new Set(request.facts ?? []);
|
||||||
|
if (request.accountId.trim() || request.linkedIdentities?.length) {
|
||||||
|
facts.add("identity.authenticated");
|
||||||
|
}
|
||||||
|
return [...facts].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiscovery(config: HeimdallConfig) {
|
||||||
|
return {
|
||||||
|
issuer: config.issuer,
|
||||||
|
jwksUri: `${config.publicBaseUrl}/.well-known/jwks.json`,
|
||||||
|
configurationUri: `${config.publicBaseUrl}/.well-known/heimdall-configuration`,
|
||||||
|
oauthStartEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/start`,
|
||||||
|
oauthCallbackEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/callback`,
|
||||||
|
claimIssueEndpoint: `${config.publicBaseUrl}/v1/apps/{appSlug}/claims/issue`,
|
||||||
|
supportedProviders: providers.map((provider) => ({
|
||||||
|
key: provider,
|
||||||
|
displayName: providerCatalog[provider].displayName,
|
||||||
|
roles: providerCatalog[provider].roles,
|
||||||
|
})),
|
||||||
|
apps: appSlugs.map((appSlug) => serializeAppProfile(getAppProfile(appSlug))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOAuthStateToken(options: {
|
||||||
|
config: HeimdallConfig;
|
||||||
|
provider: Provider;
|
||||||
|
appSlug: AppSlug;
|
||||||
|
mode: OAuthStartRequest["mode"];
|
||||||
|
returnTo: string;
|
||||||
|
connection: OAuthConnectionBinding | undefined;
|
||||||
|
keys: ReturnType<typeof createRuntimeKeyMaterial>;
|
||||||
|
}): { token: string; expiresAt: string } {
|
||||||
|
const issuedAt = nowEpochSeconds();
|
||||||
|
const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds;
|
||||||
|
|
||||||
|
const token = signJwt(
|
||||||
|
{
|
||||||
|
iss: options.config.issuer,
|
||||||
|
sub: options.appSlug,
|
||||||
|
aud: options.provider,
|
||||||
|
jti: randomUUID(),
|
||||||
|
iat: issuedAt,
|
||||||
|
nbf: issuedAt,
|
||||||
|
exp: expiresAtEpoch,
|
||||||
|
typ: "heimdall_oauth_state",
|
||||||
|
provider: options.provider,
|
||||||
|
app_slug: options.appSlug,
|
||||||
|
mode: options.mode,
|
||||||
|
return_to: options.returnTo,
|
||||||
|
connection: options.connection ?? null,
|
||||||
|
},
|
||||||
|
options.keys
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
|
||||||
|
const config = options.config ?? loadConfig();
|
||||||
|
const keys = createRuntimeKeyMaterial(config);
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
app.decorate("heimdallContext", { config, keys });
|
||||||
|
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/healthz", async () => ({
|
||||||
|
ok: true,
|
||||||
|
service: config.serviceName,
|
||||||
|
issuer: config.issuer,
|
||||||
|
now: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get("/.well-known/jwks.json", async () => ({
|
||||||
|
keys: [keys.publicJwk],
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get("/.well-known/heimdall-configuration", async () => buildDiscovery(config));
|
||||||
|
|
||||||
|
app.get("/v1/apps", async () => ({
|
||||||
|
apps: appSlugs.map((appSlug) => serializeAppProfile(getAppProfile(appSlug))),
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get<{ Params: { appSlug: AppSlug } }>(
|
||||||
|
"/v1/apps/:appSlug",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["appSlug"],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
appSlug: { type: "string", enum: [...appSlugs] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => serializeAppProfile(getAppProfile(request.params.appSlug))
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { provider: Provider }; Body: OAuthStartRequest }>(
|
||||||
|
"/v1/oauth/:provider/start",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["provider"],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
provider: { type: "string", enum: [...providers] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["appSlug", "mode", "returnTo"],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
appSlug: { type: "string", enum: [...appSlugs] },
|
||||||
|
mode: { type: "string", enum: [...oauthModes] },
|
||||||
|
returnTo: { type: "string", format: "uri" },
|
||||||
|
requestedScopes: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
uniqueItems: true,
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
type: "object",
|
||||||
|
required: ["kind", "targetId"],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
kind: { type: "string", enum: [...connectionKinds] },
|
||||||
|
targetId: { type: "string", minLength: 1 },
|
||||||
|
summary: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { provider } = request.params;
|
||||||
|
const profile = getAppProfile(request.body.appSlug);
|
||||||
|
|
||||||
|
if (!supportsProvider(profile, provider)) {
|
||||||
|
reply.code(400);
|
||||||
|
return {
|
||||||
|
error: "provider_not_supported_for_app",
|
||||||
|
provider,
|
||||||
|
appSlug: profile.slug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerConfig = config.providers[provider];
|
||||||
|
if (!providerConfig.clientId) {
|
||||||
|
reply.code(503);
|
||||||
|
return {
|
||||||
|
error: "provider_not_configured",
|
||||||
|
provider,
|
||||||
|
expectedEnv: providerExpectedEnv(provider),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
|
||||||
|
const state = buildOAuthStateToken({
|
||||||
|
config,
|
||||||
|
provider,
|
||||||
|
appSlug: profile.slug,
|
||||||
|
mode: request.body.mode,
|
||||||
|
returnTo: request.body.returnTo,
|
||||||
|
connection: request.body.connection,
|
||||||
|
keys,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
appSlug: profile.slug,
|
||||||
|
mode: request.body.mode,
|
||||||
|
callbackUrl,
|
||||||
|
authorizationUrl: buildAuthorizationUrl({
|
||||||
|
provider,
|
||||||
|
clientId: providerConfig.clientId,
|
||||||
|
redirectUri: callbackUrl,
|
||||||
|
state: state.token,
|
||||||
|
requestedScopes: request.body.requestedScopes,
|
||||||
|
}),
|
||||||
|
stateToken: state.token,
|
||||||
|
stateExpiresAt: state.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: { provider: Provider };
|
||||||
|
Querystring: { code?: string; state?: string; error?: string; error_description?: string };
|
||||||
|
}>(
|
||||||
|
"/v1/oauth/:provider/callback",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["provider"],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
provider: { type: "string", enum: [...providers] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
querystring: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
code: { type: "string" },
|
||||||
|
state: { type: "string" },
|
||||||
|
error: { type: "string" },
|
||||||
|
error_description: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!request.query.state) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "missing_state" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = verifyJwt(request.query.state, keys.publicKey);
|
||||||
|
if (!verification.valid) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "invalid_state", detail: verification.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const statePayload = verification.payload;
|
||||||
|
if (statePayload.typ !== "heimdall_oauth_state" || statePayload.provider !== request.params.provider) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "state_provider_mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.query.error) {
|
||||||
|
reply.code(400);
|
||||||
|
return {
|
||||||
|
error: "provider_error",
|
||||||
|
provider: request.params.provider,
|
||||||
|
providerError: request.query.error,
|
||||||
|
providerErrorDescription: request.query.error_description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.query.code) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "missing_code" };
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(501);
|
||||||
|
return {
|
||||||
|
error: "token_exchange_not_implemented",
|
||||||
|
provider: request.params.provider,
|
||||||
|
appSlug: statePayload.app_slug,
|
||||||
|
mode: statePayload.mode,
|
||||||
|
returnTo: statePayload.return_to,
|
||||||
|
connection: statePayload.connection,
|
||||||
|
codeReceived: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { appSlug: AppSlug }; Body: IssueClaimRequest }>(
|
||||||
|
"/v1/apps/:appSlug/claims/issue",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["appSlug"],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
appSlug: { type: "string", enum: [...appSlugs] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["accountId"],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
accountId: { type: "string", minLength: 1 },
|
||||||
|
sessionId: { type: "string", minLength: 1 },
|
||||||
|
displayName: { type: "string", minLength: 1 },
|
||||||
|
facts: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
uniqueItems: true,
|
||||||
|
},
|
||||||
|
accessRevision: { type: "integer", minimum: 1 },
|
||||||
|
ttlSeconds: { type: "integer", minimum: 60, maximum: 86400 },
|
||||||
|
linkedIdentities: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
required: ["provider", "providerUserId"],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
provider: { type: "string", enum: [...providers] },
|
||||||
|
providerUserId: { type: "string", minLength: 1 },
|
||||||
|
username: { type: "string" },
|
||||||
|
displayName: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const profile = getAppProfile(request.params.appSlug);
|
||||||
|
const facts = normalizeFacts(request.body);
|
||||||
|
const factSet = new Set(facts);
|
||||||
|
const linkedIdentities = request.body.linkedIdentities ?? [];
|
||||||
|
const sharedCapabilities = profile.evaluateSharedCapabilities({
|
||||||
|
accountId: request.body.accountId,
|
||||||
|
facts: factSet,
|
||||||
|
identities: linkedIdentities,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issuedAt = nowEpochSeconds();
|
||||||
|
const ttlSeconds = clampTtlSeconds(request.body.ttlSeconds ?? config.sessionTtlSeconds, config.sessionTtlSeconds);
|
||||||
|
const expiresAtEpoch = issuedAt + ttlSeconds;
|
||||||
|
const sessionId = request.body.sessionId ?? randomUUID();
|
||||||
|
const accessClaim: AccessClaimPayload = {
|
||||||
|
iss: config.issuer,
|
||||||
|
aud: profile.slug,
|
||||||
|
sub: request.body.accountId,
|
||||||
|
sid: sessionId,
|
||||||
|
jti: randomUUID(),
|
||||||
|
iat: issuedAt,
|
||||||
|
nbf: issuedAt,
|
||||||
|
exp: expiresAtEpoch,
|
||||||
|
typ: "heimdall_access",
|
||||||
|
account_id: request.body.accountId,
|
||||||
|
access_revision: request.body.accessRevision ?? 1,
|
||||||
|
app: {
|
||||||
|
slug: profile.slug,
|
||||||
|
profile_version: profile.profileVersion,
|
||||||
|
},
|
||||||
|
facts,
|
||||||
|
capabilities: sharedCapabilities,
|
||||||
|
identities: linkedIdentities,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.body.displayName) {
|
||||||
|
accessClaim.display_name = request.body.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
accountId: request.body.accountId,
|
||||||
|
sessionId,
|
||||||
|
appSlug: profile.slug,
|
||||||
|
accessRevision: accessClaim.access_revision,
|
||||||
|
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
accessToken: signJwt(accessClaim as unknown as Record<string, unknown>, keys),
|
||||||
|
claimSet: accessClaim,
|
||||||
|
verification: {
|
||||||
|
issuer: config.issuer,
|
||||||
|
jwksUri: `${config.publicBaseUrl}/.well-known/jwks.json`,
|
||||||
|
alg: keys.alg,
|
||||||
|
kid: keys.kid,
|
||||||
|
},
|
||||||
|
sharedCapabilities,
|
||||||
|
hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
81
src/config.ts
Normal file
81
src/config.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { providers, type Provider } from "./contracts.js";
|
||||||
|
|
||||||
|
export interface ProviderClientConfig {
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeimdallConfig {
|
||||||
|
serviceName: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
publicBaseUrl: string;
|
||||||
|
issuer: string;
|
||||||
|
sessionTtlSeconds: number;
|
||||||
|
stateTtlSeconds: number;
|
||||||
|
signingPrivateKeyPem?: string;
|
||||||
|
signingKeyId?: string;
|
||||||
|
providers: Record<Provider, ProviderClientConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingSlash(value: string): string {
|
||||||
|
return value.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInt(envValue: string | undefined, fallback: number): number {
|
||||||
|
if (!envValue) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(envValue, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`];
|
||||||
|
|
||||||
|
if (clientId) {
|
||||||
|
config.clientId = clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientSecret) {
|
||||||
|
config.clientSecret = clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig {
|
||||||
|
const host = env.HOST ?? "127.0.0.1";
|
||||||
|
const port = readInt(env.PORT, 4100);
|
||||||
|
const publicBaseUrl = trimTrailingSlash(env.GC_ACCESS_BASE_URL ?? `http://${host}:${port}`);
|
||||||
|
const issuer = trimTrailingSlash(env.GC_ACCESS_ISSUER ?? publicBaseUrl);
|
||||||
|
const providersConfig = Object.fromEntries(
|
||||||
|
providers.map((provider) => [provider, readProviderConfig(env, provider)])
|
||||||
|
) as Record<Provider, ProviderClientConfig>;
|
||||||
|
|
||||||
|
const config: HeimdallConfig = {
|
||||||
|
serviceName: "heimdall",
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
publicBaseUrl,
|
||||||
|
issuer,
|
||||||
|
sessionTtlSeconds: readInt(env.GC_ACCESS_SESSION_TTL_SECONDS, 3600),
|
||||||
|
stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600),
|
||||||
|
providers: providersConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signingPrivateKeyPem = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PEM?.replace(/\\n/g, "\n");
|
||||||
|
if (signingPrivateKeyPem) {
|
||||||
|
config.signingPrivateKeyPem = signingPrivateKeyPem;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.GC_ACCESS_SIGNING_KEY_ID) {
|
||||||
|
config.signingKeyId = env.GC_ACCESS_SIGNING_KEY_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
68
src/contracts.ts
Normal file
68
src/contracts.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
export const providers = ["discord", "patreon", "github", "twitch", "youtube"] as const;
|
||||||
|
|
||||||
|
export type Provider = (typeof providers)[number];
|
||||||
|
|
||||||
|
export const appSlugs = ["repixelizer", "streampixels"] as const;
|
||||||
|
|
||||||
|
export type AppSlug = (typeof appSlugs)[number];
|
||||||
|
|
||||||
|
export const oauthModes = ["sign_in", "link", "connect"] as const;
|
||||||
|
|
||||||
|
export type OAuthMode = (typeof oauthModes)[number];
|
||||||
|
|
||||||
|
export const connectionKinds = ["account", "creator", "workspace", "project", "channel"] as const;
|
||||||
|
|
||||||
|
export type ConnectionKind = (typeof connectionKinds)[number];
|
||||||
|
|
||||||
|
export interface OAuthConnectionBinding {
|
||||||
|
kind: ConnectionKind;
|
||||||
|
targetId: string;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthStartRequest {
|
||||||
|
appSlug: AppSlug;
|
||||||
|
mode: OAuthMode;
|
||||||
|
returnTo: string;
|
||||||
|
connection?: OAuthConnectionBinding;
|
||||||
|
requestedScopes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkedIdentityInput {
|
||||||
|
provider: Provider;
|
||||||
|
providerUserId: string;
|
||||||
|
username?: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueClaimRequest {
|
||||||
|
accountId: string;
|
||||||
|
sessionId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
facts?: string[];
|
||||||
|
linkedIdentities?: LinkedIdentityInput[];
|
||||||
|
accessRevision?: number;
|
||||||
|
ttlSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessClaimPayload {
|
||||||
|
iss: string;
|
||||||
|
aud: AppSlug;
|
||||||
|
sub: string;
|
||||||
|
sid: string;
|
||||||
|
jti: string;
|
||||||
|
iat: number;
|
||||||
|
nbf: number;
|
||||||
|
exp: number;
|
||||||
|
typ: "heimdall_access";
|
||||||
|
account_id: string;
|
||||||
|
access_revision: number;
|
||||||
|
display_name?: string;
|
||||||
|
app: {
|
||||||
|
slug: AppSlug;
|
||||||
|
profile_version: string;
|
||||||
|
};
|
||||||
|
facts: string[];
|
||||||
|
capabilities: string[];
|
||||||
|
identities: LinkedIdentityInput[];
|
||||||
|
}
|
||||||
13
src/index.ts
Normal file
13
src/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { buildApp } from "./app.js";
|
||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const app = await buildApp({ config });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ host: config.host, port: config.port });
|
||||||
|
app.log.info(`Heimdall listening on ${config.host}:${config.port}`);
|
||||||
|
} catch (error) {
|
||||||
|
app.log.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
101
src/providers.ts
Normal file
101
src/providers.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { type Provider } from "./contracts.js";
|
||||||
|
|
||||||
|
export type ProviderRole = "identity" | "entitlement" | "managed_credential";
|
||||||
|
|
||||||
|
export interface ProviderDescriptor {
|
||||||
|
key: Provider;
|
||||||
|
displayName: string;
|
||||||
|
authorizationEndpoint: string;
|
||||||
|
defaultScopes: string[];
|
||||||
|
roles: ProviderRole[];
|
||||||
|
clientIdEnv: string;
|
||||||
|
clientSecretEnv: string;
|
||||||
|
additionalAuthorizationParams?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
||||||
|
discord: {
|
||||||
|
key: "discord",
|
||||||
|
displayName: "Discord",
|
||||||
|
authorizationEndpoint: "https://discord.com/oauth2/authorize",
|
||||||
|
defaultScopes: ["identify", "guilds"],
|
||||||
|
roles: ["identity", "entitlement"],
|
||||||
|
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||||
|
clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET",
|
||||||
|
},
|
||||||
|
patreon: {
|
||||||
|
key: "patreon",
|
||||||
|
displayName: "Patreon",
|
||||||
|
authorizationEndpoint: "https://www.patreon.com/oauth2/authorize",
|
||||||
|
defaultScopes: ["identity", "identity[email]", "campaigns.members"],
|
||||||
|
roles: ["identity", "entitlement"],
|
||||||
|
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||||
|
clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET",
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
key: "github",
|
||||||
|
displayName: "GitHub",
|
||||||
|
authorizationEndpoint: "https://github.com/login/oauth/authorize",
|
||||||
|
defaultScopes: ["read:user", "user:email"],
|
||||||
|
roles: ["identity", "managed_credential"],
|
||||||
|
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
|
||||||
|
clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET",
|
||||||
|
},
|
||||||
|
twitch: {
|
||||||
|
key: "twitch",
|
||||||
|
displayName: "Twitch",
|
||||||
|
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
|
||||||
|
defaultScopes: ["user:read:email"],
|
||||||
|
roles: ["identity", "managed_credential"],
|
||||||
|
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
|
||||||
|
clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET",
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
key: "youtube",
|
||||||
|
displayName: "YouTube",
|
||||||
|
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
defaultScopes: [
|
||||||
|
"openid",
|
||||||
|
"email",
|
||||||
|
"profile",
|
||||||
|
"https://www.googleapis.com/auth/youtube.readonly",
|
||||||
|
],
|
||||||
|
roles: ["identity", "managed_credential"],
|
||||||
|
clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID",
|
||||||
|
clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET",
|
||||||
|
additionalAuthorizationParams: {
|
||||||
|
access_type: "offline",
|
||||||
|
include_granted_scopes: "true",
|
||||||
|
prompt: "consent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildAuthorizationUrl(options: {
|
||||||
|
provider: Provider;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
state: string;
|
||||||
|
requestedScopes: string[] | undefined;
|
||||||
|
}): string {
|
||||||
|
const descriptor = providerCatalog[options.provider];
|
||||||
|
const url = new URL(descriptor.authorizationEndpoint);
|
||||||
|
const scopes = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes;
|
||||||
|
|
||||||
|
url.searchParams.set("client_id", options.clientId);
|
||||||
|
url.searchParams.set("redirect_uri", options.redirectUri);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("scope", scopes.join(" "));
|
||||||
|
url.searchParams.set("state", options.state);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerExpectedEnv(provider: Provider): string[] {
|
||||||
|
const descriptor = providerCatalog[provider];
|
||||||
|
return [descriptor.clientIdEnv, descriptor.clientSecretEnv];
|
||||||
|
}
|
||||||
120
src/signing.ts
Normal file
120
src/signing.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
createPrivateKey,
|
||||||
|
createPublicKey,
|
||||||
|
generateKeyPairSync,
|
||||||
|
sign,
|
||||||
|
verify,
|
||||||
|
type KeyObject,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { type HeimdallConfig } from "./config.js";
|
||||||
|
|
||||||
|
type Jwk = {
|
||||||
|
crv?: string;
|
||||||
|
kty?: string;
|
||||||
|
x?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RuntimeKeyMaterial {
|
||||||
|
alg: "EdDSA";
|
||||||
|
kid: string;
|
||||||
|
privateKey: KeyObject;
|
||||||
|
publicKey: KeyObject;
|
||||||
|
publicJwk: Jwk & { alg: "EdDSA"; kid: string; use: "sig" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64Url(input: string | Buffer): string {
|
||||||
|
return Buffer.from(input).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Url(input: string): string {
|
||||||
|
return Buffer.from(input, "base64url").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKeyId(publicJwk: Jwk, explicitKeyId?: string): string {
|
||||||
|
if (explicitKeyId) {
|
||||||
|
return explicitKeyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbprint = createHash("sha256")
|
||||||
|
.update(JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x }))
|
||||||
|
.digest("hex");
|
||||||
|
return `ed25519-${thumbprint.slice(0, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuntimeKeyMaterial(config: HeimdallConfig): RuntimeKeyMaterial {
|
||||||
|
const privateKey = config.signingPrivateKeyPem
|
||||||
|
? createPrivateKey(config.signingPrivateKeyPem)
|
||||||
|
: generateKeyPairSync("ed25519").privateKey;
|
||||||
|
const publicKey = createPublicKey(privateKey);
|
||||||
|
const exportedJwk = publicKey.export({ format: "jwk" }) as Jwk;
|
||||||
|
const kid = buildKeyId(exportedJwk, config.signingKeyId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
alg: "EdDSA",
|
||||||
|
kid,
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
publicJwk: {
|
||||||
|
...exportedJwk,
|
||||||
|
alg: "EdDSA",
|
||||||
|
kid,
|
||||||
|
use: "sig",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signJwt(payload: Record<string, unknown>, keys: RuntimeKeyMaterial): string {
|
||||||
|
const header = {
|
||||||
|
alg: keys.alg,
|
||||||
|
kid: keys.kid,
|
||||||
|
typ: "JWT",
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedHeader = encodeBase64Url(JSON.stringify(header));
|
||||||
|
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
|
||||||
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
const signature = sign(null, Buffer.from(signingInput, "utf8"), keys.privateKey);
|
||||||
|
|
||||||
|
return `${signingInput}.${encodeBase64Url(signature)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeJwt(token: string): {
|
||||||
|
header: Record<string, unknown>;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const [encodedHeader, encodedPayload] = token.split(".");
|
||||||
|
if (!encodedHeader || !encodedPayload) {
|
||||||
|
throw new Error("Malformed JWT.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: JSON.parse(decodeBase64Url(encodedHeader)) as Record<string, unknown>,
|
||||||
|
payload: JSON.parse(decodeBase64Url(encodedPayload)) as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyJwt(
|
||||||
|
token: string,
|
||||||
|
publicKey: KeyObject
|
||||||
|
): { valid: true; header: Record<string, unknown>; payload: Record<string, unknown> } | {
|
||||||
|
valid: false;
|
||||||
|
error: string;
|
||||||
|
} {
|
||||||
|
const [encodedHeader, encodedPayload, encodedSignature] = token.split(".");
|
||||||
|
if (!encodedHeader || !encodedPayload || !encodedSignature) {
|
||||||
|
return { valid: false, error: "Malformed JWT." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
const signature = Buffer.from(encodedSignature, "base64url");
|
||||||
|
const valid = verify(null, Buffer.from(signingInput, "utf8"), publicKey, signature);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return { valid: false, error: "Invalid JWT signature." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { header, payload } = decodeJwt(token);
|
||||||
|
return { valid: true, header, payload };
|
||||||
|
}
|
||||||
|
|
@ -3,3 +3,4 @@
|
||||||
{"ts":"2026-04-26T00:00:00Z","type":"boundary","status":"accepted","note":"StreamPixels can reuse the same auth/control-plane backbone, and Heimdall may own OAuth/token custody for shared provider connections, but streamer-audience data, creator memberships, creator-side connection bindings, and runtime state remain StreamPixels-owned."}
|
{"ts":"2026-04-26T00:00:00Z","type":"boundary","status":"accepted","note":"StreamPixels can reuse the same auth/control-plane backbone, and Heimdall may own OAuth/token custody for shared provider connections, but streamer-audience data, creator memberships, creator-side connection bindings, and runtime state remain StreamPixels-owned."}
|
||||||
{"ts":"2026-04-26T06:00:00Z","type":"design","status":"accepted","note":"The cross-runtime split and current Yggdrasil topology changed the first implementation shape from an embedded shared package to Heimdall: a shared auth authority service whose signed claims are verified locally by each app."}
|
{"ts":"2026-04-26T06:00:00Z","type":"design","status":"accepted","note":"The cross-runtime split and current Yggdrasil topology changed the first implementation shape from an embedded shared package to Heimdall: a shared auth authority service whose signed claims are verified locally by each app."}
|
||||||
{"ts": "2026-04-26T11:00:02+00:00", "type": "design", "status": "accepted", "note": "Heimdall's provider model splits shared OAuth and credential custody from app-owned binding semantics and runtime use. Different provider mixes change adapter inventory and claim vocabulary, not the core service boundary."}
|
{"ts": "2026-04-26T11:00:02+00:00", "type": "design", "status": "accepted", "note": "Heimdall's provider model splits shared OAuth and credential custody from app-owned binding semantics and runtime use. Different provider mixes change adapter inventory and claim vocabulary, not the core service boundary."}
|
||||||
|
{"ts": "2026-04-26T11:24:39+00:00", "type": "design", "status": "accepted", "note": "The first Heimdall implementation pass landed as a standalone TypeScript/Fastify service skeleton with Ed25519 JWT signing, JWKS discovery, signed OAuth state, and app-profile-driven claim issuance for Repixelizer and StreamPixels."}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
project:
|
project:
|
||||||
name: Heimdall
|
name: Heimdall
|
||||||
status: active
|
status: active
|
||||||
phase: service_topology_alignment
|
phase: service_contract_skeleton
|
||||||
premise: >
|
premise: >
|
||||||
Build Heimdall as one reusable GameCult auth authority service for OAuth,
|
Build Heimdall as one reusable GameCult auth authority service for OAuth,
|
||||||
linked identities, local sessions, grants, and capability claims without
|
linked identities, local sessions, grants, and capability claims without
|
||||||
|
|
@ -59,6 +59,7 @@ active_subgoals:
|
||||||
|
|
||||||
accepted_design:
|
accepted_design:
|
||||||
- `docs/architecture.md` is the canonical shared architecture note.
|
- `docs/architecture.md` is the canonical shared architecture note.
|
||||||
|
- `docs/service-contract.md` records the concrete HTTP/JWKS contract of the landed skeleton.
|
||||||
- `docs/app-profiles/*.md` are thin app bindings, not duplicate brains.
|
- `docs/app-profiles/*.md` are thin app bindings, not duplicate brains.
|
||||||
- Heimdall owns provider OAuth, identity linking, signed local sessions, grants, entitlement refresh, capability evaluation, and audit events.
|
- Heimdall owns provider OAuth, identity linking, signed local sessions, grants, entitlement refresh, capability evaluation, and audit events.
|
||||||
- Host apps own their domain data, runtime state, and product-specific resources.
|
- Host apps own their domain data, runtime state, and product-specific resources.
|
||||||
|
|
@ -66,11 +67,13 @@ accepted_design:
|
||||||
- A shared Heimdall store, if it exists, should hold auth/control-plane truth only.
|
- A shared Heimdall store, if it exists, should hold auth/control-plane truth only.
|
||||||
- Repixelizer is the first auth-blank hosted-app binding.
|
- Repixelizer is the first auth-blank hosted-app binding.
|
||||||
- StreamPixels is the migration case that proves shared authority must not imply audience-data centralization.
|
- StreamPixels is the migration case that proves shared authority must not imply audience-data centralization.
|
||||||
|
- The first service skeleton is a standalone TypeScript/Fastify app with Ed25519 JWT signing, JWKS discovery, signed OAuth `state`, and app-profile-driven shared capability evaluation.
|
||||||
|
|
||||||
landed_baseline:
|
landed_baseline:
|
||||||
- Shared architecture extracted into its own repo and renamed to Heimdall.
|
- Shared architecture extracted into its own repo and renamed to Heimdall.
|
||||||
- Repixelizer and StreamPixels app-profile notes created.
|
- Repixelizer and StreamPixels app-profile notes created.
|
||||||
- Repo-local state, handoff, and compaction scaffolding created.
|
- Repo-local state, handoff, and compaction scaffolding created.
|
||||||
|
- Initial Heimdall service skeleton landed with discovery, JWKS, OAuth start/callback contract surfaces, and claim issuance scaffolding.
|
||||||
|
|
||||||
rejected_paths:
|
rejected_paths:
|
||||||
- name: per_app_auth_copy_paste
|
- name: per_app_auth_copy_paste
|
||||||
|
|
@ -81,5 +84,5 @@ rejected_paths:
|
||||||
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
|
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
|
||||||
|
|
||||||
current_status:
|
current_status:
|
||||||
summary: The shared hosted-access planning work now lives in Heimdall, a service-first auth authority repo with a Yggdrasil-hosted topology in mind. The key boundary is explicit: Heimdall owns OAuth, linking, grants, entitlement refresh, and signed claims; app-domain data remains app-owned; routine route authorization stays local after claim verification.
|
summary: Heimdall now has a first standalone service skeleton: TypeScript/Fastify runtime, Ed25519 JWT signing, JWKS/discovery exposure, signed OAuth start-state scaffolding, and app-profile-driven claim issuance for Repixelizer and StreamPixels. The key boundary still holds: Heimdall owns auth/control-plane authority and credential custody; app-domain bindings and runtime behavior remain app-owned.
|
||||||
next_action: Start the first implementation-oriented pass by defining Heimdall's service contract and skeleton: OAuth start/callback surfaces, signed session and claim payloads, claim verification material for app backends, and the first Repixelizer and StreamPixels integration seams.
|
next_action: Land durable persistence and the first real provider path on top of the skeleton: persisted signing key material, accounts/linked identities/grants, a real provider callback/token exchange slice, and the first end-to-end Repixelizer binding.
|
||||||
|
|
|
||||||
162
tests/app.test.ts
Normal file
162
tests/app.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { type FastifyInstance } from "fastify";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { type HeimdallConfig } from "../src/config.js";
|
||||||
|
import { decodeJwt, verifyJwt } from "../src/signing.js";
|
||||||
|
|
||||||
|
function createTestConfig(): HeimdallConfig {
|
||||||
|
return {
|
||||||
|
serviceName: "heimdall",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 4100,
|
||||||
|
publicBaseUrl: "https://heimdall.gamecult.org",
|
||||||
|
issuer: "https://heimdall.gamecult.org",
|
||||||
|
sessionTtlSeconds: 3600,
|
||||||
|
stateTtlSeconds: 600,
|
||||||
|
providers: {
|
||||||
|
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||||
|
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
|
||||||
|
github: { clientId: "github-client", clientSecret: "github-secret" },
|
||||||
|
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
||||||
|
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||||
|
|
||||||
|
function getPublicKey(app: FastifyInstance) {
|
||||||
|
return (
|
||||||
|
app as FastifyInstance & {
|
||||||
|
heimdallContext: {
|
||||||
|
keys: {
|
||||||
|
publicKey: Parameters<typeof verifyJwt>[1];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).heimdallContext.keys.publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (apps.length) {
|
||||||
|
await apps.pop()?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Heimdall service skeleton", () => {
|
||||||
|
it("serves discovery and JWKS metadata", async () => {
|
||||||
|
const app = await buildApp({ config: createTestConfig() });
|
||||||
|
apps.push(app);
|
||||||
|
|
||||||
|
const discoveryResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/.well-known/heimdall-configuration",
|
||||||
|
});
|
||||||
|
expect(discoveryResponse.statusCode).toBe(200);
|
||||||
|
expect(discoveryResponse.json()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
issuer: "https://heimdall.gamecult.org",
|
||||||
|
jwksUri: "https://heimdall.gamecult.org/.well-known/jwks.json",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const jwksResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/.well-known/jwks.json",
|
||||||
|
});
|
||||||
|
expect(jwksResponse.statusCode).toBe(200);
|
||||||
|
expect(jwksResponse.json()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
keys: [
|
||||||
|
expect.objectContaining({
|
||||||
|
alg: "EdDSA",
|
||||||
|
kid: expect.any(String),
|
||||||
|
use: "sig",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds signed OAuth start state for supported providers", async () => {
|
||||||
|
const app = await buildApp({ config: createTestConfig() });
|
||||||
|
apps.push(app);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/oauth/discord/start",
|
||||||
|
payload: {
|
||||||
|
appSlug: "repixelizer",
|
||||||
|
mode: "sign_in",
|
||||||
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
const payload = response.json();
|
||||||
|
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
||||||
|
|
||||||
|
const decoded = decodeJwt(payload.stateToken);
|
||||||
|
expect(decoded.payload).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
typ: "heimdall_oauth_state",
|
||||||
|
provider: "discord",
|
||||||
|
app_slug: "repixelizer",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues signed claims and evaluates shared app capabilities", async () => {
|
||||||
|
const app = await buildApp({ config: createTestConfig() });
|
||||||
|
apps.push(app);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/apps/repixelizer/claims/issue",
|
||||||
|
payload: {
|
||||||
|
accountId: "acct_repixelizer_001",
|
||||||
|
displayName: "Meta",
|
||||||
|
facts: ["discord.allowed_role", "grant.operator"],
|
||||||
|
linkedIdentities: [
|
||||||
|
{
|
||||||
|
provider: "discord",
|
||||||
|
providerUserId: "123456789",
|
||||||
|
username: "meta",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
const payload = response.json();
|
||||||
|
expect(payload.sharedCapabilities).toEqual(
|
||||||
|
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
|
||||||
|
);
|
||||||
|
|
||||||
|
const verified = verifyJwt(payload.accessToken, getPublicKey(app));
|
||||||
|
expect(verified.valid).toBe(true);
|
||||||
|
|
||||||
|
const tokenDecoded = decodeJwt(payload.accessToken);
|
||||||
|
expect(tokenDecoded.payload.capabilities).toEqual(
|
||||||
|
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => {
|
||||||
|
const app = await buildApp({ config: createTestConfig() });
|
||||||
|
apps.push(app);
|
||||||
|
|
||||||
|
const profileResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/apps/streampixels",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profileResponse.statusCode).toBe(200);
|
||||||
|
expect(profileResponse.json()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
slug: "streampixels",
|
||||||
|
managedConnectionProviders: ["twitch", "youtube"],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2024",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2024"],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue