Land Heimdall service skeleton

This commit is contained in:
Meta Vens 2026-04-26 12:25:11 +01:00
commit 58a12a2835
20 changed files with 3001 additions and 33 deletions

19
.env.example Normal file
View file

@ -0,0 +1,19 @@
HOST=127.0.0.1
PORT=4100
GC_ACCESS_BASE_URL=http://127.0.0.1:4100
GC_ACCESS_ISSUER=http://127.0.0.1:4100
GC_ACCESS_SESSION_TTL_SECONDS=3600
GC_ACCESS_STATE_TTL_SECONDS=600
GC_ACCESS_SIGNING_PRIVATE_KEY_PEM=
GC_ACCESS_SIGNING_KEY_ID=
GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=
GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=
GC_ACCESS_PROVIDER_PATREON_CLIENT_ID=
GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET=
GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID=
GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET=
GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID=
GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET=
GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID=
GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET=

10
.gitignore vendored
View file

@ -1,3 +1,7 @@
.venv/ node_modules/
__pycache__/ dist/
*.pyc coverage/
.pnpm-store/
.env
.env.*
!.env.example

View file

@ -13,18 +13,42 @@ The point is painfully simple:
## Current Shape ## Current Shape
This repo is still planning and service-contract work, not a landed auth This repo now has an initial standalone service skeleton.
service yet.
Landed right now:
- a TypeScript/Fastify service scaffold under `src/`
- Ed25519 JWT signing plus `/.well-known/jwks.json`
- signed OAuth start-state scaffolding for Discord, Patreon, GitHub, Twitch,
and YouTube
- app profile surfaces for Repixelizer and StreamPixels
- signed claim issuance for app-local verification experiments
Still not landed:
- provider token exchange on callback
- durable account/session/grant storage
- end-to-end app integrations
Canonical docs: Canonical docs:
- `docs/architecture.md` - `docs/architecture.md`
- `docs/implementation-plan.md` - `docs/implementation-plan.md`
- `docs/service-contract.md`
- `docs/app-profiles/repixelizer.md` - `docs/app-profiles/repixelizer.md`
- `docs/app-profiles/streampixels.md` - `docs/app-profiles/streampixels.md`
- `state/map.yaml` - `state/map.yaml`
- `notes/fresh-workspace-handoff.md` - `notes/fresh-workspace-handoff.md`
## Dev Commands
```powershell
pnpm install
pnpm dev
pnpm test
pnpm build
```
## Working Loop ## Working Loop
1. Rehydrate from canonical state. 1. Rehydrate from canonical state.

View file

@ -420,6 +420,10 @@ Suggested generic env surface:
- `GC_ACCESS_BASE_URL=https://heimdall.gamecult.org` - `GC_ACCESS_BASE_URL=https://heimdall.gamecult.org`
- `GC_ACCESS_INTERNAL_URL=http://127.0.0.1:4100` - `GC_ACCESS_INTERNAL_URL=http://127.0.0.1:4100`
- `GC_ACCESS_SESSION_SECRET=...` - `GC_ACCESS_SESSION_SECRET=...`
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM=...`
- `GC_ACCESS_SIGNING_KEY_ID=...`
- `GC_ACCESS_SESSION_TTL_SECONDS=3600`
- `GC_ACCESS_STATE_TTL_SECONDS=600`
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=...` - `GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=...`
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=...` - `GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=...`
- `GC_ACCESS_PROVIDER_DISCORD_BOT_TOKEN=...` - `GC_ACCESS_PROVIDER_DISCORD_BOT_TOKEN=...`
@ -438,6 +442,10 @@ Suggested generic env surface:
Only configure the providers Heimdall actually owns for a given deployment. Only configure the providers Heimdall actually owns for a given deployment.
If `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` is absent, the current skeleton falls
back to an ephemeral dev signing key. That is acceptable for local iteration
and absolutely not acceptable for real deployment.
Per-app binding should live in code or profile config, not in cloned provider Per-app binding should live in code or profile config, not in cloned provider
env namespaces. App-local binding and runtime config such as creator env namespaces. App-local binding and runtime config such as creator
associations, webhook subscriptions, sync toggles, or workspace/project associations, webhook subscriptions, sync toggles, or workspace/project

View file

@ -12,14 +12,19 @@ and the architecture note first, then fix this file.
## Current machine ## Current machine
There is no landed auth machine here yet. There is now a first landed Heimdall skeleton.
What exists right now is: What exists right now is:
- the shared architecture note - the shared architecture note
- the concrete service contract in `docs/service-contract.md`
- app-binding notes for Repixelizer and StreamPixels - app-binding notes for Repixelizer and StreamPixels
- explicit state/doctrine scaffolding so the plan does not immediately turn - a standalone TypeScript/Fastify service skeleton under `src/`
into soup - Ed25519 JWT signing plus JWKS/discovery exposure
- OAuth start/callback contract surfaces with signed `state`
- app-profile-driven claim issuance for Repixelizer and StreamPixels
- explicit state/doctrine scaffolding so the plan does not immediately turn into
soup
## Primary objective ## Primary objective
@ -80,21 +85,33 @@ Goals:
### Phase 1: Contract and schema scaffold ### Phase 1: Contract and schema scaffold
- define: Landed in the first pass:
- signed claim/session payloads
- JWKS or equivalent verification surface - signed claim/session payload shape
- OAuth `state` and callback contracts - Ed25519 JWKS verification surface
- grant / entitlement / audit record shapes - OAuth `state` and callback contract surfaces
- app profile / capability rule inputs and outputs - app profile / capability rule inputs and outputs
Still to do in this phase:
- durable grant / entitlement / audit record shapes
- persistence-backed account and linked-identity schema details
### Phase 2: Heimdall service skeleton ### Phase 2: Heimdall service skeleton
- create the first real service layout for: Landed in the first pass:
- provider OAuth start/callback routes
- account / linked-identity persistence - provider OAuth start route scaffolding
- session issuance and refresh - callback route scaffolding with signed-state validation
- capability evaluation - session/claim issuance contract
- audit logging - capability evaluation for shared app capabilities
Next in this phase:
- account / linked-identity persistence
- provider token exchange and refresh
- audit logging
- non-ephemeral signing key handling
### Phase 3: Repixelizer binding ### Phase 3: Repixelizer binding

272
docs/service-contract.md Normal file
View file

@ -0,0 +1,272 @@
# Heimdall Service Contract
This file describes the first landed Heimdall service skeleton.
It is the concrete HTTP/JWKS contract that now exists in the repo. It is not a
claim that provider token exchange, persistence, or app bindings are fully
implemented yet.
## Current stack
- runtime: Node.js `24+`
- service framework: Fastify
- language: TypeScript
- signing: Ed25519 JWTs with JWKS exposure
## Current routes
### `GET /healthz`
Purpose:
- basic liveness probe
Response:
```json
{
"ok": true,
"service": "heimdall",
"issuer": "https://heimdall.gamecult.org",
"now": "2026-04-26T12:00:00.000Z"
}
```
### `GET /.well-known/jwks.json`
Purpose:
- publish the public signing key that app backends use to verify Heimdall JWTs
Notes:
- current skeleton signs with Ed25519
- if `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` is absent, the service generates an
ephemeral dev key at startup
- production should use a persisted private key and stable `kid`
### `GET /.well-known/heimdall-configuration`
Purpose:
- publish discovery metadata for app backends and integration code
Current shape includes:
- `issuer`
- `jwksUri`
- `configurationUri`
- `oauthStartEndpoint`
- `oauthCallbackEndpoint`
- `claimIssueEndpoint`
- `supportedProviders`
- `apps`
### `GET /v1/apps`
Purpose:
- list known app profiles and their capability surfaces
### `GET /v1/apps/{appSlug}`
Purpose:
- return one app profile
Current app slugs:
- `repixelizer`
- `streampixels`
Profile fields:
- `slug`
- `displayName`
- `profileVersion`
- `identityProviders`
- `entitlementSources`
- `managedConnectionProviders`
- `capabilities`
Capability modes:
- `shared`
fully evaluated by Heimdall from signed facts/grants
- `hybrid`
requires app-local state after claim verification
### `POST /v1/oauth/{provider}/start`
Purpose:
- begin a provider OAuth flow
- mint signed OAuth `state`
- return the provider authorization URL
Supported providers in the skeleton:
- `discord`
- `patreon`
- `github`
- `twitch`
- `youtube`
Request body:
```json
{
"appSlug": "repixelizer",
"mode": "sign_in",
"returnTo": "https://repixelizer.gamecult.org/app/",
"connection": {
"kind": "creator",
"targetId": "creator:alpha",
"summary": "Optional app-local binding hint"
},
"requestedScopes": ["identify", "guilds"]
}
```
Response:
```json
{
"provider": "discord",
"appSlug": "repixelizer",
"mode": "sign_in",
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
"stateToken": "<signed-jwt>",
"stateExpiresAt": "2026-04-26T12:10:00.000Z"
}
```
Failure mode right now:
- `503 provider_not_configured` when the provider client id is missing
### `GET /v1/oauth/{provider}/callback`
Purpose:
- validate signed OAuth `state`
- serve as the landing surface for future code/token exchange
Current status:
- validates signed `state`
- returns provider errors cleanly
- returns `501 token_exchange_not_implemented` after successful state/code
validation
That is deliberate. The contract is landed; provider token exchange and account
persistence are next.
### `POST /v1/apps/{appSlug}/claims/issue`
Purpose:
- issue a signed Heimdall access claim for one app after identity/grant facts
have already been resolved
This is currently the concrete claim contract and integration seam for early app
binding work.
Request body:
```json
{
"accountId": "acct_repixelizer_001",
"displayName": "Meta",
"facts": ["discord.allowed_role", "grant.operator"],
"linkedIdentities": [
{
"provider": "discord",
"providerUserId": "123456789",
"username": "meta"
}
],
"accessRevision": 1,
"ttlSeconds": 3600
}
```
Response:
```json
{
"session": {
"accountId": "acct_repixelizer_001",
"sessionId": "uuid",
"appSlug": "repixelizer",
"accessRevision": 1,
"expiresAt": "2026-04-26T13:00:00.000Z"
},
"accessToken": "<signed-jwt>",
"claimSet": {},
"verification": {
"issuer": "https://heimdall.gamecult.org",
"jwksUri": "https://heimdall.gamecult.org/.well-known/jwks.json",
"alg": "EdDSA",
"kid": "ed25519-..."
},
"sharedCapabilities": ["app_access", "queue_submit", "admin_access"],
"hybridCapabilities": []
}
```
## Access claim shape
Current claim payload fields:
- `iss`
- `aud`
- `sub`
- `sid`
- `jti`
- `iat`
- `nbf`
- `exp`
- `typ`
- `account_id`
- `access_revision`
- `display_name` optional
- `app.slug`
- `app.profile_version`
- `facts`
- `capabilities`
- `identities`
Important current rule:
- Heimdall only grants fully shared capabilities directly
- hybrid capabilities still require app-local checks after claim verification
Examples:
- Repixelizer `app_access` and `queue_submit` are shared
- Repixelizer `job_read_own` still needs local ownership
- StreamPixels `viewer_access` can be shared
- StreamPixels `creator_access` still needs app-local creator membership
## Current gaps
The following are not landed yet:
- provider token exchange on callback
- durable account/session/grant storage
- linked-identity persistence
- entitlement snapshot persistence
- refresh/revocation flows
- app-facing middleware packages or reference verifiers
## Next implementation move
Build durable storage plus the first real provider path on top of this
skeleton:
- persisted signing key material
- accounts and linked identities
- real callback exchange for one provider slice
- first end-to-end Repixelizer binding

View file

@ -27,16 +27,22 @@ Do not trust this file for the exact live HEAD. Always check git.
## Current Orientation ## Current Orientation
- this repo is still planning and service-contract work, not a landed auth - this repo now has an initial standalone Heimdall service skeleton
service yet
- the canonical shared architecture lives in `docs/architecture.md` - the canonical shared architecture lives in `docs/architecture.md`
- the concrete HTTP/JWKS contract lives in `docs/service-contract.md`
- app profiles live under `docs/app-profiles/` - app profiles live under `docs/app-profiles/`
- the key boundary is explicit: - the key boundary is explicit:
- Heimdall owns OAuth, linking, grants, entitlement refresh, and signed claim - Heimdall owns OAuth, linking, managed provider credential custody, grants,
issuance entitlement refresh, and signed claim issuance
- app-domain data stays app-owned by default - app-domain data stays app-owned by default
- host apps verify signed claims locally for routine auth instead of calling - host apps verify signed claims locally for routine auth instead of calling
Heimdall on every request Heimdall on every request
- the landed skeleton already exposes:
- `/.well-known/jwks.json`
- `/.well-known/heimdall-configuration`
- `/v1/oauth/{provider}/start`
- `/v1/oauth/{provider}/callback`
- `/v1/apps/{appSlug}/claims/issue`
- Repixelizer is the first auth-blank binding target - Repixelizer is the first auth-blank binding target
- StreamPixels is the migration target with useful existing auth seams that - StreamPixels is the migration target with useful existing auth seams that
should not be flattened into mush should not be flattened into mush
@ -58,13 +64,12 @@ Do not trust this file for the exact live HEAD. Always check git.
Do not continue implementation automatically from a rehydrate-only request. Do not continue implementation automatically from a rehydrate-only request.
If the user asks to continue, the current next move is to define the first If the user asks to continue, the current next move is to define the first
Heimdall service contract and skeleton: real provider/storage slice on top of the landed skeleton:
- OAuth start/callback surfaces - durable account / linked-identity / grant persistence
- signed session / claim shape - persisted signing key material
- claim verification contract for app backends - first real provider callback/token exchange path
- entitlement/grant persistence boundaries - first end-to-end Repixelizer binding
- Repixelizer and StreamPixels integration seams
without dragging app-domain data into the shared layer and without forcing without dragging app-domain data into the shared layer and without forcing
per-request auth round-trips. per-request auth round-trips.

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "heimdall",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"fastify": "^5.8.5"
},
"devDependencies": {
"@types/node": "^25.6.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
}
}

1434
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

172
src/app-profiles.ts Normal file
View file

@ -0,0 +1,172 @@
import { type AppSlug, type LinkedIdentityInput, type Provider } from "./contracts.js";
export type CapabilityMode = "shared" | "hybrid";
export interface CapabilityDefinition {
key: string;
mode: CapabilityMode;
summary: string;
sharedRule?: string;
localRequirement?: string;
}
export interface ClaimEvaluationContext {
accountId: string;
facts: Set<string>;
identities: LinkedIdentityInput[];
}
export interface AppProfile {
slug: AppSlug;
displayName: string;
profileVersion: string;
identityProviders: Provider[];
entitlementSources: Provider[];
managedConnectionProviders: Provider[];
capabilities: CapabilityDefinition[];
evaluateSharedCapabilities(context: ClaimEvaluationContext): string[];
}
function hasAnyFact(facts: Set<string>, values: readonly string[]): boolean {
return values.some((value) => facts.has(value));
}
const repixelizerProfile: AppProfile = {
slug: "repixelizer",
displayName: "Repixelizer",
profileVersion: "2026-04-26",
identityProviders: ["discord", "patreon"],
entitlementSources: ["discord", "patreon"],
managedConnectionProviders: [],
capabilities: [
{
key: "app_access",
mode: "shared",
summary: "May load the protected hosted GUI.",
sharedRule:
"discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access",
},
{
key: "queue_submit",
mode: "shared",
summary: "May create a repixelizer job.",
sharedRule: "app_access",
},
{
key: "job_read_own",
mode: "hybrid",
summary: "May read own job state, event stream, and final output.",
localRequirement: "Host app must combine app_access with job ownership.",
},
{
key: "job_cancel_own",
mode: "hybrid",
summary: "May cancel own queued or running job.",
localRequirement: "Host app must combine app_access with job ownership.",
},
{
key: "admin_access",
mode: "shared",
summary: "May inspect grant/admin surfaces.",
sharedRule: "grant.operator || grant.admin_access",
},
],
evaluateSharedCapabilities(context) {
const capabilities: string[] = [];
const appAccess = hasAnyFact(context.facts, [
"discord.allowed_role",
"patreon.allowed_tier",
"grant.global_member",
"grant.app_access",
]);
if (appAccess) {
capabilities.push("app_access", "queue_submit");
}
if (hasAnyFact(context.facts, ["grant.operator", "grant.admin_access"])) {
capabilities.push("admin_access");
}
return capabilities;
},
};
const streampixelsProfile: AppProfile = {
slug: "streampixels",
displayName: "StreamPixels",
profileVersion: "2026-04-26",
identityProviders: ["twitch", "youtube"],
entitlementSources: [],
managedConnectionProviders: ["twitch", "youtube"],
capabilities: [
{
key: "viewer_access",
mode: "shared",
summary: "Authenticated viewer session for control-plane surfaces.",
sharedRule: "identity.authenticated",
},
{
key: "creator_access",
mode: "hybrid",
summary: "Creator-scoped route access.",
localRequirement:
"Host app must combine authenticated session claims with local creator membership.",
},
{
key: "creator_admin",
mode: "hybrid",
summary: "Creator-scoped write/admin access.",
localRequirement:
"Host app must combine authenticated session claims with local creator admin membership.",
},
{
key: "operator_access",
mode: "shared",
summary: "Global operator access.",
sharedRule: "grant.operator",
},
],
evaluateSharedCapabilities(context) {
const capabilities: string[] = [];
if (context.facts.has("identity.authenticated")) {
capabilities.push("viewer_access");
}
if (context.facts.has("grant.operator")) {
capabilities.push("operator_access");
}
return capabilities;
},
};
export const appProfiles: Record<AppSlug, AppProfile> = {
repixelizer: repixelizerProfile,
streampixels: streampixelsProfile,
};
export function getAppProfile(appSlug: AppSlug): AppProfile {
return appProfiles[appSlug];
}
export function serializeAppProfile(profile: AppProfile): Record<string, unknown> {
return {
slug: profile.slug,
displayName: profile.displayName,
profileVersion: profile.profileVersion,
identityProviders: profile.identityProviders,
entitlementSources: profile.entitlementSources,
managedConnectionProviders: profile.managedConnectionProviders,
capabilities: profile.capabilities,
};
}
export function supportsProvider(profile: AppProfile, provider: Provider): boolean {
return (
profile.identityProviders.includes(provider) ||
profile.managedConnectionProviders.includes(provider) ||
profile.entitlementSources.includes(provider)
);
}

418
src/app.ts Normal file
View file

@ -0,0 +1,418 @@
import { randomUUID } from "node:crypto";
import cors from "@fastify/cors";
import Fastify, { type FastifyInstance } from "fastify";
import {
appSlugs,
connectionKinds,
oauthModes,
providers,
type AccessClaimPayload,
type AppSlug,
type IssueClaimRequest,
type OAuthConnectionBinding,
type OAuthStartRequest,
type Provider,
} from "./contracts.js";
import { getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
import { type HeimdallConfig, loadConfig } from "./config.js";
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
interface BuildAppOptions {
config?: HeimdallConfig;
}
function nowEpochSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function clampTtlSeconds(value: number, fallback: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.max(60, Math.min(86_400, Math.trunc(value)));
}
function normalizeFacts(request: IssueClaimRequest): string[] {
const facts = new Set(request.facts ?? []);
if (request.accountId.trim() || request.linkedIdentities?.length) {
facts.add("identity.authenticated");
}
return [...facts].sort();
}
function buildDiscovery(config: HeimdallConfig) {
return {
issuer: config.issuer,
jwksUri: `${config.publicBaseUrl}/.well-known/jwks.json`,
configurationUri: `${config.publicBaseUrl}/.well-known/heimdall-configuration`,
oauthStartEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/start`,
oauthCallbackEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/callback`,
claimIssueEndpoint: `${config.publicBaseUrl}/v1/apps/{appSlug}/claims/issue`,
supportedProviders: providers.map((provider) => ({
key: provider,
displayName: providerCatalog[provider].displayName,
roles: providerCatalog[provider].roles,
})),
apps: appSlugs.map((appSlug) => serializeAppProfile(getAppProfile(appSlug))),
};
}
function buildOAuthStateToken(options: {
config: HeimdallConfig;
provider: Provider;
appSlug: AppSlug;
mode: OAuthStartRequest["mode"];
returnTo: string;
connection: OAuthConnectionBinding | undefined;
keys: ReturnType<typeof createRuntimeKeyMaterial>;
}): { token: string; expiresAt: string } {
const issuedAt = nowEpochSeconds();
const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds;
const token = signJwt(
{
iss: options.config.issuer,
sub: options.appSlug,
aud: options.provider,
jti: randomUUID(),
iat: issuedAt,
nbf: issuedAt,
exp: expiresAtEpoch,
typ: "heimdall_oauth_state",
provider: options.provider,
app_slug: options.appSlug,
mode: options.mode,
return_to: options.returnTo,
connection: options.connection ?? null,
},
options.keys
);
return {
token,
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
};
}
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
const config = options.config ?? loadConfig();
const keys = createRuntimeKeyMaterial(config);
const app = Fastify({ logger: false });
app.decorate("heimdallContext", { config, keys });
await app.register(cors, {
origin: true,
credentials: true,
});
app.get("/healthz", async () => ({
ok: true,
service: config.serviceName,
issuer: config.issuer,
now: new Date().toISOString(),
}));
app.get("/.well-known/jwks.json", async () => ({
keys: [keys.publicJwk],
}));
app.get("/.well-known/heimdall-configuration", async () => buildDiscovery(config));
app.get("/v1/apps", async () => ({
apps: appSlugs.map((appSlug) => serializeAppProfile(getAppProfile(appSlug))),
}));
app.get<{ Params: { appSlug: AppSlug } }>(
"/v1/apps/:appSlug",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
},
},
},
},
async (request) => serializeAppProfile(getAppProfile(request.params.appSlug))
);
app.post<{ Params: { provider: Provider }; Body: OAuthStartRequest }>(
"/v1/oauth/:provider/start",
{
schema: {
params: {
type: "object",
required: ["provider"],
additionalProperties: false,
properties: {
provider: { type: "string", enum: [...providers] },
},
},
body: {
type: "object",
required: ["appSlug", "mode", "returnTo"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
mode: { type: "string", enum: [...oauthModes] },
returnTo: { type: "string", format: "uri" },
requestedScopes: {
type: "array",
items: { type: "string" },
uniqueItems: true,
},
connection: {
type: "object",
required: ["kind", "targetId"],
additionalProperties: false,
properties: {
kind: { type: "string", enum: [...connectionKinds] },
targetId: { type: "string", minLength: 1 },
summary: { type: "string" },
},
},
},
},
},
},
async (request, reply) => {
const { provider } = request.params;
const profile = getAppProfile(request.body.appSlug);
if (!supportsProvider(profile, provider)) {
reply.code(400);
return {
error: "provider_not_supported_for_app",
provider,
appSlug: profile.slug,
};
}
const providerConfig = config.providers[provider];
if (!providerConfig.clientId) {
reply.code(503);
return {
error: "provider_not_configured",
provider,
expectedEnv: providerExpectedEnv(provider),
};
}
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
const state = buildOAuthStateToken({
config,
provider,
appSlug: profile.slug,
mode: request.body.mode,
returnTo: request.body.returnTo,
connection: request.body.connection,
keys,
});
reply.code(201);
return {
provider,
appSlug: profile.slug,
mode: request.body.mode,
callbackUrl,
authorizationUrl: buildAuthorizationUrl({
provider,
clientId: providerConfig.clientId,
redirectUri: callbackUrl,
state: state.token,
requestedScopes: request.body.requestedScopes,
}),
stateToken: state.token,
stateExpiresAt: state.expiresAt,
};
}
);
app.get<{
Params: { provider: Provider };
Querystring: { code?: string; state?: string; error?: string; error_description?: string };
}>(
"/v1/oauth/:provider/callback",
{
schema: {
params: {
type: "object",
required: ["provider"],
additionalProperties: false,
properties: {
provider: { type: "string", enum: [...providers] },
},
},
querystring: {
type: "object",
additionalProperties: false,
properties: {
code: { type: "string" },
state: { type: "string" },
error: { type: "string" },
error_description: { type: "string" },
},
},
},
},
async (request, reply) => {
if (!request.query.state) {
reply.code(400);
return { error: "missing_state" };
}
const verification = verifyJwt(request.query.state, keys.publicKey);
if (!verification.valid) {
reply.code(400);
return { error: "invalid_state", detail: verification.error };
}
const statePayload = verification.payload;
if (statePayload.typ !== "heimdall_oauth_state" || statePayload.provider !== request.params.provider) {
reply.code(400);
return { error: "state_provider_mismatch" };
}
if (request.query.error) {
reply.code(400);
return {
error: "provider_error",
provider: request.params.provider,
providerError: request.query.error,
providerErrorDescription: request.query.error_description,
};
}
if (!request.query.code) {
reply.code(400);
return { error: "missing_code" };
}
reply.code(501);
return {
error: "token_exchange_not_implemented",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
codeReceived: true,
};
}
);
app.post<{ Params: { appSlug: AppSlug }; Body: IssueClaimRequest }>(
"/v1/apps/:appSlug/claims/issue",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
},
},
body: {
type: "object",
required: ["accountId"],
additionalProperties: false,
properties: {
accountId: { type: "string", minLength: 1 },
sessionId: { type: "string", minLength: 1 },
displayName: { type: "string", minLength: 1 },
facts: {
type: "array",
items: { type: "string" },
uniqueItems: true,
},
accessRevision: { type: "integer", minimum: 1 },
ttlSeconds: { type: "integer", minimum: 60, maximum: 86400 },
linkedIdentities: {
type: "array",
items: {
type: "object",
required: ["provider", "providerUserId"],
additionalProperties: false,
properties: {
provider: { type: "string", enum: [...providers] },
providerUserId: { type: "string", minLength: 1 },
username: { type: "string" },
displayName: { type: "string" },
},
},
},
},
},
},
},
async (request, reply) => {
const profile = getAppProfile(request.params.appSlug);
const facts = normalizeFacts(request.body);
const factSet = new Set(facts);
const linkedIdentities = request.body.linkedIdentities ?? [];
const sharedCapabilities = profile.evaluateSharedCapabilities({
accountId: request.body.accountId,
facts: factSet,
identities: linkedIdentities,
});
const issuedAt = nowEpochSeconds();
const ttlSeconds = clampTtlSeconds(request.body.ttlSeconds ?? config.sessionTtlSeconds, config.sessionTtlSeconds);
const expiresAtEpoch = issuedAt + ttlSeconds;
const sessionId = request.body.sessionId ?? randomUUID();
const accessClaim: AccessClaimPayload = {
iss: config.issuer,
aud: profile.slug,
sub: request.body.accountId,
sid: sessionId,
jti: randomUUID(),
iat: issuedAt,
nbf: issuedAt,
exp: expiresAtEpoch,
typ: "heimdall_access",
account_id: request.body.accountId,
access_revision: request.body.accessRevision ?? 1,
app: {
slug: profile.slug,
profile_version: profile.profileVersion,
},
facts,
capabilities: sharedCapabilities,
identities: linkedIdentities,
};
if (request.body.displayName) {
accessClaim.display_name = request.body.displayName;
}
reply.code(201);
return {
session: {
accountId: request.body.accountId,
sessionId,
appSlug: profile.slug,
accessRevision: accessClaim.access_revision,
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
},
accessToken: signJwt(accessClaim as unknown as Record<string, unknown>, keys),
claimSet: accessClaim,
verification: {
issuer: config.issuer,
jwksUri: `${config.publicBaseUrl}/.well-known/jwks.json`,
alg: keys.alg,
kid: keys.kid,
},
sharedCapabilities,
hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
};
}
);
return app;
}

81
src/config.ts Normal file
View file

@ -0,0 +1,81 @@
import { providers, type Provider } from "./contracts.js";
export interface ProviderClientConfig {
clientId?: string;
clientSecret?: string;
}
export interface HeimdallConfig {
serviceName: string;
host: string;
port: number;
publicBaseUrl: string;
issuer: string;
sessionTtlSeconds: number;
stateTtlSeconds: number;
signingPrivateKeyPem?: string;
signingKeyId?: string;
providers: Record<Provider, ProviderClientConfig>;
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function readInt(envValue: string | undefined, fallback: number): number {
if (!envValue) {
return fallback;
}
const parsed = Number.parseInt(envValue, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function readProviderConfig(env: NodeJS.ProcessEnv, provider: Provider): ProviderClientConfig {
const prefix = `GC_ACCESS_PROVIDER_${provider.toUpperCase()}`;
const config: ProviderClientConfig = {};
const clientId = env[`${prefix}_CLIENT_ID`];
const clientSecret = env[`${prefix}_CLIENT_SECRET`];
if (clientId) {
config.clientId = clientId;
}
if (clientSecret) {
config.clientSecret = clientSecret;
}
return config;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig {
const host = env.HOST ?? "127.0.0.1";
const port = readInt(env.PORT, 4100);
const publicBaseUrl = trimTrailingSlash(env.GC_ACCESS_BASE_URL ?? `http://${host}:${port}`);
const issuer = trimTrailingSlash(env.GC_ACCESS_ISSUER ?? publicBaseUrl);
const providersConfig = Object.fromEntries(
providers.map((provider) => [provider, readProviderConfig(env, provider)])
) as Record<Provider, ProviderClientConfig>;
const config: HeimdallConfig = {
serviceName: "heimdall",
host,
port,
publicBaseUrl,
issuer,
sessionTtlSeconds: readInt(env.GC_ACCESS_SESSION_TTL_SECONDS, 3600),
stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600),
providers: providersConfig,
};
const signingPrivateKeyPem = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PEM?.replace(/\\n/g, "\n");
if (signingPrivateKeyPem) {
config.signingPrivateKeyPem = signingPrivateKeyPem;
}
if (env.GC_ACCESS_SIGNING_KEY_ID) {
config.signingKeyId = env.GC_ACCESS_SIGNING_KEY_ID;
}
return config;
}

68
src/contracts.ts Normal file
View file

@ -0,0 +1,68 @@
export const providers = ["discord", "patreon", "github", "twitch", "youtube"] as const;
export type Provider = (typeof providers)[number];
export const appSlugs = ["repixelizer", "streampixels"] as const;
export type AppSlug = (typeof appSlugs)[number];
export const oauthModes = ["sign_in", "link", "connect"] as const;
export type OAuthMode = (typeof oauthModes)[number];
export const connectionKinds = ["account", "creator", "workspace", "project", "channel"] as const;
export type ConnectionKind = (typeof connectionKinds)[number];
export interface OAuthConnectionBinding {
kind: ConnectionKind;
targetId: string;
summary?: string;
}
export interface OAuthStartRequest {
appSlug: AppSlug;
mode: OAuthMode;
returnTo: string;
connection?: OAuthConnectionBinding;
requestedScopes?: string[];
}
export interface LinkedIdentityInput {
provider: Provider;
providerUserId: string;
username?: string;
displayName?: string;
}
export interface IssueClaimRequest {
accountId: string;
sessionId?: string;
displayName?: string;
facts?: string[];
linkedIdentities?: LinkedIdentityInput[];
accessRevision?: number;
ttlSeconds?: number;
}
export interface AccessClaimPayload {
iss: string;
aud: AppSlug;
sub: string;
sid: string;
jti: string;
iat: number;
nbf: number;
exp: number;
typ: "heimdall_access";
account_id: string;
access_revision: number;
display_name?: string;
app: {
slug: AppSlug;
profile_version: string;
};
facts: string[];
capabilities: string[];
identities: LinkedIdentityInput[];
}

13
src/index.ts Normal file
View file

@ -0,0 +1,13 @@
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
const config = loadConfig();
const app = await buildApp({ config });
try {
await app.listen({ host: config.host, port: config.port });
app.log.info(`Heimdall listening on ${config.host}:${config.port}`);
} catch (error) {
app.log.error(error);
process.exitCode = 1;
}

101
src/providers.ts Normal file
View file

@ -0,0 +1,101 @@
import { type Provider } from "./contracts.js";
export type ProviderRole = "identity" | "entitlement" | "managed_credential";
export interface ProviderDescriptor {
key: Provider;
displayName: string;
authorizationEndpoint: string;
defaultScopes: string[];
roles: ProviderRole[];
clientIdEnv: string;
clientSecretEnv: string;
additionalAuthorizationParams?: Record<string, string>;
}
export const providerCatalog: Record<Provider, ProviderDescriptor> = {
discord: {
key: "discord",
displayName: "Discord",
authorizationEndpoint: "https://discord.com/oauth2/authorize",
defaultScopes: ["identify", "guilds"],
roles: ["identity", "entitlement"],
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET",
},
patreon: {
key: "patreon",
displayName: "Patreon",
authorizationEndpoint: "https://www.patreon.com/oauth2/authorize",
defaultScopes: ["identity", "identity[email]", "campaigns.members"],
roles: ["identity", "entitlement"],
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET",
},
github: {
key: "github",
displayName: "GitHub",
authorizationEndpoint: "https://github.com/login/oauth/authorize",
defaultScopes: ["read:user", "user:email"],
roles: ["identity", "managed_credential"],
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET",
},
twitch: {
key: "twitch",
displayName: "Twitch",
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
defaultScopes: ["user:read:email"],
roles: ["identity", "managed_credential"],
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET",
},
youtube: {
key: "youtube",
displayName: "YouTube",
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
defaultScopes: [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/youtube.readonly",
],
roles: ["identity", "managed_credential"],
clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET",
additionalAuthorizationParams: {
access_type: "offline",
include_granted_scopes: "true",
prompt: "consent",
},
},
};
export function buildAuthorizationUrl(options: {
provider: Provider;
clientId: string;
redirectUri: string;
state: string;
requestedScopes: string[] | undefined;
}): string {
const descriptor = providerCatalog[options.provider];
const url = new URL(descriptor.authorizationEndpoint);
const scopes = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes;
url.searchParams.set("client_id", options.clientId);
url.searchParams.set("redirect_uri", options.redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", scopes.join(" "));
url.searchParams.set("state", options.state);
for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) {
url.searchParams.set(key, value);
}
return url.toString();
}
export function providerExpectedEnv(provider: Provider): string[] {
const descriptor = providerCatalog[provider];
return [descriptor.clientIdEnv, descriptor.clientSecretEnv];
}

120
src/signing.ts Normal file
View file

@ -0,0 +1,120 @@
import {
createHash,
createPrivateKey,
createPublicKey,
generateKeyPairSync,
sign,
verify,
type KeyObject,
} from "node:crypto";
import { type HeimdallConfig } from "./config.js";
type Jwk = {
crv?: string;
kty?: string;
x?: string;
[key: string]: unknown;
};
export interface RuntimeKeyMaterial {
alg: "EdDSA";
kid: string;
privateKey: KeyObject;
publicKey: KeyObject;
publicJwk: Jwk & { alg: "EdDSA"; kid: string; use: "sig" };
}
function encodeBase64Url(input: string | Buffer): string {
return Buffer.from(input).toString("base64url");
}
function decodeBase64Url(input: string): string {
return Buffer.from(input, "base64url").toString("utf8");
}
function buildKeyId(publicJwk: Jwk, explicitKeyId?: string): string {
if (explicitKeyId) {
return explicitKeyId;
}
const thumbprint = createHash("sha256")
.update(JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x }))
.digest("hex");
return `ed25519-${thumbprint.slice(0, 12)}`;
}
export function createRuntimeKeyMaterial(config: HeimdallConfig): RuntimeKeyMaterial {
const privateKey = config.signingPrivateKeyPem
? createPrivateKey(config.signingPrivateKeyPem)
: generateKeyPairSync("ed25519").privateKey;
const publicKey = createPublicKey(privateKey);
const exportedJwk = publicKey.export({ format: "jwk" }) as Jwk;
const kid = buildKeyId(exportedJwk, config.signingKeyId);
return {
alg: "EdDSA",
kid,
privateKey,
publicKey,
publicJwk: {
...exportedJwk,
alg: "EdDSA",
kid,
use: "sig",
},
};
}
export function signJwt(payload: Record<string, unknown>, keys: RuntimeKeyMaterial): string {
const header = {
alg: keys.alg,
kid: keys.kid,
typ: "JWT",
};
const encodedHeader = encodeBase64Url(JSON.stringify(header));
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = sign(null, Buffer.from(signingInput, "utf8"), keys.privateKey);
return `${signingInput}.${encodeBase64Url(signature)}`;
}
export function decodeJwt(token: string): {
header: Record<string, unknown>;
payload: Record<string, unknown>;
} {
const [encodedHeader, encodedPayload] = token.split(".");
if (!encodedHeader || !encodedPayload) {
throw new Error("Malformed JWT.");
}
return {
header: JSON.parse(decodeBase64Url(encodedHeader)) as Record<string, unknown>,
payload: JSON.parse(decodeBase64Url(encodedPayload)) as Record<string, unknown>,
};
}
export function verifyJwt(
token: string,
publicKey: KeyObject
): { valid: true; header: Record<string, unknown>; payload: Record<string, unknown> } | {
valid: false;
error: string;
} {
const [encodedHeader, encodedPayload, encodedSignature] = token.split(".");
if (!encodedHeader || !encodedPayload || !encodedSignature) {
return { valid: false, error: "Malformed JWT." };
}
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = Buffer.from(encodedSignature, "base64url");
const valid = verify(null, Buffer.from(signingInput, "utf8"), publicKey, signature);
if (!valid) {
return { valid: false, error: "Invalid JWT signature." };
}
const { header, payload } = decodeJwt(token);
return { valid: true, header, payload };
}

View file

@ -3,3 +3,4 @@
{"ts":"2026-04-26T00:00:00Z","type":"boundary","status":"accepted","note":"StreamPixels can reuse the same auth/control-plane backbone, and Heimdall may own OAuth/token custody for shared provider connections, but streamer-audience data, creator memberships, creator-side connection bindings, and runtime state remain StreamPixels-owned."} {"ts":"2026-04-26T00:00:00Z","type":"boundary","status":"accepted","note":"StreamPixels can reuse the same auth/control-plane backbone, and Heimdall may own OAuth/token custody for shared provider connections, but streamer-audience data, creator memberships, creator-side connection bindings, and runtime state remain StreamPixels-owned."}
{"ts":"2026-04-26T06:00:00Z","type":"design","status":"accepted","note":"The cross-runtime split and current Yggdrasil topology changed the first implementation shape from an embedded shared package to Heimdall: a shared auth authority service whose signed claims are verified locally by each app."} {"ts":"2026-04-26T06:00:00Z","type":"design","status":"accepted","note":"The cross-runtime split and current Yggdrasil topology changed the first implementation shape from an embedded shared package to Heimdall: a shared auth authority service whose signed claims are verified locally by each app."}
{"ts": "2026-04-26T11:00:02+00:00", "type": "design", "status": "accepted", "note": "Heimdall's provider model splits shared OAuth and credential custody from app-owned binding semantics and runtime use. Different provider mixes change adapter inventory and claim vocabulary, not the core service boundary."} {"ts": "2026-04-26T11:00:02+00:00", "type": "design", "status": "accepted", "note": "Heimdall's provider model splits shared OAuth and credential custody from app-owned binding semantics and runtime use. Different provider mixes change adapter inventory and claim vocabulary, not the core service boundary."}
{"ts": "2026-04-26T11:24:39+00:00", "type": "design", "status": "accepted", "note": "The first Heimdall implementation pass landed as a standalone TypeScript/Fastify service skeleton with Ed25519 JWT signing, JWKS discovery, signed OAuth state, and app-profile-driven claim issuance for Repixelizer and StreamPixels."}

View file

@ -1,7 +1,7 @@
project: project:
name: Heimdall name: Heimdall
status: active status: active
phase: service_topology_alignment phase: service_contract_skeleton
premise: > premise: >
Build Heimdall as one reusable GameCult auth authority service for OAuth, Build Heimdall as one reusable GameCult auth authority service for OAuth,
linked identities, local sessions, grants, and capability claims without linked identities, local sessions, grants, and capability claims without
@ -59,6 +59,7 @@ active_subgoals:
accepted_design: accepted_design:
- `docs/architecture.md` is the canonical shared architecture note. - `docs/architecture.md` is the canonical shared architecture note.
- `docs/service-contract.md` records the concrete HTTP/JWKS contract of the landed skeleton.
- `docs/app-profiles/*.md` are thin app bindings, not duplicate brains. - `docs/app-profiles/*.md` are thin app bindings, not duplicate brains.
- Heimdall owns provider OAuth, identity linking, signed local sessions, grants, entitlement refresh, capability evaluation, and audit events. - Heimdall owns provider OAuth, identity linking, signed local sessions, grants, entitlement refresh, capability evaluation, and audit events.
- Host apps own their domain data, runtime state, and product-specific resources. - Host apps own their domain data, runtime state, and product-specific resources.
@ -66,11 +67,13 @@ accepted_design:
- A shared Heimdall store, if it exists, should hold auth/control-plane truth only. - A shared Heimdall store, if it exists, should hold auth/control-plane truth only.
- Repixelizer is the first auth-blank hosted-app binding. - Repixelizer is the first auth-blank hosted-app binding.
- StreamPixels is the migration case that proves shared authority must not imply audience-data centralization. - StreamPixels is the migration case that proves shared authority must not imply audience-data centralization.
- The first service skeleton is a standalone TypeScript/Fastify app with Ed25519 JWT signing, JWKS discovery, signed OAuth `state`, and app-profile-driven shared capability evaluation.
landed_baseline: landed_baseline:
- Shared architecture extracted into its own repo and renamed to Heimdall. - Shared architecture extracted into its own repo and renamed to Heimdall.
- Repixelizer and StreamPixels app-profile notes created. - Repixelizer and StreamPixels app-profile notes created.
- Repo-local state, handoff, and compaction scaffolding created. - Repo-local state, handoff, and compaction scaffolding created.
- Initial Heimdall service skeleton landed with discovery, JWKS, OAuth start/callback contract surfaces, and claim issuance scaffolding.
rejected_paths: rejected_paths:
- name: per_app_auth_copy_paste - name: per_app_auth_copy_paste
@ -81,5 +84,5 @@ rejected_paths:
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
current_status: current_status:
summary: The shared hosted-access planning work now lives in Heimdall, a service-first auth authority repo with a Yggdrasil-hosted topology in mind. The key boundary is explicit: Heimdall owns OAuth, linking, grants, entitlement refresh, and signed claims; app-domain data remains app-owned; routine route authorization stays local after claim verification. summary: Heimdall now has a first standalone service skeleton: TypeScript/Fastify runtime, Ed25519 JWT signing, JWKS/discovery exposure, signed OAuth start-state scaffolding, and app-profile-driven claim issuance for Repixelizer and StreamPixels. The key boundary still holds: Heimdall owns auth/control-plane authority and credential custody; app-domain bindings and runtime behavior remain app-owned.
next_action: Start the first implementation-oriented pass by defining Heimdall's service contract and skeleton: OAuth start/callback surfaces, signed session and claim payloads, claim verification material for app backends, and the first Repixelizer and StreamPixels integration seams. next_action: Land durable persistence and the first real provider path on top of the skeleton: persisted signing key material, accounts/linked identities/grants, a real provider callback/token exchange slice, and the first end-to-end Repixelizer binding.

162
tests/app.test.ts Normal file
View file

@ -0,0 +1,162 @@
import { afterEach, describe, expect, it } from "vitest";
import { type FastifyInstance } from "fastify";
import { buildApp } from "../src/app.js";
import { type HeimdallConfig } from "../src/config.js";
import { decodeJwt, verifyJwt } from "../src/signing.js";
function createTestConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
sessionTtlSeconds: 3600,
stateTtlSeconds: 600,
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
github: { clientId: "github-client", clientSecret: "github-secret" },
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
},
};
}
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
function getPublicKey(app: FastifyInstance) {
return (
app as FastifyInstance & {
heimdallContext: {
keys: {
publicKey: Parameters<typeof verifyJwt>[1];
};
};
}
).heimdallContext.keys.publicKey;
}
afterEach(async () => {
while (apps.length) {
await apps.pop()?.close();
}
});
describe("Heimdall service skeleton", () => {
it("serves discovery and JWKS metadata", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const discoveryResponse = await app.inject({
method: "GET",
url: "/.well-known/heimdall-configuration",
});
expect(discoveryResponse.statusCode).toBe(200);
expect(discoveryResponse.json()).toEqual(
expect.objectContaining({
issuer: "https://heimdall.gamecult.org",
jwksUri: "https://heimdall.gamecult.org/.well-known/jwks.json",
})
);
const jwksResponse = await app.inject({
method: "GET",
url: "/.well-known/jwks.json",
});
expect(jwksResponse.statusCode).toBe(200);
expect(jwksResponse.json()).toEqual(
expect.objectContaining({
keys: [
expect.objectContaining({
alg: "EdDSA",
kid: expect.any(String),
use: "sig",
}),
],
})
);
});
it("builds signed OAuth start state for supported providers", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: {
appSlug: "repixelizer",
mode: "sign_in",
returnTo: "https://repixelizer.gamecult.org/app/",
},
});
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
const decoded = decodeJwt(payload.stateToken);
expect(decoded.payload).toEqual(
expect.objectContaining({
typ: "heimdall_oauth_state",
provider: "discord",
app_slug: "repixelizer",
})
);
});
it("issues signed claims and evaluates shared app capabilities", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "POST",
url: "/v1/apps/repixelizer/claims/issue",
payload: {
accountId: "acct_repixelizer_001",
displayName: "Meta",
facts: ["discord.allowed_role", "grant.operator"],
linkedIdentities: [
{
provider: "discord",
providerUserId: "123456789",
username: "meta",
},
],
},
});
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.sharedCapabilities).toEqual(
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
);
const verified = verifyJwt(payload.accessToken, getPublicKey(app));
expect(verified.valid).toBe(true);
const tokenDecoded = decodeJwt(payload.accessToken);
expect(tokenDecoded.payload.capabilities).toEqual(
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
);
});
it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const profileResponse = await app.inject({
method: "GET",
url: "/v1/apps/streampixels",
});
expect(profileResponse.statusCode).toBe(200);
expect(profileResponse.json()).toEqual(
expect.objectContaining({
slug: "streampixels",
managedConnectionProviders: ["twitch", "youtube"],
})
);
});
});

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2024"],
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "dist",
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}