Land first stateful Discord auth slice

This commit is contained in:
Meta Vens 2026-04-26 12:50:07 +01:00
commit cb0041343a
21 changed files with 2479 additions and 188 deletions

View file

@ -1,6 +1,7 @@
# Heimdall
Heimdall is the planned shared auth authority for GameCult-hosted experiments.
Heimdall is the shared auth authority taking shape for GameCult-hosted
experiments.
The point is painfully simple:
@ -13,7 +14,7 @@ The point is painfully simple:
## Current Shape
This repo now has an initial standalone service skeleton.
This repo now has a stateful first slice instead of a decorative skeleton.
Landed right now:
@ -21,14 +22,20 @@ Landed right now:
- Ed25519 JWT signing plus `/.well-known/jwks.json`
- signed OAuth start-state scaffolding for Discord, Patreon, GitHub, Twitch,
and YouTube
- durable auth/control-plane storage with in-memory and Postgres backends
- account, linked-identity, session, grant, entitlement snapshot, and audit
persistence
- a real Discord OAuth callback path for the first Repixelizer access flow
- 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
- non-ephemeral signing key handling
- real token encryption at rest instead of "encrypted" field names doing
cosplay
- end-to-end app integrations in consumer repos
- refresh/revocation flows and admin surfaces
Canonical docs:

View file

@ -23,6 +23,10 @@ What exists right now is:
- Ed25519 JWT signing plus JWKS/discovery exposure
- OAuth start/callback contract surfaces with signed `state`
- app-profile-driven claim issuance for Repixelizer and StreamPixels
- in-memory/Postgres auth-control-plane storage
- persisted accounts, linked identities, sessions, grants, entitlement
snapshots, and audit records
- a real Discord provider callback slice for Repixelizer access
- explicit state/doctrine scaffolding so the plan does not immediately turn into
soup
@ -54,6 +58,7 @@ Goals:
- define OAuth start/callback/link surfaces
- define grant and entitlement persistence boundaries
- define how app backends verify claims locally
This is now the next missing seam, not a hypothetical one.
### 3. Make Repixelizer the first auth-blank consumer
@ -94,8 +99,8 @@ Landed in the first pass:
Still to do in this phase:
- durable grant / entitlement / audit record shapes
- persistence-backed account and linked-identity schema details
- production-safe signing key persistence
- token custody hardening instead of plain placeholder storage
### Phase 2: Heimdall service skeleton
@ -108,16 +113,16 @@ Landed in the first pass:
Next in this phase:
- account / linked-identity persistence
- provider token exchange and refresh
- audit logging
- provider refresh/revocation flows
- non-ephemeral signing key handling
- token encryption at rest
### Phase 3: Repixelizer binding
- integrate Heimdall into the hosted demo
- gate `POST /api/jobs`
- gate per-job read/cancel/event routes by local session ownership
- land a small verifier seam so Repixelizer can trust Heimdall JWTs locally
### Phase 4: StreamPixels authority migration

View file

@ -1,10 +1,10 @@
# Heimdall Service Contract
This file describes the first landed Heimdall service skeleton.
This file describes the first landed Heimdall service slice.
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.
claim that every provider, consumer binding, or production-hardening concern is
done yet.
## Current stack
@ -28,6 +28,7 @@ Response:
"ok": true,
"service": "heimdall",
"issuer": "https://heimdall.gamecult.org",
"storageBackend": "memory",
"now": "2026-04-26T12:00:00.000Z"
}
```
@ -157,11 +158,15 @@ 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.
- for Discord, exchanges the authorization code, resolves the provider
identity, persists the local account/link/session/audit records, evaluates
Repixelizer entitlement facts, and issues a signed Heimdall access claim
- for browser-style callers, redirects back to `returnTo` with a fragment
carrying status and the issued access token
- for non-browser callers, returns JSON with the issued claim, session, account
summary, token metadata, and entitlement result
- other configured providers still return "not implemented" at the runtime
adapter layer until their callback paths are added
### `POST /v1/apps/{appSlug}/claims/issue`
@ -254,19 +259,17 @@ Examples:
The following are not landed yet:
- provider token exchange on callback
- durable account/session/grant storage
- linked-identity persistence
- entitlement snapshot persistence
- non-ephemeral signing key handling
- actual token encryption at rest and secret-management policy
- refresh/revocation flows
- app-facing middleware packages or reference verifiers
- admin/grant management surfaces
## Next implementation move
Build durable storage plus the first real provider path on top of this
skeleton:
Build the first consumer seam and harden the custody story:
- persisted signing key material
- accounts and linked identities
- real callback exchange for one provider slice
- first end-to-end Repixelizer binding
- real token encryption at rest
- a reference verifier / middleware contract for app backends
- the first Repixelizer consumer binding that trusts Heimdall claims locally

View file

@ -27,7 +27,7 @@ Do not trust this file for the exact live HEAD. Always check git.
## Current Orientation
- this repo now has an initial standalone Heimdall service skeleton
- this repo now has a first stateful Heimdall service slice
- 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/`
@ -38,11 +38,15 @@ Do not trust this file for the exact live HEAD. Always check git.
- host apps verify signed claims locally for routine auth instead of calling
Heimdall on every request
- the landed skeleton already exposes:
- the landed service now exposes:
- `/.well-known/jwks.json`
- `/.well-known/heimdall-configuration`
- `/v1/oauth/{provider}/start`
- `/v1/oauth/{provider}/callback`
- `/v1/apps/{appSlug}/claims/issue`
- the Discord callback path now performs real code exchange, identity
resolution, auth persistence, entitlement evaluation, audit logging, and
claim issuance for the Repixelizer slice
- Repixelizer is the first auth-blank binding target
- StreamPixels is the migration target with useful existing auth seams that
should not be flattened into mush
@ -63,13 +67,14 @@ Do not trust this file for the exact live HEAD. Always check git.
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
real provider/storage slice on top of the landed skeleton:
If the user asks to continue, the current next move is to harden and consume
the landed slice:
- durable account / linked-identity / grant persistence
- persisted signing key material
- first real provider callback/token exchange path
- first end-to-end Repixelizer binding
- real token encryption at rest
- a reference verifier / middleware seam for app backends
- the first Repixelizer consumer integration that trusts Heimdall claims
locally
without dragging app-domain data into the shared layer and without forcing
per-request auth round-trips.

View file

@ -15,10 +15,13 @@
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"fastify": "^5.8.5"
"fastify": "^5.8.5",
"pg": "^8.20.0"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/pg": "^8.20.0",
"pg-mem": "^3.0.14",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"vitest": "^4.1.5"

426
pnpm-lock.yaml generated
View file

@ -14,10 +14,19 @@ importers:
fastify:
specifier: ^5.8.5
version: 5.8.5
pg:
specifier: ^8.20.0
version: 8.20.0
devDependencies:
'@types/node':
specifier: ^25.6.0
version: 25.6.0
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
pg-mem:
specifier: ^3.0.14
version: 3.0.14
tsx:
specifier: ^4.21.0
version: 4.21.0
@ -347,6 +356,9 @@ packages:
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@vitest/expect@4.1.5':
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
@ -401,10 +413,25 @@ packages:
avvio@9.2.0:
resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bind@1.0.9:
resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -412,6 +439,10 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@ -420,9 +451,28 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
discontinuous-range@1.0.0:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
@ -477,19 +527,61 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
functional-red-black-tree@1.0.1:
resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-property-descriptors@1.0.2:
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
hasown@2.0.3:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
immutable@4.3.8:
resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==}
ipaddr.js@2.3.0:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
json-schema-ref-resolver@3.0.0:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-stable-stringify@1.3.0:
resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==}
engines: {node: '>= 0.4'}
jsonify@0.0.1:
resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==}
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
@ -567,14 +659,40 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
moo@0.5.3:
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nearley@2.20.1:
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
hasBin: true
object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
@ -585,6 +703,78 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
pg-connection-string@2.12.0:
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-mem@3.0.14:
resolution: {integrity: sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==}
peerDependencies:
'@mikro-orm/core': '>=4.5.3'
'@mikro-orm/postgresql': '>=4.5.3'
knex: '>=0.20'
kysely: '>=0.26'
mikro-orm: '*'
pg-promise: '>=10.8.7'
pg-server: ^0.1.5
postgres: ^3.4.4
slonik: '>=23.0.1'
typeorm: '>=0.2.29'
peerDependenciesMeta:
'@mikro-orm/core':
optional: true
'@mikro-orm/postgresql':
optional: true
knex:
optional: true
kysely:
optional: true
mikro-orm:
optional: true
pg-promise:
optional: true
pg-server:
optional: true
postgres:
optional: true
slonik:
optional: true
typeorm:
optional: true
pg-pool@3.13.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.13.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.20.0:
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
pgsql-ast-parser@12.0.2:
resolution: {integrity: sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -606,6 +796,22 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
process-warning@4.0.1:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
@ -615,6 +821,13 @@ packages:
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
railroad-diagrams@1.0.0:
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
randexp@0.4.6:
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
engines: {node: '>=0.12'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
@ -626,6 +839,10 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
ret@0.1.15:
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
engines: {node: '>=0.12'}
ret@0.5.0:
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
engines: {node: '>=10'}
@ -661,6 +878,10 @@ packages:
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@ -809,6 +1030,13 @@ packages:
engines: {node: '>=8'}
hasBin: true
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
snapshots:
'@emnapi/core@1.10.0':
@ -1017,6 +1245,12 @@ snapshots:
dependencies:
undici-types: 7.19.2
'@types/pg@8.20.0':
dependencies:
'@types/node': 25.6.0
pg-protocol: 1.13.0
pg-types: 2.2.0
'@vitest/expect@4.1.5':
dependencies:
'@standard-schema/spec': 1.1.0
@ -1080,18 +1314,59 @@ snapshots:
'@fastify/error': 4.2.0
fastq: 1.20.1
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bind@1.0.9:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
get-intrinsic: 1.3.0
set-function-length: 1.2.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
chai@6.2.2: {}
commander@2.20.3: {}
convert-source-map@2.0.0: {}
cookie@1.1.1: {}
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
es-errors: 1.3.0
gopd: 1.2.0
dequal@2.0.3: {}
detect-libc@2.1.2: {}
discontinuous-range@1.0.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
@ -1183,18 +1458,66 @@ snapshots:
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
functional-red-black-tree@1.0.1: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.3
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
gopd@1.2.0: {}
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.1
has-symbols@1.1.0: {}
hasown@2.0.3:
dependencies:
function-bind: 1.1.2
immutable@4.3.8: {}
ipaddr.js@2.3.0: {}
isarray@2.0.5: {}
json-schema-ref-resolver@3.0.0:
dependencies:
dequal: 2.0.3
json-schema-traverse@1.0.0: {}
json-stable-stringify@1.3.0:
dependencies:
call-bind: 1.0.9
call-bound: 1.0.4
isarray: 2.0.5
jsonify: 0.0.1
object-keys: 1.1.1
jsonify@0.0.1: {}
light-my-request@6.6.0:
dependencies:
cookie: 1.1.1
@ -1250,18 +1573,89 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
moment@2.30.1: {}
moo@0.5.3: {}
nanoid@3.3.11: {}
nearley@2.20.1:
dependencies:
commander: 2.20.3
moo: 0.5.3
railroad-diagrams: 1.0.0
randexp: 0.4.6
object-hash@2.2.0: {}
object-keys@1.1.1: {}
obug@2.1.1: {}
on-exit-leak-free@2.1.2: {}
pathe@2.0.3: {}
pg-cloudflare@1.3.0:
optional: true
pg-connection-string@2.12.0: {}
pg-int8@1.0.1: {}
pg-mem@3.0.14:
dependencies:
functional-red-black-tree: 1.0.1
immutable: 4.3.8
json-stable-stringify: 1.3.0
lru-cache: 6.0.0
moment: 2.30.1
object-hash: 2.2.0
pgsql-ast-parser: 12.0.2
pg-pool@3.13.0(pg@8.20.0):
dependencies:
pg: 8.20.0
pg-protocol@1.13.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.20.0:
dependencies:
pg-connection-string: 2.12.0
pg-pool: 3.13.0(pg@8.20.0)
pg-protocol: 1.13.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.3.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
pgsql-ast-parser@12.0.2:
dependencies:
moo: 0.5.3
nearley: 2.20.1
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@ -1292,18 +1686,37 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
process-warning@4.0.1: {}
process-warning@5.0.0: {}
quick-format-unescaped@4.0.4: {}
railroad-diagrams@1.0.0: {}
randexp@0.4.6:
dependencies:
discontinuous-range: 1.0.0
ret: 0.1.15
real-require@0.2.0: {}
require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {}
ret@0.1.15: {}
ret@0.5.0: {}
reusify@1.1.0: {}
@ -1343,6 +1756,15 @@ snapshots:
set-cookie-parser@2.7.2: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
es-errors: 1.3.0
function-bind: 1.1.2
get-intrinsic: 1.3.0
gopd: 1.2.0
has-property-descriptors: 1.0.2
siginfo@2.0.0: {}
sonic-boom@4.2.1:
@ -1432,3 +1854,7 @@ snapshots:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
xtend@4.0.2: {}
yallist@4.0.0: {}

View file

@ -6,40 +6,26 @@ import {
connectionKinds,
oauthModes,
providers,
type AccessClaimPayload,
type AppSlug,
type IssueClaimRequest,
type OAuthMode,
type OAuthConnectionBinding,
type OAuthStatePayload,
type OAuthStartRequest,
type Provider,
} from "./contracts.js";
import { mapIssueClaimRequest, issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
import { getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
import { type HeimdallConfig, loadConfig } from "./config.js";
import { createOAuthRuntimeRegistry, type OAuthRuntimeRegistry } from "./oauth.js";
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
import { createStore, type HeimdallStore } from "./store/index.js";
import { type CreateAccountInput, type StoredCapabilityGrant, type UpsertLinkedIdentityInput } from "./store/types.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();
store?: HeimdallStore;
oauthRuntimes?: Partial<OAuthRuntimeRegistry>;
}
function buildDiscovery(config: HeimdallConfig) {
@ -59,36 +45,37 @@ function buildDiscovery(config: HeimdallConfig) {
};
}
function nowEpochSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function buildOAuthStateToken(options: {
config: HeimdallConfig;
provider: Provider;
appSlug: AppSlug;
mode: OAuthStartRequest["mode"];
mode: OAuthMode;
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
);
const payload: OAuthStatePayload = {
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,
};
const token = signJwt(payload as unknown as Record<string, unknown>, options.keys);
return {
token,
@ -96,11 +83,69 @@ function buildOAuthStateToken(options: {
};
}
function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePayload | null {
if (
payload.typ !== "heimdall_oauth_state" ||
typeof payload.provider !== "string" ||
!providers.includes(payload.provider as Provider) ||
typeof payload.app_slug !== "string" ||
!appSlugs.includes(payload.app_slug as AppSlug) ||
typeof payload.mode !== "string" ||
!oauthModes.includes(payload.mode as OAuthMode) ||
typeof payload.return_to !== "string" ||
typeof payload.iss !== "string" ||
typeof payload.sub !== "string" ||
typeof payload.aud !== "string" ||
typeof payload.jti !== "string" ||
typeof payload.iat !== "number" ||
typeof payload.nbf !== "number" ||
typeof payload.exp !== "number"
) {
return null;
}
return payload as unknown as OAuthStatePayload;
}
function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] {
return grants.map((grant) => `grant.${grant.capability}`);
}
function prefersHtml(acceptHeader: string | undefined): boolean {
if (!acceptHeader) {
return false;
}
return acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml");
}
function buildRedirectUrl(returnTo: string, params: Record<string, string | undefined>): string {
const url = new URL(returnTo);
const fragment = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value) {
fragment.set(key, value);
}
}
url.hash = fragment.toString();
return url.toString();
}
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
const config = options.config ?? loadConfig();
const store = options.store ?? (await createStore(config));
const keys = createRuntimeKeyMaterial(config);
const oauthRuntimes: OAuthRuntimeRegistry = {
...createOAuthRuntimeRegistry(),
...(options.oauthRuntimes ?? {}),
};
const app = Fastify({ logger: false });
app.decorate("heimdallContext", { config, keys });
app.decorate("heimdallContext", { config, keys, store, oauthRuntimes });
app.addHook("onClose", async () => {
await store.close();
});
await app.register(cors, {
origin: true,
@ -111,6 +156,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
ok: true,
service: config.serviceName,
issuer: config.issuer,
storageBackend: config.storage.backend,
now: new Date().toISOString(),
}));
@ -272,41 +318,227 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return { error: "invalid_state", detail: verification.error };
}
const statePayload = verification.payload;
if (statePayload.typ !== "heimdall_oauth_state" || statePayload.provider !== request.params.provider) {
const statePayload = parseOAuthStatePayload(verification.payload);
if (!statePayload || statePayload.provider !== request.params.provider) {
reply.code(400);
return { error: "state_provider_mismatch" };
}
if (request.query.error) {
const redirectUrl = buildRedirectUrl(statePayload.return_to, {
heimdall_status: "error",
heimdall_provider: request.params.provider,
heimdall_error: "provider_error",
heimdall_error_description: request.query.error_description ?? request.query.error,
});
if (prefersHtml(request.headers.accept)) {
return reply.redirect(redirectUrl);
}
reply.code(400);
return {
error: "provider_error",
provider: request.params.provider,
providerError: request.query.error,
providerErrorDescription: request.query.error_description,
redirectUrl,
};
}
if (!request.query.code) {
const redirectUrl = buildRedirectUrl(statePayload.return_to, {
heimdall_status: "error",
heimdall_provider: request.params.provider,
heimdall_error: "missing_code",
});
if (prefersHtml(request.headers.accept)) {
return reply.redirect(redirectUrl);
}
reply.code(400);
return { error: "missing_code" };
return { error: "missing_code", redirectUrl };
}
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,
};
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${request.params.provider}/callback`;
const runtime = oauthRuntimes[request.params.provider];
try {
const tokenSet = await runtime.exchangeAuthorizationCode({
config,
code: request.query.code,
redirectUri: callbackUrl,
});
const identity = await runtime.resolveIdentity({
accessToken: tokenSet.accessToken,
});
const nowIso = new Date().toISOString();
let account = await store.findAccountByLinkedIdentity(identity.provider, identity.providerUserId);
if (!account) {
const createAccountInput: CreateAccountInput = {
createdAt: nowIso,
lastSeenAt: nowIso,
};
const displayName = identity.displayName ?? identity.username;
if (displayName !== undefined) {
createAccountInput.displayName = displayName;
}
if (identity.primaryEmail !== undefined) {
createAccountInput.primaryEmail = identity.primaryEmail;
}
account = await store.createAccount(createAccountInput);
} else {
const accountUpdates: { displayName?: string; primaryEmail?: string } = {};
const displayName = identity.displayName ?? identity.username;
if (displayName !== undefined) {
accountUpdates.displayName = displayName;
}
if (identity.primaryEmail !== undefined) {
accountUpdates.primaryEmail = identity.primaryEmail;
}
await store.touchAccount(account.id, nowIso, accountUpdates);
}
const linkedIdentityInput: UpsertLinkedIdentityInput = {
accountId: account.id,
provider: identity.provider,
providerUserId: identity.providerUserId,
scopes: tokenSet.scope,
profileJson: identity.profile,
createdAt: nowIso,
updatedAt: nowIso,
};
if (identity.username !== undefined) {
linkedIdentityInput.username = identity.username;
}
if (identity.displayName !== undefined) {
linkedIdentityInput.displayName = identity.displayName;
}
if (identity.primaryEmail !== undefined) {
linkedIdentityInput.primaryEmail = identity.primaryEmail;
}
linkedIdentityInput.accessTokenEncrypted = tokenSet.accessToken;
if (tokenSet.refreshToken !== undefined) {
linkedIdentityInput.refreshTokenEncrypted = tokenSet.refreshToken;
}
if (tokenSet.expiresAt !== undefined) {
linkedIdentityInput.tokenExpiresAt = tokenSet.expiresAt;
}
await store.upsertLinkedIdentity(linkedIdentityInput);
const entitlementEvaluation = await runtime.evaluateEntitlements({
config,
callback: {
appSlug: statePayload.app_slug,
accountId: account.id,
connection: statePayload.connection,
},
identity,
tokenSet,
});
for (const snapshot of entitlementEvaluation.snapshots) {
await store.upsertEntitlementSnapshot(snapshot);
}
const grants = await store.listActiveGrants(account.id, statePayload.app_slug, nowIso);
const issueInput: IssueAccessClaimInput = {
appSlug: statePayload.app_slug,
accountId: account.id,
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
};
if (account.displayName !== undefined) {
issueInput.displayName = account.displayName;
}
const issued = await issueAccessClaim({
config,
keys,
store,
input: issueInput,
});
await store.createAuditEvent({
accountId: account.id,
sessionId: issued.session.sessionId,
appSlug: statePayload.app_slug,
eventType: "oauth_callback_succeeded",
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
returnTo: statePayload.return_to,
grantedFacts: issued.claimSet.facts,
sharedCapabilities: issued.sharedCapabilities,
},
createdAt: nowIso,
});
const redirectUrl = buildRedirectUrl(statePayload.return_to, {
heimdall_status: "success",
heimdall_provider: request.params.provider,
heimdall_app_slug: statePayload.app_slug,
heimdall_account_id: account.id,
heimdall_session_id: issued.session.sessionId,
heimdall_access_token: issued.accessToken,
});
if (prefersHtml(request.headers.accept)) {
return reply.redirect(redirectUrl);
}
reply.code(201);
return {
provider: request.params.provider,
mode: statePayload.mode,
account,
linkedIdentity: identity,
token: {
tokenType: tokenSet.tokenType,
scope: tokenSet.scope,
expiresAt: tokenSet.expiresAt,
},
entitlements: entitlementEvaluation,
redirectUrl,
returnTo: statePayload.return_to,
...issued,
};
} catch (error) {
const message = error instanceof Error ? error.message : "OAuth callback failed.";
const nowIso = new Date().toISOString();
await store.createAuditEvent({
appSlug: statePayload.app_slug,
eventType: "oauth_callback_failed",
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
error: message,
},
createdAt: nowIso,
});
const redirectUrl = buildRedirectUrl(statePayload.return_to, {
heimdall_status: "error",
heimdall_provider: request.params.provider,
heimdall_error: "oauth_callback_failed",
heimdall_error_description: message,
});
if (prefersHtml(request.headers.accept)) {
return reply.redirect(redirectUrl);
}
reply.code(502);
return {
error: "oauth_callback_failed",
detail: message,
provider: request.params.provider,
appSlug: statePayload.app_slug,
redirectUrl,
};
}
}
);
app.post<{ Params: { appSlug: AppSlug }; Body: IssueClaimRequest }>(
app.post<{ Params: { appSlug: AppSlug }; Body: import("./contracts.js").IssueClaimRequest }>(
"/v1/apps/:appSlug/claims/issue",
{
schema: {
@ -352,65 +584,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
},
},
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 issued = await issueAccessClaim({
config,
keys,
store,
input: mapIssueClaimRequest(request.params.appSlug, request.body),
});
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 issued;
}
);

174
src/claims.ts Normal file
View file

@ -0,0 +1,174 @@
import { randomUUID } from "node:crypto";
import {
type AccessClaimPayload,
type AppSlug,
type IssueClaimRequest,
type LinkedIdentityInput,
} from "./contracts.js";
import { getAppProfile } from "./app-profiles.js";
import { type HeimdallConfig } from "./config.js";
import { signJwt, type RuntimeKeyMaterial } from "./signing.js";
import { type CreateSessionInput, type HeimdallStore } from "./store/types.js";
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(accountId: string, linkedIdentities: LinkedIdentityInput[], facts: string[]): string[] {
const normalized = new Set(facts);
if (accountId.trim() || linkedIdentities.length) {
normalized.add("identity.authenticated");
}
return [...normalized].sort();
}
export interface IssueAccessClaimInput {
appSlug: AppSlug;
accountId: string;
displayName?: string;
linkedIdentities: LinkedIdentityInput[];
facts: string[];
sessionId?: string;
accessRevision?: number;
ttlSeconds?: number;
}
export interface IssuedAccessClaimResult {
session: {
accountId: string;
sessionId: string;
appSlug: AppSlug;
accessRevision: number;
expiresAt: string;
};
accessToken: string;
claimSet: AccessClaimPayload;
verification: {
issuer: string;
jwksUri: string;
alg: string;
kid: string;
};
sharedCapabilities: string[];
hybridCapabilities: ReturnType<typeof getAppProfile>["capabilities"];
}
export async function issueAccessClaim(options: {
config: HeimdallConfig;
keys: RuntimeKeyMaterial;
store: HeimdallStore;
input: IssueAccessClaimInput;
}): Promise<IssuedAccessClaimResult> {
const profile = getAppProfile(options.input.appSlug);
const facts = normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts);
const factSet = new Set(facts);
const sharedCapabilities = profile.evaluateSharedCapabilities({
accountId: options.input.accountId,
facts: factSet,
identities: options.input.linkedIdentities,
});
const issuedAt = nowEpochSeconds();
const ttlSeconds = clampTtlSeconds(
options.input.ttlSeconds ?? options.config.sessionTtlSeconds,
options.config.sessionTtlSeconds
);
const expiresAtEpoch = issuedAt + ttlSeconds;
const sessionId = options.input.sessionId ?? randomUUID();
const accessRevision = options.input.accessRevision ?? 1;
const claimSet: AccessClaimPayload = {
iss: options.config.issuer,
aud: profile.slug,
sub: options.input.accountId,
sid: sessionId,
jti: randomUUID(),
iat: issuedAt,
nbf: issuedAt,
exp: expiresAtEpoch,
typ: "heimdall_access",
account_id: options.input.accountId,
access_revision: accessRevision,
app: {
slug: profile.slug,
profile_version: profile.profileVersion,
},
facts,
capabilities: sharedCapabilities,
identities: options.input.linkedIdentities,
};
if (options.input.displayName) {
claimSet.display_name = options.input.displayName;
}
const accessToken = signJwt(claimSet as unknown as Record<string, unknown>, options.keys);
const sessionRecord: CreateSessionInput = {
id: sessionId,
accountId: options.input.accountId,
appSlug: profile.slug,
createdAt: new Date(issuedAt * 1000).toISOString(),
lastSeenAt: new Date(issuedAt * 1000).toISOString(),
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
accessRevision,
claimsJson: claimSet as unknown as Record<string, unknown>,
};
await options.store.createSession(sessionRecord);
return {
session: {
accountId: options.input.accountId,
sessionId,
appSlug: profile.slug,
accessRevision,
expiresAt: sessionRecord.expiresAt,
},
accessToken,
claimSet,
verification: {
issuer: options.config.issuer,
jwksUri: `${options.config.publicBaseUrl}/.well-known/jwks.json`,
alg: options.keys.alg,
kid: options.keys.kid,
},
sharedCapabilities,
hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
};
}
export function mapIssueClaimRequest(appSlug: AppSlug, request: IssueClaimRequest): IssueAccessClaimInput {
const input: IssueAccessClaimInput = {
appSlug,
accountId: request.accountId,
linkedIdentities: request.linkedIdentities ?? [],
facts: request.facts ?? [],
};
if (request.displayName !== undefined) {
input.displayName = request.displayName;
}
if (request.sessionId !== undefined) {
input.sessionId = request.sessionId;
}
if (request.accessRevision !== undefined) {
input.accessRevision = request.accessRevision;
}
if (request.ttlSeconds !== undefined) {
input.ttlSeconds = request.ttlSeconds;
}
return input;
}

View file

@ -5,6 +5,17 @@ export interface ProviderClientConfig {
clientSecret?: string;
}
export interface StorageConfig {
backend: "memory" | "postgres";
databaseUrl?: string;
applySchemaOnStartup: boolean;
}
export interface RepixelizerBindingConfig {
discordGuildId?: string;
discordAllowedRoleIds: string[];
}
export interface HeimdallConfig {
serviceName: string;
host: string;
@ -15,6 +26,10 @@ export interface HeimdallConfig {
stateTtlSeconds: number;
signingPrivateKeyPem?: string;
signingKeyId?: string;
storage: StorageConfig;
apps: {
repixelizer: RepixelizerBindingConfig;
};
providers: Record<Provider, ProviderClientConfig>;
}
@ -31,6 +46,34 @@ function readInt(envValue: string | undefined, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
function readBoolean(envValue: string | undefined, fallback: boolean): boolean {
if (!envValue) {
return fallback;
}
const normalized = envValue.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return fallback;
}
function readCsv(envValue: string | undefined): string[] {
if (!envValue) {
return [];
}
return envValue
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
function readProviderConfig(env: NodeJS.ProcessEnv, provider: Provider): ProviderClientConfig {
const prefix = `GC_ACCESS_PROVIDER_${provider.toUpperCase()}`;
const config: ProviderClientConfig = {};
@ -53,6 +96,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig
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 storageBackend =
env.GC_ACCESS_STORAGE_BACKEND === "postgres" || env.GC_ACCESS_DATABASE_URL ? "postgres" : "memory";
const providersConfig = Object.fromEntries(
providers.map((provider) => [provider, readProviderConfig(env, provider)])
) as Record<Provider, ProviderClientConfig>;
@ -65,6 +110,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig
issuer,
sessionTtlSeconds: readInt(env.GC_ACCESS_SESSION_TTL_SECONDS, 3600),
stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600),
storage: {
backend: storageBackend,
applySchemaOnStartup: readBoolean(env.GC_ACCESS_APPLY_SCHEMA_ON_STARTUP, true),
},
apps: {
repixelizer: {
discordAllowedRoleIds: readCsv(env.GC_ACCESS_APP_REPIXELIZER_DISCORD_ALLOWED_ROLE_IDS),
},
},
providers: providersConfig,
};
@ -77,5 +131,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig
config.signingKeyId = env.GC_ACCESS_SIGNING_KEY_ID;
}
if (env.GC_ACCESS_DATABASE_URL) {
config.storage.databaseUrl = env.GC_ACCESS_DATABASE_URL;
}
if (env.GC_ACCESS_APP_REPIXELIZER_DISCORD_GUILD_ID) {
config.apps.repixelizer.discordGuildId = env.GC_ACCESS_APP_REPIXELIZER_DISCORD_GUILD_ID;
}
return config;
}

View file

@ -28,6 +28,22 @@ export interface OAuthStartRequest {
requestedScopes?: string[];
}
export interface OAuthStatePayload {
iss: string;
sub: AppSlug;
aud: Provider;
jti: string;
iat: number;
nbf: number;
exp: number;
typ: "heimdall_oauth_state";
provider: Provider;
app_slug: AppSlug;
mode: OAuthMode;
return_to: string;
connection: OAuthConnectionBinding | null;
}
export interface LinkedIdentityInput {
provider: Provider;
providerUserId: string;

276
src/oauth.ts Normal file
View file

@ -0,0 +1,276 @@
import { Buffer } from "node:buffer";
import { type AppSlug, type OAuthConnectionBinding, type Provider } from "./contracts.js";
import { type HeimdallConfig } from "./config.js";
import { type CreateEntitlementSnapshotInput } from "./store/types.js";
export interface OAuthTokenSet {
accessToken: string;
refreshToken?: string;
tokenType: string;
scope: string[];
expiresAt?: string;
raw: Record<string, unknown>;
}
export interface ResolvedIdentity {
provider: Provider;
providerUserId: string;
username?: string;
displayName?: string;
primaryEmail?: string;
avatarUrl?: string;
profile: Record<string, unknown>;
}
export interface EntitlementEvaluation {
facts: string[];
snapshots: CreateEntitlementSnapshotInput[];
}
export interface OAuthCallbackContext {
appSlug: AppSlug;
accountId: string;
connection: OAuthConnectionBinding | null;
}
export interface OAuthProviderRuntime {
exchangeAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet>;
resolveIdentity(options: { accessToken: string }): Promise<ResolvedIdentity>;
evaluateEntitlements(options: {
config: HeimdallConfig;
callback: OAuthCallbackContext;
identity: ResolvedIdentity;
tokenSet: OAuthTokenSet;
}): Promise<EntitlementEvaluation>;
}
function buildErrorMessage(status: number, body: string, fallback: string): string {
return `${fallback} (status ${status}): ${body || "empty response"}`;
}
async function fetchJson<T>(input: string | URL, init: RequestInit, fallbackError: string): Promise<T> {
const response = await fetch(input, init);
const text = await response.text();
if (!response.ok) {
throw new Error(buildErrorMessage(response.status, text, fallbackError));
}
return (text ? JSON.parse(text) : {}) as T;
}
async function exchangeDiscordAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.discord;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Discord 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;
}>(
"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 tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: tokenResponse.scope.split(/\s+/).filter(Boolean),
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
return tokenSet;
}
async function resolveDiscordIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
const user = await fetchJson<{
id: string;
username?: string;
global_name?: string;
email?: string;
avatar?: string | null;
}>(
"https://discord.com/api/v10/users/@me",
{
headers: {
Authorization: `Bearer ${options.accessToken}`,
},
},
"Discord identity lookup failed"
);
const identity: ResolvedIdentity = {
provider: "discord",
providerUserId: user.id,
profile: user as unknown as Record<string, unknown>,
};
if (user.username) {
identity.username = user.username;
}
const displayName = user.global_name ?? user.username;
if (displayName) {
identity.displayName = displayName;
}
if (user.email) {
identity.primaryEmail = user.email;
}
if (user.avatar) {
identity.avatarUrl = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`;
}
return identity;
}
async function evaluateDiscordEntitlements(options: {
config: HeimdallConfig;
callback: OAuthCallbackContext;
tokenSet: OAuthTokenSet;
}): Promise<EntitlementEvaluation> {
if (options.callback.appSlug !== "repixelizer") {
return { facts: [], snapshots: [] };
}
const guildId = options.config.apps.repixelizer.discordGuildId;
const allowedRoleIds = options.config.apps.repixelizer.discordAllowedRoleIds;
if (!guildId) {
return { facts: [], snapshots: [] };
}
const response = await fetch(
`https://discord.com/api/v10/users/@me/guilds/${guildId}/member`,
{
headers: {
Authorization: `Bearer ${options.tokenSet.accessToken}`,
},
}
);
if (response.status === 404) {
return {
facts: [],
snapshots: [
{
accountId: options.callback.accountId,
provider: "discord",
scope: `repixelizer:discord_member:${guildId}`,
evaluatedAt: new Date().toISOString(),
isAllowed: false,
reasonCode: "not_in_guild",
reasonDetail: "Current user is not a member of the configured GameCult guild.",
rawSummaryJson: { guildId },
},
],
};
}
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 {
nick?: string;
roles?: string[];
joined_at?: string;
};
const roles = member.roles ?? [];
const matchedRoles = allowedRoleIds.filter((roleId) => roles.includes(roleId));
const facts = ["discord.in_guild"];
if (matchedRoles.length > 0) {
facts.push("discord.allowed_role");
}
return {
facts,
snapshots: [
{
accountId: options.callback.accountId,
provider: "discord",
scope: `repixelizer:discord_role_access:${guildId}`,
evaluatedAt: new Date().toISOString(),
isAllowed: matchedRoles.length > 0,
reasonCode: matchedRoles.length > 0 ? "matched_role" : "missing_role",
reasonDetail:
matchedRoles.length > 0
? `Matched Discord roles: ${matchedRoles.join(", ")}`
: allowedRoleIds.length
? "User is in the guild but lacks the configured Repixelizer access role."
: "No Repixelizer Discord role ids are configured.",
rawSummaryJson: {
guildId,
roles,
matchedRoles,
nick: member.nick,
joinedAt: member.joined_at,
},
},
],
};
}
const discordRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeDiscordAuthorizationCode,
resolveIdentity: resolveDiscordIdentity,
evaluateEntitlements: evaluateDiscordEntitlements,
};
const notImplementedRuntime: OAuthProviderRuntime = {
async exchangeAuthorizationCode() {
throw new Error("Provider callback exchange is not implemented yet.");
},
async resolveIdentity() {
throw new Error("Provider identity resolution is not implemented yet.");
},
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
export type OAuthRuntimeRegistry = Record<Provider, OAuthProviderRuntime>;
export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
return {
discord: discordRuntime,
patreon: notImplementedRuntime,
github: notImplementedRuntime,
twitch: notImplementedRuntime,
youtube: notImplementedRuntime,
};
}

View file

@ -18,7 +18,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "discord",
displayName: "Discord",
authorizationEndpoint: "https://discord.com/oauth2/authorize",
defaultScopes: ["identify", "guilds"],
defaultScopes: ["identify", "email", "guilds.members.read"],
roles: ["identity", "entitlement"],
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET",

210
src/store/in-memory.ts Normal file
View file

@ -0,0 +1,210 @@
import { randomUUID } from "node:crypto";
import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js";
import {
type CreateAccountInput,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CreateEntitlementSnapshotInput,
type CreateSessionInput,
type HeimdallStore,
type StoredAccount,
type StoredCapabilityGrant,
type StoredLinkedIdentity,
type StoredSession,
type UpsertLinkedIdentityInput,
} from "./types.js";
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function isGrantActive(grant: StoredCapabilityGrant, appSlug: AppSlug, at: string): boolean {
if (grant.status !== "active") {
return false;
}
if (grant.expiresAt && grant.expiresAt <= at) {
return false;
}
return grant.scopeType === "global" || grant.scopeId === appSlug;
}
export class InMemoryStore implements HeimdallStore {
private readonly accounts = new Map<string, StoredAccount>();
private readonly linkedIdentities = new Map<string, StoredLinkedIdentity>();
private readonly grants = new Map<string, StoredCapabilityGrant>();
private readonly sessions = new Map<string, StoredSession>();
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
async ensureSchema(): Promise<void> {
return;
}
async close(): Promise<void> {
return;
}
async createAccount(input: CreateAccountInput): Promise<StoredAccount> {
const account: StoredAccount = {
id: input.id ?? randomUUID(),
createdAt: input.createdAt,
lastSeenAt: input.lastSeenAt,
};
if (input.displayName !== undefined) {
account.displayName = input.displayName;
}
if (input.primaryEmail !== undefined) {
account.primaryEmail = input.primaryEmail;
}
this.accounts.set(account.id, clone(account));
return clone(account);
}
async touchAccount(accountId: string, at: string, updates?: { displayName?: string; primaryEmail?: string }): Promise<void> {
const account = this.accounts.get(accountId);
if (!account) {
throw new Error(`Account '${accountId}' was not found.`);
}
account.lastSeenAt = at;
if (updates?.displayName) {
account.displayName = updates.displayName;
}
if (updates?.primaryEmail) {
account.primaryEmail = updates.primaryEmail;
}
}
async findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise<StoredAccount | null> {
for (const linkedIdentity of this.linkedIdentities.values()) {
if (linkedIdentity.provider === provider && linkedIdentity.providerUserId === providerUserId) {
return clone(this.accounts.get(linkedIdentity.accountId) ?? null);
}
}
return null;
}
async upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity> {
const existing = [...this.linkedIdentities.values()].find(
(identity) => identity.provider === input.provider && identity.providerUserId === input.providerUserId
);
const id = existing?.id ?? input.id ?? randomUUID();
const linkedIdentity: StoredLinkedIdentity = {
id,
accountId: input.accountId,
provider: input.provider,
providerUserId: input.providerUserId,
scopes: [...input.scopes],
profileJson: clone(input.profileJson),
createdAt: existing?.createdAt ?? input.createdAt,
updatedAt: input.updatedAt,
};
if (input.username !== undefined) {
linkedIdentity.username = input.username;
}
if (input.displayName !== undefined) {
linkedIdentity.displayName = input.displayName;
}
if (input.primaryEmail !== undefined) {
linkedIdentity.primaryEmail = input.primaryEmail;
}
if (input.accessTokenEncrypted !== undefined) {
linkedIdentity.accessTokenEncrypted = input.accessTokenEncrypted;
}
if (input.refreshTokenEncrypted !== undefined) {
linkedIdentity.refreshTokenEncrypted = input.refreshTokenEncrypted;
}
if (input.tokenExpiresAt !== undefined) {
linkedIdentity.tokenExpiresAt = input.tokenExpiresAt;
}
this.linkedIdentities.set(id, clone(linkedIdentity));
return clone(linkedIdentity);
}
async listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]> {
return [...this.linkedIdentities.values()]
.filter((identity) => identity.accountId === accountId)
.map((identity) => {
const linkedIdentity: LinkedIdentityInput = {
provider: identity.provider,
providerUserId: identity.providerUserId,
};
if (identity.username !== undefined) {
linkedIdentity.username = identity.username;
}
if (identity.displayName !== undefined) {
linkedIdentity.displayName = identity.displayName;
}
return linkedIdentity;
});
}
async createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant> {
const grant: StoredCapabilityGrant = {
id: input.id ?? randomUUID(),
accountId: input.accountId,
scopeType: input.scopeType,
capability: input.capability,
source: input.source,
status: input.status,
createdAt: input.createdAt,
updatedAt: input.updatedAt,
};
if (input.scopeId !== undefined) {
grant.scopeId = input.scopeId;
}
if (input.expiresAt !== undefined) {
grant.expiresAt = input.expiresAt;
}
if (input.note !== undefined) {
grant.note = input.note;
}
this.grants.set(grant.id, clone(grant));
return clone(grant);
}
async listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]> {
return [...this.grants.values()]
.filter((grant) => grant.accountId === accountId && isGrantActive(grant, appSlug, at))
.map((grant) => clone(grant));
}
async createSession(input: CreateSessionInput): Promise<StoredSession> {
const session: StoredSession = clone(input);
this.sessions.set(session.id, session);
return clone(session);
}
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
const key = `${input.accountId}:${input.provider}:${input.scope}`;
this.entitlementSnapshots.set(key, clone(input));
}
async createAuditEvent(input: CreateAuditEventInput): Promise<void> {
const event: CreateAuditEventInput = clone({
...input,
id: input.id ?? randomUUID(),
});
this.auditEvents.set(event.id ?? randomUUID(), event);
}
}

21
src/store/index.ts Normal file
View file

@ -0,0 +1,21 @@
import { type HeimdallConfig } from "../config.js";
import { InMemoryStore } from "./in-memory.js";
import { createPostgresStore } from "./postgres.js";
import { type HeimdallStore } from "./types.js";
export * from "./types.js";
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.applySchemaOnStartup) {
await store.ensureSchema();
}
return store;
}

455
src/store/postgres.ts Normal file
View file

@ -0,0 +1,455 @@
import { randomUUID } from "node:crypto";
import { Pool, type PoolClient, type QueryResultRow } from "pg";
import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js";
import { CREATE_SCHEMA_SQL } from "./schema.js";
import {
type CreateAccountInput,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CreateEntitlementSnapshotInput,
type CreateSessionInput,
type HeimdallStore,
type StoredAccount,
type StoredCapabilityGrant,
type StoredLinkedIdentity,
type StoredSession,
type UpsertLinkedIdentityInput,
} from "./types.js";
interface AccountRow extends QueryResultRow {
id: string;
created_at: string;
last_seen_at: string;
display_name: string | null;
primary_email: string | null;
}
interface LinkedIdentityRow extends QueryResultRow {
id: string;
account_id: string;
provider: Provider;
provider_user_id: string;
username: string | null;
display_name: string | null;
primary_email: string | null;
access_token_encrypted: string | null;
refresh_token_encrypted: string | null;
token_expires_at: string | null;
scopes: string;
profile_json: Record<string, unknown>;
created_at: string;
updated_at: string;
}
interface GrantRow extends QueryResultRow {
id: string;
account_id: string;
scope_type: "global" | "app";
scope_id: AppSlug | null;
capability: string;
source: string;
status: "active" | "revoked";
expires_at: string | null;
note: string | null;
created_at: string;
updated_at: string;
}
interface SessionRow extends QueryResultRow {
id: string;
account_id: string;
app_slug: AppSlug;
created_at: string;
last_seen_at: string;
expires_at: string;
access_revision: number;
claims_json: Record<string, unknown>;
}
function expectRow<T>(row: T | undefined, label: string): T {
if (!row) {
throw new Error(`${label} query returned no rows.`);
}
return row;
}
function nullable<T>(value: T | undefined): T | null {
return value ?? null;
}
function parseScopes(value: string): string[] {
return value.split(" ").map((item) => item.trim()).filter(Boolean);
}
function mapAccountRow(row: AccountRow): StoredAccount {
const account: StoredAccount = {
id: row.id,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at,
};
if (row.display_name) {
account.displayName = row.display_name;
}
if (row.primary_email) {
account.primaryEmail = row.primary_email;
}
return account;
}
function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
const identity: StoredLinkedIdentity = {
id: row.id,
accountId: row.account_id,
provider: row.provider,
providerUserId: row.provider_user_id,
scopes: parseScopes(row.scopes),
profileJson: row.profile_json,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
if (row.username) {
identity.username = row.username;
}
if (row.display_name) {
identity.displayName = row.display_name;
}
if (row.primary_email) {
identity.primaryEmail = row.primary_email;
}
if (row.access_token_encrypted) {
identity.accessTokenEncrypted = row.access_token_encrypted;
}
if (row.refresh_token_encrypted) {
identity.refreshTokenEncrypted = row.refresh_token_encrypted;
}
if (row.token_expires_at) {
identity.tokenExpiresAt = row.token_expires_at;
}
return identity;
}
function mapGrantRow(row: GrantRow): StoredCapabilityGrant {
const grant: StoredCapabilityGrant = {
id: row.id,
accountId: row.account_id,
scopeType: row.scope_type,
capability: row.capability,
source: row.source,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
if (row.scope_id) {
grant.scopeId = row.scope_id;
}
if (row.expires_at) {
grant.expiresAt = row.expires_at;
}
if (row.note) {
grant.note = row.note;
}
return grant;
}
function mapSessionRow(row: SessionRow): StoredSession {
return {
id: row.id,
accountId: row.account_id,
appSlug: row.app_slug,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at,
expiresAt: row.expires_at,
accessRevision: row.access_revision,
claimsJson: row.claims_json,
};
}
export class PostgresStore implements HeimdallStore {
constructor(private readonly pool: Pick<Pool, "query" | "end">) {}
async ensureSchema(): Promise<void> {
await this.pool.query(CREATE_SCHEMA_SQL);
}
async close(): Promise<void> {
await this.pool.end();
}
async createAccount(input: CreateAccountInput): Promise<StoredAccount> {
const id = input.id ?? randomUUID();
const result = await this.pool.query<AccountRow>(
`
INSERT INTO accounts (id, created_at, last_seen_at, display_name, primary_email)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`,
[id, input.createdAt, input.lastSeenAt, nullable(input.displayName), nullable(input.primaryEmail)]
);
return mapAccountRow(expectRow(result.rows[0], "createAccount"));
}
async touchAccount(accountId: string, at: string, updates?: { displayName?: string; primaryEmail?: string }): Promise<void> {
await this.pool.query(
`
UPDATE accounts
SET last_seen_at = $2,
display_name = COALESCE($3, display_name),
primary_email = COALESCE($4, primary_email)
WHERE id = $1
`,
[accountId, at, nullable(updates?.displayName), nullable(updates?.primaryEmail)]
);
}
async findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise<StoredAccount | null> {
const result = await this.pool.query<AccountRow>(
`
SELECT accounts.*
FROM accounts
INNER JOIN linked_identities ON linked_identities.account_id = accounts.id
WHERE linked_identities.provider = $1
AND linked_identities.provider_user_id = $2
LIMIT 1
`,
[provider, providerUserId]
);
return result.rowCount ? mapAccountRow(expectRow(result.rows[0], "findAccountByLinkedIdentity")) : null;
}
async upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity> {
const id = input.id ?? randomUUID();
const result = await this.pool.query<LinkedIdentityRow>(
`
INSERT INTO linked_identities (
id,
account_id,
provider,
provider_user_id,
username,
display_name,
primary_email,
access_token_encrypted,
refresh_token_encrypted,
token_expires_at,
scopes,
profile_json,
created_at,
updated_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13, $14
)
ON CONFLICT (provider, provider_user_id)
DO UPDATE SET
account_id = EXCLUDED.account_id,
username = EXCLUDED.username,
display_name = EXCLUDED.display_name,
primary_email = EXCLUDED.primary_email,
access_token_encrypted = EXCLUDED.access_token_encrypted,
refresh_token_encrypted = EXCLUDED.refresh_token_encrypted,
token_expires_at = EXCLUDED.token_expires_at,
scopes = EXCLUDED.scopes,
profile_json = EXCLUDED.profile_json,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
id,
input.accountId,
input.provider,
input.providerUserId,
nullable(input.username),
nullable(input.displayName),
nullable(input.primaryEmail),
nullable(input.accessTokenEncrypted),
nullable(input.refreshTokenEncrypted),
nullable(input.tokenExpiresAt),
input.scopes.join(" "),
JSON.stringify(input.profileJson),
input.createdAt,
input.updatedAt,
]
);
return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity"));
}
async listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]> {
const result = await this.pool.query<LinkedIdentityRow>(
`
SELECT *
FROM linked_identities
WHERE account_id = $1
ORDER BY created_at ASC
`,
[accountId]
);
return result.rows.map((row) => {
const linkedIdentity: LinkedIdentityInput = {
provider: row.provider,
providerUserId: row.provider_user_id,
};
if (row.username) {
linkedIdentity.username = row.username;
}
if (row.display_name) {
linkedIdentity.displayName = row.display_name;
}
return linkedIdentity;
});
}
async createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant> {
const id = input.id ?? randomUUID();
const result = await this.pool.query<GrantRow>(
`
INSERT INTO capability_grants (
id, account_id, scope_type, scope_id, capability,
source, status, expires_at, note, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`,
[
id,
input.accountId,
input.scopeType,
nullable(input.scopeId),
input.capability,
input.source,
input.status,
nullable(input.expiresAt),
nullable(input.note),
input.createdAt,
input.updatedAt,
]
);
return mapGrantRow(expectRow(result.rows[0], "createCapabilityGrant"));
}
async listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]> {
const result = await this.pool.query<GrantRow>(
`
SELECT *
FROM capability_grants
WHERE account_id = $1
AND status = 'active'
AND (expires_at IS NULL OR expires_at > $3)
AND (
scope_type = 'global'
OR (scope_type = 'app' AND scope_id = $2)
)
ORDER BY created_at ASC
`,
[accountId, appSlug, at]
);
return result.rows.map(mapGrantRow);
}
async createSession(input: CreateSessionInput): Promise<StoredSession> {
const result = await this.pool.query<SessionRow>(
`
INSERT INTO sessions (
id, account_id, app_slug, created_at, last_seen_at,
expires_at, claims_json, access_revision
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`,
[
input.id,
input.accountId,
input.appSlug,
input.createdAt,
input.lastSeenAt,
input.expiresAt,
JSON.stringify(input.claimsJson),
input.accessRevision,
]
);
return mapSessionRow(expectRow(result.rows[0], "createSession"));
}
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
await this.pool.query(
`
INSERT INTO entitlement_snapshots (
id, account_id, provider, scope, evaluated_at,
is_allowed, reason_code, reason_detail, raw_summary_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (account_id, provider, scope)
DO UPDATE SET
evaluated_at = EXCLUDED.evaluated_at,
is_allowed = EXCLUDED.is_allowed,
reason_code = EXCLUDED.reason_code,
reason_detail = EXCLUDED.reason_detail,
raw_summary_json = EXCLUDED.raw_summary_json
`,
[
randomUUID(),
input.accountId,
input.provider,
input.scope,
input.evaluatedAt,
input.isAllowed,
input.reasonCode,
nullable(input.reasonDetail),
JSON.stringify(input.rawSummaryJson),
]
);
}
async createAuditEvent(input: CreateAuditEventInput): Promise<void> {
await this.pool.query(
`
INSERT INTO audit_events (
id, account_id, session_id, app_slug, event_type,
event_payload_json, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
[
input.id ?? randomUUID(),
nullable(input.accountId),
nullable(input.sessionId),
nullable(input.appSlug),
input.eventType,
JSON.stringify(input.eventPayloadJson),
input.createdAt,
]
);
}
}
export function createPostgresStore(databaseUrl: string): HeimdallStore {
const pool = new Pool({
connectionString: databaseUrl,
});
return new PostgresStore(pool);
}

87
src/store/schema.ts Normal file
View file

@ -0,0 +1,87 @@
export const CREATE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
display_name TEXT,
primary_email TEXT
);
CREATE TABLE IF NOT EXISTS linked_identities (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
username TEXT,
display_name TEXT,
primary_email TEXT,
access_token_encrypted TEXT,
refresh_token_encrypted TEXT,
token_expires_at TIMESTAMPTZ,
scopes TEXT NOT NULL DEFAULT '',
profile_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE(provider, provider_user_id)
);
CREATE INDEX IF NOT EXISTS linked_identities_account_id_idx
ON linked_identities(account_id);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
app_slug TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
claims_json JSONB NOT NULL,
access_revision INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_account_id_idx
ON sessions(account_id);
CREATE TABLE IF NOT EXISTS capability_grants (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
scope_type TEXT NOT NULL,
scope_id TEXT,
capability TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL,
expires_at TIMESTAMPTZ,
note TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS capability_grants_lookup_idx
ON capability_grants(account_id, scope_type, scope_id, status);
CREATE TABLE IF NOT EXISTS entitlement_snapshots (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
scope TEXT NOT NULL,
evaluated_at TIMESTAMPTZ NOT NULL,
is_allowed BOOLEAN NOT NULL,
reason_code TEXT NOT NULL,
reason_detail TEXT,
raw_summary_json JSONB NOT NULL,
UNIQUE(account_id, provider, scope)
);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
account_id TEXT,
session_id TEXT,
app_slug TEXT,
event_type TEXT NOT NULL,
event_payload_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS audit_events_lookup_idx
ON audit_events(account_id, session_id, app_slug, created_at);
`;

137
src/store/types.ts Normal file
View file

@ -0,0 +1,137 @@
import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js";
export interface StoredAccount {
id: string;
createdAt: string;
lastSeenAt: string;
displayName?: string;
primaryEmail?: string;
}
export interface CreateAccountInput {
id?: string;
createdAt: string;
lastSeenAt: string;
displayName?: string;
primaryEmail?: string;
}
export interface StoredLinkedIdentity {
id: string;
accountId: string;
provider: Provider;
providerUserId: string;
username?: string;
displayName?: string;
primaryEmail?: string;
accessTokenEncrypted?: string;
refreshTokenEncrypted?: string;
tokenExpiresAt?: string;
scopes: string[];
profileJson: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export interface UpsertLinkedIdentityInput {
id?: string;
accountId: string;
provider: Provider;
providerUserId: string;
username?: string;
displayName?: string;
primaryEmail?: string;
accessTokenEncrypted?: string;
refreshTokenEncrypted?: string;
tokenExpiresAt?: string;
scopes: string[];
profileJson: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export interface StoredSession {
id: string;
accountId: string;
appSlug: AppSlug;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
accessRevision: number;
claimsJson: Record<string, unknown>;
}
export interface CreateSessionInput {
id: string;
accountId: string;
appSlug: AppSlug;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
accessRevision: number;
claimsJson: Record<string, unknown>;
}
export interface StoredCapabilityGrant {
id: string;
accountId: string;
scopeType: "global" | "app";
scopeId?: AppSlug;
capability: string;
source: string;
status: "active" | "revoked";
expiresAt?: string;
note?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateCapabilityGrantInput {
id?: string;
accountId: string;
scopeType: "global" | "app";
scopeId?: AppSlug;
capability: string;
source: string;
status: "active" | "revoked";
expiresAt?: string;
note?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateEntitlementSnapshotInput {
accountId: string;
provider: Provider;
scope: string;
evaluatedAt: string;
isAllowed: boolean;
reasonCode: string;
reasonDetail?: string;
rawSummaryJson: Record<string, unknown>;
}
export interface CreateAuditEventInput {
id?: string;
accountId?: string;
sessionId?: string;
appSlug?: AppSlug;
eventType: string;
eventPayloadJson: Record<string, unknown>;
createdAt: string;
}
export interface HeimdallStore {
ensureSchema(): Promise<void>;
close(): Promise<void>;
createAccount(input: CreateAccountInput): Promise<StoredAccount>;
touchAccount(accountId: string, at: string, updates?: { displayName?: string; primaryEmail?: string }): Promise<void>;
findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise<StoredAccount | null>;
upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity>;
listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]>;
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;
listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>;
createSession(input: CreateSessionInput): Promise<StoredSession>;
upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void>;
createAuditEvent(input: CreateAuditEventInput): Promise<void>;
}

View file

@ -4,3 +4,4 @@
{"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: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."}
{"ts": "2026-04-26T11:49:41+00:00", "type": "implementation", "status": "accepted", "note": "The first stateful Heimdall slice landed durable auth-control-plane persistence plus a real Discord callback path for Repixelizer, with accounts, linked identities, sessions, grants, entitlement snapshots, and audit events stored behind in-memory/Postgres backends."}

View file

@ -1,7 +1,7 @@
project:
name: Heimdall
status: active
phase: service_contract_skeleton
phase: first_provider_persistence
premise: >
Build Heimdall as one reusable GameCult auth authority service for OAuth,
linked identities, local sessions, grants, and capability claims without
@ -12,8 +12,9 @@ objective:
Create a shared auth authority service that Repixelizer, StreamPixels, and
future GameCult-hosted experiments can consume.
current: >
Lock the Heimdall service boundary, Yggdrasil deployment shape, and app
integration rules before implementation starts breeding myths.
Use the new stateful provider slice to land the first consumer verifier
seam and harden key/token custody before app integrations start breeding
fresh sludge.
constraints:
- Heimdall owns auth/control-plane authority; app-domain data stays app-owned by default.
@ -59,7 +60,7 @@ active_subgoals:
accepted_design:
- `docs/architecture.md` is the canonical shared architecture note.
- `docs/service-contract.md` records the concrete HTTP/JWKS contract of the landed skeleton.
- `docs/service-contract.md` records the concrete HTTP/JWKS contract of the landed service slice.
- `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.
- Host apps own their domain data, runtime state, and product-specific resources.
@ -68,12 +69,15 @@ accepted_design:
- Repixelizer is the first auth-blank hosted-app binding.
- 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.
- The first stateful slice adds durable auth/control-plane storage plus a real Discord callback path for Repixelizer.
landed_baseline:
- Shared architecture extracted into its own repo and renamed to Heimdall.
- Repixelizer and StreamPixels app-profile notes 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.
- Durable auth persistence landed for accounts, linked identities, sessions, grants, entitlement snapshots, and audit events.
- The first real provider slice landed for Discord callback exchange, identity resolution, entitlement evaluation, and session/claim issuance for Repixelizer.
rejected_paths:
- name: per_app_auth_copy_paste
@ -84,5 +88,5 @@ rejected_paths:
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
current_status:
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: 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.
summary: Heimdall now has a stateful first provider slice: TypeScript/Fastify runtime, Ed25519 JWT signing, JWKS/discovery exposure, signed OAuth start-state handling, durable auth/control-plane persistence, and a real Discord callback path that resolves identity, evaluates Repixelizer entitlements, records audit state, and issues signed claims. 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: Land the first consumer seam and harden custody: persisted signing key material, real token encryption at rest, and a small verifier/integration contract so Repixelizer can trust Heimdall claims locally.

View file

@ -2,7 +2,8 @@ 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";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { verifyJwt } from "../src/signing.js";
function createTestConfig(): HeimdallConfig {
return {
@ -13,6 +14,16 @@ function createTestConfig(): HeimdallConfig {
issuer: "https://heimdall.gamecult.org",
sessionTtlSeconds: 3600,
stateTtlSeconds: 600,
storage: {
backend: "memory",
applySchemaOnStartup: true,
},
apps: {
repixelizer: {
discordGuildId: "gamecult-guild",
discordAllowedRoleIds: ["role-repixelizer"],
},
},
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
@ -23,6 +34,51 @@ function createTestConfig(): HeimdallConfig {
};
}
function createMockDiscordRuntime(): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return {
accessToken: "discord-access-token",
refreshToken: "discord-refresh-token",
tokenType: "Bearer",
scope: ["identify", "email", "guilds.members.read"],
expiresAt: "2026-04-26T13:00:00.000Z",
raw: { source: "test" },
};
},
async resolveIdentity() {
return {
provider: "discord",
providerUserId: "discord-user-123",
username: "meta",
displayName: "Meta",
primaryEmail: "meta@gamecult.org",
profile: { id: "discord-user-123", username: "meta" },
};
},
async evaluateEntitlements({ callback }) {
return {
facts: ["discord.in_guild", "discord.allowed_role"],
snapshots: [
{
accountId: callback.accountId,
provider: "discord",
scope: "repixelizer:discord_role_access:gamecult-guild",
evaluatedAt: "2026-04-26T12:00:00.000Z",
isAllowed: true,
reasonCode: "matched_role",
reasonDetail: "Matched Repixelizer access role.",
rawSummaryJson: {
guildId: "gamecult-guild",
matchedRoles: ["role-repixelizer"],
},
},
],
};
},
};
}
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
function getPublicKey(app: FastifyInstance) {
@ -37,13 +93,28 @@ function getPublicKey(app: FastifyInstance) {
).heimdallContext.keys.publicKey;
}
async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
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);
return response.json().stateToken as string;
}
afterEach(async () => {
while (apps.length) {
await apps.pop()?.close();
}
});
describe("Heimdall service skeleton", () => {
describe("Heimdall service", () => {
it("serves discovery and JWKS metadata", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
@ -96,50 +167,84 @@ describe("Heimdall service skeleton", () => {
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",
})
);
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.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() });
it("completes a provider callback and issues a verified Repixelizer claim", async () => {
const app = await buildApp({
config: createTestConfig(),
oauthRuntimes: {
discord: createMockDiscordRuntime(),
},
});
apps.push(app);
const stateToken = await startDiscordSignIn(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",
},
],
},
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.sharedCapabilities).toEqual(
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
expect(payload.sharedCapabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
expect(payload.account).toEqual(
expect.objectContaining({
displayName: "Meta",
primaryEmail: "meta@gamecult.org",
})
);
expect(payload.entitlements.facts).toEqual(expect.arrayContaining(["discord.allowed_role"]));
const verified = verifyJwt(payload.accessToken, getPublicKey(app));
const verified = verifyJwt(payload.accessToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
expect.objectContaining({
typ: "heimdall_access",
aud: "repixelizer",
account_id: payload.account.id,
})
);
expect(verified.payload.capabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
}
});
const tokenDecoded = decodeJwt(payload.accessToken);
expect(tokenDecoded.payload.capabilities).toEqual(
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
);
it("redirects browser callbacks back to the app with a token fragment", async () => {
const app = await buildApp({
config: createTestConfig(),
oauthRuntimes: {
discord: createMockDiscordRuntime(),
},
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
headers: {
accept: "text/html",
},
});
expect(response.statusCode).toBe(302);
const redirectUrl = new URL(response.headers.location ?? "");
expect(redirectUrl.origin + redirectUrl.pathname).toBe("https://repixelizer.gamecult.org/app/");
const fragment = new URLSearchParams(redirectUrl.hash.slice(1));
expect(fragment.get("heimdall_status")).toBe("success");
expect(fragment.get("heimdall_provider")).toBe("discord");
expect(fragment.get("heimdall_access_token")).toBeTruthy();
});
it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => {

113
tests/store.test.ts Normal file
View file

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { newDb } from "pg-mem";
import { PostgresStore } from "../src/store/postgres.js";
describe("PostgresStore", () => {
it("round-trips core auth records through postgres", async () => {
const db = newDb();
const adapter = db.adapters.createPg();
const pool = new adapter.Pool();
const store = new PostgresStore(pool);
await store.ensureSchema();
const now = "2026-04-26T12:00:00.000Z";
const account = await store.createAccount({
createdAt: now,
lastSeenAt: now,
displayName: "Meta",
primaryEmail: "meta@gamecult.org",
});
await store.upsertLinkedIdentity({
accountId: account.id,
provider: "discord",
providerUserId: "discord-user-123",
username: "meta",
displayName: "Meta",
primaryEmail: "meta@gamecult.org",
accessTokenEncrypted: "encrypted-access-token",
refreshTokenEncrypted: "encrypted-refresh-token",
tokenExpiresAt: "2026-04-26T13:00:00.000Z",
scopes: ["identify", "email"],
profileJson: { id: "discord-user-123", username: "meta" },
createdAt: now,
updatedAt: now,
});
const found = await store.findAccountByLinkedIdentity("discord", "discord-user-123");
expect(found).toEqual(
expect.objectContaining({
id: account.id,
displayName: "Meta",
primaryEmail: "meta@gamecult.org",
})
);
const linkedIdentities = await store.listLinkedIdentitiesForAccount(account.id);
expect(linkedIdentities).toEqual([
{
provider: "discord",
providerUserId: "discord-user-123",
username: "meta",
displayName: "Meta",
},
]);
await store.createCapabilityGrant({
accountId: account.id,
scopeType: "app",
scopeId: "repixelizer",
capability: "app_access",
source: "manual",
status: "active",
note: "Test grant",
createdAt: now,
updatedAt: now,
});
const grants = await store.listActiveGrants(account.id, "repixelizer", "2026-04-26T12:01:00.000Z");
expect(grants).toEqual([
expect.objectContaining({
capability: "app_access",
scopeType: "app",
scopeId: "repixelizer",
}),
]);
await store.createSession({
id: "session-123",
accountId: account.id,
appSlug: "repixelizer",
createdAt: now,
lastSeenAt: now,
expiresAt: "2026-04-26T13:00:00.000Z",
accessRevision: 1,
claimsJson: {
iss: "https://heimdall.gamecult.org",
aud: "repixelizer",
},
});
await store.upsertEntitlementSnapshot({
accountId: account.id,
provider: "discord",
scope: "repixelizer:discord_role_access:gamecult-guild",
evaluatedAt: now,
isAllowed: true,
reasonCode: "matched_role",
reasonDetail: "Matched test role",
rawSummaryJson: { matchedRoles: ["role-repixelizer"] },
});
await store.createAuditEvent({
accountId: account.id,
sessionId: "session-123",
appSlug: "repixelizer",
eventType: "oauth_callback_succeeded",
eventPayloadJson: { provider: "discord" },
createdAt: now,
});
await store.close();
});
});