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/
__pycache__/
*.pyc
node_modules/
dist/
coverage/
.pnpm-store/
.env
.env.*
!.env.example

View file

@ -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.

View file

@ -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

View file

@ -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

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
- 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.

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-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."}

View file

@ -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.

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"]
}