From 58a12a2835dd9548d70aadf5790ef637d2ff3af6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 26 Apr 2026 12:25:11 +0100 Subject: [PATCH] Land Heimdall service skeleton --- .env.example | 19 + .gitignore | 10 +- README.md | 28 +- docs/architecture.md | 8 + docs/implementation-plan.md | 47 +- docs/service-contract.md | 272 ++++++ notes/fresh-workspace-handoff.md | 25 +- package.json | 26 + pnpm-lock.yaml | 1434 ++++++++++++++++++++++++++++++ src/app-profiles.ts | 172 ++++ src/app.ts | 418 +++++++++ src/config.ts | 81 ++ src/contracts.ts | 68 ++ src/index.ts | 13 + src/providers.ts | 101 +++ src/signing.ts | 120 +++ state/evidence.jsonl | 1 + state/map.yaml | 9 +- tests/app.test.ts | 162 ++++ tsconfig.json | 20 + 20 files changed, 3001 insertions(+), 33 deletions(-) create mode 100644 .env.example create mode 100644 docs/service-contract.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 src/app-profiles.ts create mode 100644 src/app.ts create mode 100644 src/config.ts create mode 100644 src/contracts.ts create mode 100644 src/index.ts create mode 100644 src/providers.ts create mode 100644 src/signing.ts create mode 100644 tests/app.test.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fbf1e68 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +HOST=127.0.0.1 +PORT=4100 +GC_ACCESS_BASE_URL=http://127.0.0.1:4100 +GC_ACCESS_ISSUER=http://127.0.0.1:4100 +GC_ACCESS_SESSION_TTL_SECONDS=3600 +GC_ACCESS_STATE_TTL_SECONDS=600 +GC_ACCESS_SIGNING_PRIVATE_KEY_PEM= +GC_ACCESS_SIGNING_KEY_ID= + +GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID= +GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET= +GC_ACCESS_PROVIDER_PATREON_CLIENT_ID= +GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET= +GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID= +GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET= +GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID= +GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET= +GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID= +GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET= diff --git a/.gitignore b/.gitignore index 77ac754..0b8e67b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ -.venv/ -__pycache__/ -*.pyc +node_modules/ +dist/ +coverage/ +.pnpm-store/ +.env +.env.* +!.env.example diff --git a/README.md b/README.md index b6bfaf8..cb5e1f4 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,42 @@ The point is painfully simple: ## Current Shape -This repo is still planning and service-contract work, not a landed auth -service yet. +This repo now has an initial standalone service skeleton. + +Landed right now: + +- a TypeScript/Fastify service scaffold under `src/` +- Ed25519 JWT signing plus `/.well-known/jwks.json` +- signed OAuth start-state scaffolding for Discord, Patreon, GitHub, Twitch, + and YouTube +- app profile surfaces for Repixelizer and StreamPixels +- signed claim issuance for app-local verification experiments + +Still not landed: + +- provider token exchange on callback +- durable account/session/grant storage +- end-to-end app integrations Canonical docs: - `docs/architecture.md` - `docs/implementation-plan.md` +- `docs/service-contract.md` - `docs/app-profiles/repixelizer.md` - `docs/app-profiles/streampixels.md` - `state/map.yaml` - `notes/fresh-workspace-handoff.md` +## Dev Commands + +```powershell +pnpm install +pnpm dev +pnpm test +pnpm build +``` + ## Working Loop 1. Rehydrate from canonical state. diff --git a/docs/architecture.md b/docs/architecture.md index 85c27a2..1ad2b09 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -420,6 +420,10 @@ Suggested generic env surface: - `GC_ACCESS_BASE_URL=https://heimdall.gamecult.org` - `GC_ACCESS_INTERNAL_URL=http://127.0.0.1:4100` - `GC_ACCESS_SESSION_SECRET=...` +- `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM=...` +- `GC_ACCESS_SIGNING_KEY_ID=...` +- `GC_ACCESS_SESSION_TTL_SECONDS=3600` +- `GC_ACCESS_STATE_TTL_SECONDS=600` - `GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=...` - `GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=...` - `GC_ACCESS_PROVIDER_DISCORD_BOT_TOKEN=...` @@ -438,6 +442,10 @@ Suggested generic env surface: Only configure the providers Heimdall actually owns for a given deployment. +If `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` is absent, the current skeleton falls +back to an ephemeral dev signing key. That is acceptable for local iteration +and absolutely not acceptable for real deployment. + Per-app binding should live in code or profile config, not in cloned provider env namespaces. App-local binding and runtime config such as creator associations, webhook subscriptions, sync toggles, or workspace/project diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 18ff84c..55f28cd 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -12,14 +12,19 @@ and the architecture note first, then fix this file. ## Current machine -There is no landed auth machine here yet. +There is now a first landed Heimdall skeleton. What exists right now is: - the shared architecture note +- the concrete service contract in `docs/service-contract.md` - app-binding notes for Repixelizer and StreamPixels -- explicit state/doctrine scaffolding so the plan does not immediately turn - into soup +- a standalone TypeScript/Fastify service skeleton under `src/` +- Ed25519 JWT signing plus JWKS/discovery exposure +- OAuth start/callback contract surfaces with signed `state` +- app-profile-driven claim issuance for Repixelizer and StreamPixels +- explicit state/doctrine scaffolding so the plan does not immediately turn into + soup ## Primary objective @@ -80,21 +85,33 @@ Goals: ### Phase 1: Contract and schema scaffold -- define: - - signed claim/session payloads - - JWKS or equivalent verification surface - - OAuth `state` and callback contracts - - grant / entitlement / audit record shapes - - app profile / capability rule inputs and outputs +Landed in the first pass: + +- signed claim/session payload shape +- Ed25519 JWKS verification surface +- OAuth `state` and callback contract surfaces +- app profile / capability rule inputs and outputs + +Still to do in this phase: + +- durable grant / entitlement / audit record shapes +- persistence-backed account and linked-identity schema details ### Phase 2: Heimdall service skeleton -- create the first real service layout for: - - provider OAuth start/callback routes - - account / linked-identity persistence - - session issuance and refresh - - capability evaluation - - audit logging +Landed in the first pass: + +- provider OAuth start route scaffolding +- callback route scaffolding with signed-state validation +- session/claim issuance contract +- capability evaluation for shared app capabilities + +Next in this phase: + +- account / linked-identity persistence +- provider token exchange and refresh +- audit logging +- non-ephemeral signing key handling ### Phase 3: Repixelizer binding diff --git a/docs/service-contract.md b/docs/service-contract.md new file mode 100644 index 0000000..9904a77 --- /dev/null +++ b/docs/service-contract.md @@ -0,0 +1,272 @@ +# Heimdall Service Contract + +This file describes the first landed Heimdall service skeleton. + +It is the concrete HTTP/JWKS contract that now exists in the repo. It is not a +claim that provider token exchange, persistence, or app bindings are fully +implemented yet. + +## Current stack + +- runtime: Node.js `24+` +- service framework: Fastify +- language: TypeScript +- signing: Ed25519 JWTs with JWKS exposure + +## Current routes + +### `GET /healthz` + +Purpose: + +- basic liveness probe + +Response: + +```json +{ + "ok": true, + "service": "heimdall", + "issuer": "https://heimdall.gamecult.org", + "now": "2026-04-26T12:00:00.000Z" +} +``` + +### `GET /.well-known/jwks.json` + +Purpose: + +- publish the public signing key that app backends use to verify Heimdall JWTs + +Notes: + +- current skeleton signs with Ed25519 +- if `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` is absent, the service generates an + ephemeral dev key at startup +- production should use a persisted private key and stable `kid` + +### `GET /.well-known/heimdall-configuration` + +Purpose: + +- publish discovery metadata for app backends and integration code + +Current shape includes: + +- `issuer` +- `jwksUri` +- `configurationUri` +- `oauthStartEndpoint` +- `oauthCallbackEndpoint` +- `claimIssueEndpoint` +- `supportedProviders` +- `apps` + +### `GET /v1/apps` + +Purpose: + +- list known app profiles and their capability surfaces + +### `GET /v1/apps/{appSlug}` + +Purpose: + +- return one app profile + +Current app slugs: + +- `repixelizer` +- `streampixels` + +Profile fields: + +- `slug` +- `displayName` +- `profileVersion` +- `identityProviders` +- `entitlementSources` +- `managedConnectionProviders` +- `capabilities` + +Capability modes: + +- `shared` + fully evaluated by Heimdall from signed facts/grants +- `hybrid` + requires app-local state after claim verification + +### `POST /v1/oauth/{provider}/start` + +Purpose: + +- begin a provider OAuth flow +- mint signed OAuth `state` +- return the provider authorization URL + +Supported providers in the skeleton: + +- `discord` +- `patreon` +- `github` +- `twitch` +- `youtube` + +Request body: + +```json +{ + "appSlug": "repixelizer", + "mode": "sign_in", + "returnTo": "https://repixelizer.gamecult.org/app/", + "connection": { + "kind": "creator", + "targetId": "creator:alpha", + "summary": "Optional app-local binding hint" + }, + "requestedScopes": ["identify", "guilds"] +} +``` + +Response: + +```json +{ + "provider": "discord", + "appSlug": "repixelizer", + "mode": "sign_in", + "callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback", + "authorizationUrl": "https://discord.com/oauth2/authorize?...", + "stateToken": "", + "stateExpiresAt": "2026-04-26T12:10:00.000Z" +} +``` + +Failure mode right now: + +- `503 provider_not_configured` when the provider client id is missing + +### `GET /v1/oauth/{provider}/callback` + +Purpose: + +- validate signed OAuth `state` +- serve as the landing surface for future code/token exchange + +Current status: + +- validates signed `state` +- returns provider errors cleanly +- returns `501 token_exchange_not_implemented` after successful state/code + validation + +That is deliberate. The contract is landed; provider token exchange and account +persistence are next. + +### `POST /v1/apps/{appSlug}/claims/issue` + +Purpose: + +- issue a signed Heimdall access claim for one app after identity/grant facts + have already been resolved + +This is currently the concrete claim contract and integration seam for early app +binding work. + +Request body: + +```json +{ + "accountId": "acct_repixelizer_001", + "displayName": "Meta", + "facts": ["discord.allowed_role", "grant.operator"], + "linkedIdentities": [ + { + "provider": "discord", + "providerUserId": "123456789", + "username": "meta" + } + ], + "accessRevision": 1, + "ttlSeconds": 3600 +} +``` + +Response: + +```json +{ + "session": { + "accountId": "acct_repixelizer_001", + "sessionId": "uuid", + "appSlug": "repixelizer", + "accessRevision": 1, + "expiresAt": "2026-04-26T13:00:00.000Z" + }, + "accessToken": "", + "claimSet": {}, + "verification": { + "issuer": "https://heimdall.gamecult.org", + "jwksUri": "https://heimdall.gamecult.org/.well-known/jwks.json", + "alg": "EdDSA", + "kid": "ed25519-..." + }, + "sharedCapabilities": ["app_access", "queue_submit", "admin_access"], + "hybridCapabilities": [] +} +``` + +## Access claim shape + +Current claim payload fields: + +- `iss` +- `aud` +- `sub` +- `sid` +- `jti` +- `iat` +- `nbf` +- `exp` +- `typ` +- `account_id` +- `access_revision` +- `display_name` optional +- `app.slug` +- `app.profile_version` +- `facts` +- `capabilities` +- `identities` + +Important current rule: + +- Heimdall only grants fully shared capabilities directly +- hybrid capabilities still require app-local checks after claim verification + +Examples: + +- Repixelizer `app_access` and `queue_submit` are shared +- Repixelizer `job_read_own` still needs local ownership +- StreamPixels `viewer_access` can be shared +- StreamPixels `creator_access` still needs app-local creator membership + +## Current gaps + +The following are not landed yet: + +- provider token exchange on callback +- durable account/session/grant storage +- linked-identity persistence +- entitlement snapshot persistence +- refresh/revocation flows +- app-facing middleware packages or reference verifiers + +## Next implementation move + +Build durable storage plus the first real provider path on top of this +skeleton: + +- persisted signing key material +- accounts and linked identities +- real callback exchange for one provider slice +- first end-to-end Repixelizer binding diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 317eb1e..b7214ca 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -27,16 +27,22 @@ Do not trust this file for the exact live HEAD. Always check git. ## Current Orientation -- this repo is still planning and service-contract work, not a landed auth - service yet +- this repo now has an initial standalone Heimdall service skeleton - 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/` - the key boundary is explicit: - - Heimdall owns OAuth, linking, grants, entitlement refresh, and signed claim - issuance + - Heimdall owns OAuth, linking, managed provider credential custody, grants, + entitlement refresh, and signed claim issuance - app-domain data stays app-owned by default - host apps verify signed claims locally for routine auth instead of calling Heimdall on every request +- the landed skeleton already exposes: + - `/.well-known/jwks.json` + - `/.well-known/heimdall-configuration` + - `/v1/oauth/{provider}/start` + - `/v1/oauth/{provider}/callback` + - `/v1/apps/{appSlug}/claims/issue` - Repixelizer is the first auth-blank binding target - StreamPixels is the migration target with useful existing auth seams that should not be flattened into mush @@ -58,13 +64,12 @@ Do not trust this file for the exact live HEAD. Always check git. Do not continue implementation automatically from a rehydrate-only request. If the user asks to continue, the current next move is to define the first -Heimdall service contract and skeleton: +real provider/storage slice on top of the landed skeleton: -- OAuth start/callback surfaces -- signed session / claim shape -- claim verification contract for app backends -- entitlement/grant persistence boundaries -- Repixelizer and StreamPixels integration seams +- durable account / linked-identity / grant persistence +- persisted signing key material +- first real provider callback/token exchange path +- first end-to-end Repixelizer binding without dragging app-domain data into the shared layer and without forcing per-request auth round-trips. diff --git a/package.json b/package.json new file mode 100644 index 0000000..bf5cc57 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "heimdall", + "private": true, + "type": "module", + "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=24.0.0" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@fastify/cors": "^11.2.0", + "fastify": "^5.8.5" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..0724627 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1434 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fastify/cors': + specifier: ^11.2.0 + version: 11.2.0 + fastify: + specifier: ^5.8.5 + version: 5.8.5 + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)) + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + 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==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.127.0': {} + + '@pinojs/redact@0.4.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.3.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.0: {} + + fastify-plugin@5.1.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + find-my-way@9.5.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + ipaddr.js@2.3.0: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + on-exit-leak-free@2.1.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.7.4: {} + + set-cookie-parser@2.7.2: {} + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + tinybench@2.9.0: {} + + tinyexec@1.1.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + toad-cache@3.7.0: {} + + tslib@2.8.1: + optional: true + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@6.0.3: {} + + undici-types@7.19.2: {} + + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.0 + esbuild: 0.27.7 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/src/app-profiles.ts b/src/app-profiles.ts new file mode 100644 index 0000000..ff75ef6 --- /dev/null +++ b/src/app-profiles.ts @@ -0,0 +1,172 @@ +import { type AppSlug, type LinkedIdentityInput, type Provider } from "./contracts.js"; + +export type CapabilityMode = "shared" | "hybrid"; + +export interface CapabilityDefinition { + key: string; + mode: CapabilityMode; + summary: string; + sharedRule?: string; + localRequirement?: string; +} + +export interface ClaimEvaluationContext { + accountId: string; + facts: Set; + identities: LinkedIdentityInput[]; +} + +export interface AppProfile { + slug: AppSlug; + displayName: string; + profileVersion: string; + identityProviders: Provider[]; + entitlementSources: Provider[]; + managedConnectionProviders: Provider[]; + capabilities: CapabilityDefinition[]; + evaluateSharedCapabilities(context: ClaimEvaluationContext): string[]; +} + +function hasAnyFact(facts: Set, values: readonly string[]): boolean { + return values.some((value) => facts.has(value)); +} + +const repixelizerProfile: AppProfile = { + slug: "repixelizer", + displayName: "Repixelizer", + profileVersion: "2026-04-26", + identityProviders: ["discord", "patreon"], + entitlementSources: ["discord", "patreon"], + managedConnectionProviders: [], + capabilities: [ + { + key: "app_access", + mode: "shared", + summary: "May load the protected hosted GUI.", + sharedRule: + "discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access", + }, + { + key: "queue_submit", + mode: "shared", + summary: "May create a repixelizer job.", + sharedRule: "app_access", + }, + { + key: "job_read_own", + mode: "hybrid", + summary: "May read own job state, event stream, and final output.", + localRequirement: "Host app must combine app_access with job ownership.", + }, + { + key: "job_cancel_own", + mode: "hybrid", + summary: "May cancel own queued or running job.", + localRequirement: "Host app must combine app_access with job ownership.", + }, + { + key: "admin_access", + mode: "shared", + summary: "May inspect grant/admin surfaces.", + sharedRule: "grant.operator || grant.admin_access", + }, + ], + evaluateSharedCapabilities(context) { + const capabilities: string[] = []; + const appAccess = hasAnyFact(context.facts, [ + "discord.allowed_role", + "patreon.allowed_tier", + "grant.global_member", + "grant.app_access", + ]); + + if (appAccess) { + capabilities.push("app_access", "queue_submit"); + } + + if (hasAnyFact(context.facts, ["grant.operator", "grant.admin_access"])) { + capabilities.push("admin_access"); + } + + return capabilities; + }, +}; + +const streampixelsProfile: AppProfile = { + slug: "streampixels", + displayName: "StreamPixels", + profileVersion: "2026-04-26", + identityProviders: ["twitch", "youtube"], + entitlementSources: [], + managedConnectionProviders: ["twitch", "youtube"], + capabilities: [ + { + key: "viewer_access", + mode: "shared", + summary: "Authenticated viewer session for control-plane surfaces.", + sharedRule: "identity.authenticated", + }, + { + key: "creator_access", + mode: "hybrid", + summary: "Creator-scoped route access.", + localRequirement: + "Host app must combine authenticated session claims with local creator membership.", + }, + { + key: "creator_admin", + mode: "hybrid", + summary: "Creator-scoped write/admin access.", + localRequirement: + "Host app must combine authenticated session claims with local creator admin membership.", + }, + { + key: "operator_access", + mode: "shared", + summary: "Global operator access.", + sharedRule: "grant.operator", + }, + ], + evaluateSharedCapabilities(context) { + const capabilities: string[] = []; + + if (context.facts.has("identity.authenticated")) { + capabilities.push("viewer_access"); + } + + if (context.facts.has("grant.operator")) { + capabilities.push("operator_access"); + } + + return capabilities; + }, +}; + +export const appProfiles: Record = { + repixelizer: repixelizerProfile, + streampixels: streampixelsProfile, +}; + +export function getAppProfile(appSlug: AppSlug): AppProfile { + return appProfiles[appSlug]; +} + +export function serializeAppProfile(profile: AppProfile): Record { + return { + slug: profile.slug, + displayName: profile.displayName, + profileVersion: profile.profileVersion, + identityProviders: profile.identityProviders, + entitlementSources: profile.entitlementSources, + managedConnectionProviders: profile.managedConnectionProviders, + capabilities: profile.capabilities, + }; +} + +export function supportsProvider(profile: AppProfile, provider: Provider): boolean { + return ( + profile.identityProviders.includes(provider) || + profile.managedConnectionProviders.includes(provider) || + profile.entitlementSources.includes(provider) + ); +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..aae127a --- /dev/null +++ b/src/app.ts @@ -0,0 +1,418 @@ +import { randomUUID } from "node:crypto"; +import cors from "@fastify/cors"; +import Fastify, { type FastifyInstance } from "fastify"; +import { + appSlugs, + connectionKinds, + oauthModes, + providers, + type AccessClaimPayload, + type AppSlug, + type IssueClaimRequest, + type OAuthConnectionBinding, + type OAuthStartRequest, + type Provider, +} from "./contracts.js"; +import { getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js"; +import { type HeimdallConfig, loadConfig } from "./config.js"; +import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; +import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; + +interface BuildAppOptions { + config?: HeimdallConfig; +} + +function nowEpochSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function clampTtlSeconds(value: number, fallback: number): number { + if (!Number.isFinite(value)) { + return fallback; + } + + return Math.max(60, Math.min(86_400, Math.trunc(value))); +} + +function normalizeFacts(request: IssueClaimRequest): string[] { + const facts = new Set(request.facts ?? []); + if (request.accountId.trim() || request.linkedIdentities?.length) { + facts.add("identity.authenticated"); + } + return [...facts].sort(); +} + +function buildDiscovery(config: HeimdallConfig) { + return { + issuer: config.issuer, + jwksUri: `${config.publicBaseUrl}/.well-known/jwks.json`, + configurationUri: `${config.publicBaseUrl}/.well-known/heimdall-configuration`, + oauthStartEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/start`, + oauthCallbackEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/callback`, + claimIssueEndpoint: `${config.publicBaseUrl}/v1/apps/{appSlug}/claims/issue`, + supportedProviders: providers.map((provider) => ({ + key: provider, + displayName: providerCatalog[provider].displayName, + roles: providerCatalog[provider].roles, + })), + apps: appSlugs.map((appSlug) => serializeAppProfile(getAppProfile(appSlug))), + }; +} + +function buildOAuthStateToken(options: { + config: HeimdallConfig; + provider: Provider; + appSlug: AppSlug; + mode: OAuthStartRequest["mode"]; + returnTo: string; + connection: OAuthConnectionBinding | undefined; + keys: ReturnType; +}): { token: string; expiresAt: string } { + const issuedAt = nowEpochSeconds(); + const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds; + + const token = signJwt( + { + iss: options.config.issuer, + sub: options.appSlug, + aud: options.provider, + jti: randomUUID(), + iat: issuedAt, + nbf: issuedAt, + exp: expiresAtEpoch, + typ: "heimdall_oauth_state", + provider: options.provider, + app_slug: options.appSlug, + mode: options.mode, + return_to: options.returnTo, + connection: options.connection ?? null, + }, + options.keys + ); + + return { + token, + expiresAt: new Date(expiresAtEpoch * 1000).toISOString(), + }; +} + +export async function buildApp(options: BuildAppOptions = {}): Promise { + const config = options.config ?? loadConfig(); + const keys = createRuntimeKeyMaterial(config); + const app = Fastify({ logger: false }); + app.decorate("heimdallContext", { config, keys }); + + await app.register(cors, { + origin: true, + credentials: true, + }); + + app.get("/healthz", async () => ({ + ok: true, + service: config.serviceName, + issuer: config.issuer, + now: new Date().toISOString(), + })); + + app.get("/.well-known/jwks.json", async () => ({ + keys: [keys.publicJwk], + })); + + app.get("/.well-known/heimdall-configuration", async () => buildDiscovery(config)); + + app.get("/v1/apps", async () => ({ + apps: appSlugs.map((appSlug) => serializeAppProfile(getAppProfile(appSlug))), + })); + + app.get<{ Params: { appSlug: AppSlug } }>( + "/v1/apps/:appSlug", + { + schema: { + params: { + type: "object", + required: ["appSlug"], + additionalProperties: false, + properties: { + appSlug: { type: "string", enum: [...appSlugs] }, + }, + }, + }, + }, + async (request) => serializeAppProfile(getAppProfile(request.params.appSlug)) + ); + + app.post<{ Params: { provider: Provider }; Body: OAuthStartRequest }>( + "/v1/oauth/:provider/start", + { + schema: { + params: { + type: "object", + required: ["provider"], + additionalProperties: false, + properties: { + provider: { type: "string", enum: [...providers] }, + }, + }, + body: { + type: "object", + required: ["appSlug", "mode", "returnTo"], + additionalProperties: false, + properties: { + appSlug: { type: "string", enum: [...appSlugs] }, + mode: { type: "string", enum: [...oauthModes] }, + returnTo: { type: "string", format: "uri" }, + requestedScopes: { + type: "array", + items: { type: "string" }, + uniqueItems: true, + }, + connection: { + type: "object", + required: ["kind", "targetId"], + additionalProperties: false, + properties: { + kind: { type: "string", enum: [...connectionKinds] }, + targetId: { type: "string", minLength: 1 }, + summary: { type: "string" }, + }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { provider } = request.params; + const profile = getAppProfile(request.body.appSlug); + + if (!supportsProvider(profile, provider)) { + reply.code(400); + return { + error: "provider_not_supported_for_app", + provider, + appSlug: profile.slug, + }; + } + + const providerConfig = config.providers[provider]; + if (!providerConfig.clientId) { + reply.code(503); + return { + error: "provider_not_configured", + provider, + expectedEnv: providerExpectedEnv(provider), + }; + } + + const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`; + const state = buildOAuthStateToken({ + config, + provider, + appSlug: profile.slug, + mode: request.body.mode, + returnTo: request.body.returnTo, + connection: request.body.connection, + keys, + }); + + reply.code(201); + return { + provider, + appSlug: profile.slug, + mode: request.body.mode, + callbackUrl, + authorizationUrl: buildAuthorizationUrl({ + provider, + clientId: providerConfig.clientId, + redirectUri: callbackUrl, + state: state.token, + requestedScopes: request.body.requestedScopes, + }), + stateToken: state.token, + stateExpiresAt: state.expiresAt, + }; + } + ); + + app.get<{ + Params: { provider: Provider }; + Querystring: { code?: string; state?: string; error?: string; error_description?: string }; + }>( + "/v1/oauth/:provider/callback", + { + schema: { + params: { + type: "object", + required: ["provider"], + additionalProperties: false, + properties: { + provider: { type: "string", enum: [...providers] }, + }, + }, + querystring: { + type: "object", + additionalProperties: false, + properties: { + code: { type: "string" }, + state: { type: "string" }, + error: { type: "string" }, + error_description: { type: "string" }, + }, + }, + }, + }, + async (request, reply) => { + if (!request.query.state) { + reply.code(400); + return { error: "missing_state" }; + } + + const verification = verifyJwt(request.query.state, keys.publicKey); + if (!verification.valid) { + reply.code(400); + return { error: "invalid_state", detail: verification.error }; + } + + const statePayload = verification.payload; + if (statePayload.typ !== "heimdall_oauth_state" || statePayload.provider !== request.params.provider) { + reply.code(400); + return { error: "state_provider_mismatch" }; + } + + if (request.query.error) { + reply.code(400); + return { + error: "provider_error", + provider: request.params.provider, + providerError: request.query.error, + providerErrorDescription: request.query.error_description, + }; + } + + if (!request.query.code) { + reply.code(400); + return { error: "missing_code" }; + } + + reply.code(501); + return { + error: "token_exchange_not_implemented", + provider: request.params.provider, + appSlug: statePayload.app_slug, + mode: statePayload.mode, + returnTo: statePayload.return_to, + connection: statePayload.connection, + codeReceived: true, + }; + } + ); + + app.post<{ Params: { appSlug: AppSlug }; Body: IssueClaimRequest }>( + "/v1/apps/:appSlug/claims/issue", + { + schema: { + params: { + type: "object", + required: ["appSlug"], + additionalProperties: false, + properties: { + appSlug: { type: "string", enum: [...appSlugs] }, + }, + }, + body: { + type: "object", + required: ["accountId"], + additionalProperties: false, + properties: { + accountId: { type: "string", minLength: 1 }, + sessionId: { type: "string", minLength: 1 }, + displayName: { type: "string", minLength: 1 }, + facts: { + type: "array", + items: { type: "string" }, + uniqueItems: true, + }, + accessRevision: { type: "integer", minimum: 1 }, + ttlSeconds: { type: "integer", minimum: 60, maximum: 86400 }, + linkedIdentities: { + type: "array", + items: { + type: "object", + required: ["provider", "providerUserId"], + additionalProperties: false, + properties: { + provider: { type: "string", enum: [...providers] }, + providerUserId: { type: "string", minLength: 1 }, + username: { type: "string" }, + displayName: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + async (request, reply) => { + const profile = getAppProfile(request.params.appSlug); + const facts = normalizeFacts(request.body); + const factSet = new Set(facts); + const linkedIdentities = request.body.linkedIdentities ?? []; + const sharedCapabilities = profile.evaluateSharedCapabilities({ + accountId: request.body.accountId, + facts: factSet, + identities: linkedIdentities, + }); + + const issuedAt = nowEpochSeconds(); + const ttlSeconds = clampTtlSeconds(request.body.ttlSeconds ?? config.sessionTtlSeconds, config.sessionTtlSeconds); + const expiresAtEpoch = issuedAt + ttlSeconds; + const sessionId = request.body.sessionId ?? randomUUID(); + const accessClaim: AccessClaimPayload = { + iss: config.issuer, + aud: profile.slug, + sub: request.body.accountId, + sid: sessionId, + jti: randomUUID(), + iat: issuedAt, + nbf: issuedAt, + exp: expiresAtEpoch, + typ: "heimdall_access", + account_id: request.body.accountId, + access_revision: request.body.accessRevision ?? 1, + app: { + slug: profile.slug, + profile_version: profile.profileVersion, + }, + facts, + capabilities: sharedCapabilities, + identities: linkedIdentities, + }; + + if (request.body.displayName) { + accessClaim.display_name = request.body.displayName; + } + + reply.code(201); + return { + session: { + accountId: request.body.accountId, + sessionId, + appSlug: profile.slug, + accessRevision: accessClaim.access_revision, + expiresAt: new Date(expiresAtEpoch * 1000).toISOString(), + }, + accessToken: signJwt(accessClaim as unknown as Record, keys), + claimSet: accessClaim, + verification: { + issuer: config.issuer, + jwksUri: `${config.publicBaseUrl}/.well-known/jwks.json`, + alg: keys.alg, + kid: keys.kid, + }, + sharedCapabilities, + hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"), + }; + } + ); + + return app; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6e011a0 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,81 @@ +import { providers, type Provider } from "./contracts.js"; + +export interface ProviderClientConfig { + clientId?: string; + clientSecret?: string; +} + +export interface HeimdallConfig { + serviceName: string; + host: string; + port: number; + publicBaseUrl: string; + issuer: string; + sessionTtlSeconds: number; + stateTtlSeconds: number; + signingPrivateKeyPem?: string; + signingKeyId?: string; + providers: Record; +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +function readInt(envValue: string | undefined, fallback: number): number { + if (!envValue) { + return fallback; + } + + const parsed = Number.parseInt(envValue, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function readProviderConfig(env: NodeJS.ProcessEnv, provider: Provider): ProviderClientConfig { + const prefix = `GC_ACCESS_PROVIDER_${provider.toUpperCase()}`; + const config: ProviderClientConfig = {}; + const clientId = env[`${prefix}_CLIENT_ID`]; + const clientSecret = env[`${prefix}_CLIENT_SECRET`]; + + if (clientId) { + config.clientId = clientId; + } + + if (clientSecret) { + config.clientSecret = clientSecret; + } + + return config; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig { + const host = env.HOST ?? "127.0.0.1"; + const port = readInt(env.PORT, 4100); + const publicBaseUrl = trimTrailingSlash(env.GC_ACCESS_BASE_URL ?? `http://${host}:${port}`); + const issuer = trimTrailingSlash(env.GC_ACCESS_ISSUER ?? publicBaseUrl); + const providersConfig = Object.fromEntries( + providers.map((provider) => [provider, readProviderConfig(env, provider)]) + ) as Record; + + const config: HeimdallConfig = { + serviceName: "heimdall", + host, + port, + publicBaseUrl, + issuer, + sessionTtlSeconds: readInt(env.GC_ACCESS_SESSION_TTL_SECONDS, 3600), + stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600), + providers: providersConfig, + }; + + const signingPrivateKeyPem = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PEM?.replace(/\\n/g, "\n"); + if (signingPrivateKeyPem) { + config.signingPrivateKeyPem = signingPrivateKeyPem; + } + + if (env.GC_ACCESS_SIGNING_KEY_ID) { + config.signingKeyId = env.GC_ACCESS_SIGNING_KEY_ID; + } + + return config; +} diff --git a/src/contracts.ts b/src/contracts.ts new file mode 100644 index 0000000..3652df1 --- /dev/null +++ b/src/contracts.ts @@ -0,0 +1,68 @@ +export const providers = ["discord", "patreon", "github", "twitch", "youtube"] as const; + +export type Provider = (typeof providers)[number]; + +export const appSlugs = ["repixelizer", "streampixels"] as const; + +export type AppSlug = (typeof appSlugs)[number]; + +export const oauthModes = ["sign_in", "link", "connect"] as const; + +export type OAuthMode = (typeof oauthModes)[number]; + +export const connectionKinds = ["account", "creator", "workspace", "project", "channel"] as const; + +export type ConnectionKind = (typeof connectionKinds)[number]; + +export interface OAuthConnectionBinding { + kind: ConnectionKind; + targetId: string; + summary?: string; +} + +export interface OAuthStartRequest { + appSlug: AppSlug; + mode: OAuthMode; + returnTo: string; + connection?: OAuthConnectionBinding; + requestedScopes?: string[]; +} + +export interface LinkedIdentityInput { + provider: Provider; + providerUserId: string; + username?: string; + displayName?: string; +} + +export interface IssueClaimRequest { + accountId: string; + sessionId?: string; + displayName?: string; + facts?: string[]; + linkedIdentities?: LinkedIdentityInput[]; + accessRevision?: number; + ttlSeconds?: number; +} + +export interface AccessClaimPayload { + iss: string; + aud: AppSlug; + sub: string; + sid: string; + jti: string; + iat: number; + nbf: number; + exp: number; + typ: "heimdall_access"; + account_id: string; + access_revision: number; + display_name?: string; + app: { + slug: AppSlug; + profile_version: string; + }; + facts: string[]; + capabilities: string[]; + identities: LinkedIdentityInput[]; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..eeb1f85 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +import { buildApp } from "./app.js"; +import { loadConfig } from "./config.js"; + +const config = loadConfig(); +const app = await buildApp({ config }); + +try { + await app.listen({ host: config.host, port: config.port }); + app.log.info(`Heimdall listening on ${config.host}:${config.port}`); +} catch (error) { + app.log.error(error); + process.exitCode = 1; +} diff --git a/src/providers.ts b/src/providers.ts new file mode 100644 index 0000000..8ebec69 --- /dev/null +++ b/src/providers.ts @@ -0,0 +1,101 @@ +import { type Provider } from "./contracts.js"; + +export type ProviderRole = "identity" | "entitlement" | "managed_credential"; + +export interface ProviderDescriptor { + key: Provider; + displayName: string; + authorizationEndpoint: string; + defaultScopes: string[]; + roles: ProviderRole[]; + clientIdEnv: string; + clientSecretEnv: string; + additionalAuthorizationParams?: Record; +} + +export const providerCatalog: Record = { + discord: { + key: "discord", + displayName: "Discord", + authorizationEndpoint: "https://discord.com/oauth2/authorize", + defaultScopes: ["identify", "guilds"], + roles: ["identity", "entitlement"], + clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET", + }, + patreon: { + key: "patreon", + displayName: "Patreon", + authorizationEndpoint: "https://www.patreon.com/oauth2/authorize", + defaultScopes: ["identity", "identity[email]", "campaigns.members"], + roles: ["identity", "entitlement"], + clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET", + }, + github: { + key: "github", + displayName: "GitHub", + authorizationEndpoint: "https://github.com/login/oauth/authorize", + defaultScopes: ["read:user", "user:email"], + roles: ["identity", "managed_credential"], + clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET", + }, + twitch: { + key: "twitch", + displayName: "Twitch", + authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize", + defaultScopes: ["user:read:email"], + roles: ["identity", "managed_credential"], + clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET", + }, + youtube: { + key: "youtube", + displayName: "YouTube", + authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + defaultScopes: [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/youtube.readonly", + ], + roles: ["identity", "managed_credential"], + clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET", + additionalAuthorizationParams: { + access_type: "offline", + include_granted_scopes: "true", + prompt: "consent", + }, + }, +}; + +export function buildAuthorizationUrl(options: { + provider: Provider; + clientId: string; + redirectUri: string; + state: string; + requestedScopes: string[] | undefined; +}): string { + const descriptor = providerCatalog[options.provider]; + const url = new URL(descriptor.authorizationEndpoint); + const scopes = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes; + + url.searchParams.set("client_id", options.clientId); + url.searchParams.set("redirect_uri", options.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("state", options.state); + + for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) { + url.searchParams.set(key, value); + } + + return url.toString(); +} + +export function providerExpectedEnv(provider: Provider): string[] { + const descriptor = providerCatalog[provider]; + return [descriptor.clientIdEnv, descriptor.clientSecretEnv]; +} diff --git a/src/signing.ts b/src/signing.ts new file mode 100644 index 0000000..c20944a --- /dev/null +++ b/src/signing.ts @@ -0,0 +1,120 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + generateKeyPairSync, + sign, + verify, + type KeyObject, +} from "node:crypto"; +import { type HeimdallConfig } from "./config.js"; + +type Jwk = { + crv?: string; + kty?: string; + x?: string; + [key: string]: unknown; +}; + +export interface RuntimeKeyMaterial { + alg: "EdDSA"; + kid: string; + privateKey: KeyObject; + publicKey: KeyObject; + publicJwk: Jwk & { alg: "EdDSA"; kid: string; use: "sig" }; +} + +function encodeBase64Url(input: string | Buffer): string { + return Buffer.from(input).toString("base64url"); +} + +function decodeBase64Url(input: string): string { + return Buffer.from(input, "base64url").toString("utf8"); +} + +function buildKeyId(publicJwk: Jwk, explicitKeyId?: string): string { + if (explicitKeyId) { + return explicitKeyId; + } + + const thumbprint = createHash("sha256") + .update(JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x })) + .digest("hex"); + return `ed25519-${thumbprint.slice(0, 12)}`; +} + +export function createRuntimeKeyMaterial(config: HeimdallConfig): RuntimeKeyMaterial { + const privateKey = config.signingPrivateKeyPem + ? createPrivateKey(config.signingPrivateKeyPem) + : generateKeyPairSync("ed25519").privateKey; + const publicKey = createPublicKey(privateKey); + const exportedJwk = publicKey.export({ format: "jwk" }) as Jwk; + const kid = buildKeyId(exportedJwk, config.signingKeyId); + + return { + alg: "EdDSA", + kid, + privateKey, + publicKey, + publicJwk: { + ...exportedJwk, + alg: "EdDSA", + kid, + use: "sig", + }, + }; +} + +export function signJwt(payload: Record, keys: RuntimeKeyMaterial): string { + const header = { + alg: keys.alg, + kid: keys.kid, + typ: "JWT", + }; + + const encodedHeader = encodeBase64Url(JSON.stringify(header)); + const encodedPayload = encodeBase64Url(JSON.stringify(payload)); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signature = sign(null, Buffer.from(signingInput, "utf8"), keys.privateKey); + + return `${signingInput}.${encodeBase64Url(signature)}`; +} + +export function decodeJwt(token: string): { + header: Record; + payload: Record; +} { + const [encodedHeader, encodedPayload] = token.split("."); + if (!encodedHeader || !encodedPayload) { + throw new Error("Malformed JWT."); + } + + return { + header: JSON.parse(decodeBase64Url(encodedHeader)) as Record, + payload: JSON.parse(decodeBase64Url(encodedPayload)) as Record, + }; +} + +export function verifyJwt( + token: string, + publicKey: KeyObject +): { valid: true; header: Record; payload: Record } | { + valid: false; + error: string; +} { + const [encodedHeader, encodedPayload, encodedSignature] = token.split("."); + if (!encodedHeader || !encodedPayload || !encodedSignature) { + return { valid: false, error: "Malformed JWT." }; + } + + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signature = Buffer.from(encodedSignature, "base64url"); + const valid = verify(null, Buffer.from(signingInput, "utf8"), publicKey, signature); + + if (!valid) { + return { valid: false, error: "Invalid JWT signature." }; + } + + const { header, payload } = decodeJwt(token); + return { valid: true, header, payload }; +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index f69d964..d929d57 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -3,3 +3,4 @@ {"ts":"2026-04-26T00:00:00Z","type":"boundary","status":"accepted","note":"StreamPixels can reuse the same auth/control-plane backbone, and Heimdall may own OAuth/token custody for shared provider connections, but streamer-audience data, creator memberships, creator-side connection bindings, and runtime state remain StreamPixels-owned."} {"ts":"2026-04-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."} diff --git a/state/map.yaml b/state/map.yaml index 0114be8..54514df 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -1,7 +1,7 @@ project: name: Heimdall status: active - phase: service_topology_alignment + phase: service_contract_skeleton premise: > Build Heimdall as one reusable GameCult auth authority service for OAuth, linked identities, local sessions, grants, and capability claims without @@ -59,6 +59,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/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. @@ -66,11 +67,13 @@ accepted_design: - A shared Heimdall store, if it exists, should hold auth/control-plane truth only. - 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. 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. rejected_paths: - name: per_app_auth_copy_paste @@ -81,5 +84,5 @@ rejected_paths: reason: reusing auth mechanics does not justify centralizing app-domain audience or product data current_status: - summary: The shared hosted-access planning work now lives in Heimdall, a service-first auth authority repo with a Yggdrasil-hosted topology in mind. The key boundary is explicit: Heimdall owns OAuth, linking, grants, entitlement refresh, and signed claims; app-domain data remains app-owned; routine route authorization stays local after claim verification. - next_action: Start the first implementation-oriented pass by defining Heimdall's service contract and skeleton: OAuth start/callback surfaces, signed session and claim payloads, claim verification material for app backends, and the first Repixelizer and StreamPixels integration seams. + 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. diff --git a/tests/app.test.ts b/tests/app.test.ts new file mode 100644 index 0000000..3d80fc5 --- /dev/null +++ b/tests/app.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { type FastifyInstance } from "fastify"; +import { buildApp } from "../src/app.js"; +import { type HeimdallConfig } from "../src/config.js"; +import { decodeJwt, verifyJwt } from "../src/signing.js"; + +function createTestConfig(): HeimdallConfig { + return { + serviceName: "heimdall", + host: "127.0.0.1", + port: 4100, + publicBaseUrl: "https://heimdall.gamecult.org", + issuer: "https://heimdall.gamecult.org", + sessionTtlSeconds: 3600, + stateTtlSeconds: 600, + providers: { + discord: { clientId: "discord-client", clientSecret: "discord-secret" }, + patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" }, + github: { clientId: "github-client", clientSecret: "github-secret" }, + twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" }, + youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" }, + }, + }; +} + +const apps: Awaited>[] = []; + +function getPublicKey(app: FastifyInstance) { + return ( + app as FastifyInstance & { + heimdallContext: { + keys: { + publicKey: Parameters[1]; + }; + }; + } + ).heimdallContext.keys.publicKey; +} + +afterEach(async () => { + while (apps.length) { + await apps.pop()?.close(); + } +}); + +describe("Heimdall service skeleton", () => { + it("serves discovery and JWKS metadata", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const discoveryResponse = await app.inject({ + method: "GET", + url: "/.well-known/heimdall-configuration", + }); + expect(discoveryResponse.statusCode).toBe(200); + expect(discoveryResponse.json()).toEqual( + expect.objectContaining({ + issuer: "https://heimdall.gamecult.org", + jwksUri: "https://heimdall.gamecult.org/.well-known/jwks.json", + }) + ); + + const jwksResponse = await app.inject({ + method: "GET", + url: "/.well-known/jwks.json", + }); + expect(jwksResponse.statusCode).toBe(200); + expect(jwksResponse.json()).toEqual( + expect.objectContaining({ + keys: [ + expect.objectContaining({ + alg: "EdDSA", + kid: expect.any(String), + use: "sig", + }), + ], + }) + ); + }); + + it("builds signed OAuth start state for supported providers", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/discord/start", + payload: { + appSlug: "repixelizer", + mode: "sign_in", + returnTo: "https://repixelizer.gamecult.org/app/", + }, + }); + + expect(response.statusCode).toBe(201); + const payload = response.json(); + expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); + + const decoded = decodeJwt(payload.stateToken); + expect(decoded.payload).toEqual( + expect.objectContaining({ + typ: "heimdall_oauth_state", + provider: "discord", + app_slug: "repixelizer", + }) + ); + }); + + it("issues signed claims and evaluates shared app capabilities", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/apps/repixelizer/claims/issue", + payload: { + accountId: "acct_repixelizer_001", + displayName: "Meta", + facts: ["discord.allowed_role", "grant.operator"], + linkedIdentities: [ + { + provider: "discord", + providerUserId: "123456789", + username: "meta", + }, + ], + }, + }); + + expect(response.statusCode).toBe(201); + const payload = response.json(); + expect(payload.sharedCapabilities).toEqual( + expect.arrayContaining(["app_access", "queue_submit", "admin_access"]) + ); + + const verified = verifyJwt(payload.accessToken, getPublicKey(app)); + expect(verified.valid).toBe(true); + + const tokenDecoded = decodeJwt(payload.accessToken); + expect(tokenDecoded.payload.capabilities).toEqual( + expect.arrayContaining(["app_access", "queue_submit", "admin_access"]) + ); + }); + + it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const profileResponse = await app.inject({ + method: "GET", + url: "/v1/apps/streampixels", + }); + + expect(profileResponse.statusCode).toBe(200); + expect(profileResponse.json()).toEqual( + expect.objectContaining({ + slug: "streampixels", + managedConnectionProviders: ["twitch", "youtube"], + }) + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..67a3932 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2024"], + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "rootDir": ".", + "outDir": "dist", + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +}