Land first stateful Discord auth slice
This commit is contained in:
parent
702ac33f34
commit
cb0041343a
21 changed files with 2479 additions and 188 deletions
17
README.md
17
README.md
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
426
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
409
src/app.ts
409
src/app.ts
|
|
@ -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
174
src/claims.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
276
src/oauth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
210
src/store/in-memory.ts
Normal 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
21
src/store/index.ts
Normal 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
455
src/store/postgres.ts
Normal 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
87
src/store/schema.ts
Normal 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
137
src/store/types.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
113
tests/store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue