Add StreamPixels managed auth seams

This commit is contained in:
Meta Vens 2026-04-29 15:00:55 +01:00
commit f22db66d64
15 changed files with 589 additions and 26 deletions

View file

@ -31,6 +31,7 @@ In the service-first Heimdall version, the most likely reusable slice is:
- provider OAuth authority
- identity-linking authority
- managed Twitch/YouTube credential custody for creator connections
- entitlement refresh
- shared claim semantics

View file

@ -36,6 +36,13 @@ What exists right now is:
- a local verifier helper that app backends can use against Heimdall JWKS
- Repixelizer has consumed the direct backend callback flow in its hosted web
layer and verifies Heimdall Ed25519 access tokens locally
- generic Twitch and YouTube OAuth callback runtimes for StreamPixels identity
and managed creator connections
- an app-authenticated managed-credential resolve endpoint so StreamPixels can
call provider APIs without storing refresh tokens locally
- StreamPixels now starts viewer and creator Twitch/YouTube OAuth through
Heimdall, redeems one-time completion codes server-side, and keeps local
profile/creator connector binding state in StreamPixels
- Heimdall is deployed on Yggdrasil at `https://heimdall.gamecult.org` with
nginx/TLS, Postgres storage, stable file-backed signing, configured token
custody, and public health/discovery/JWKS checks passing
@ -124,12 +131,29 @@ Current Patreon policy:
Goals:
- move provider/linking authority toward Heimdall
- keep viewer profiles, creator memberships, connector creds, and audience data
- move provider/linking authority to Heimdall
- keep viewer profiles, creator memberships, connector bindings, diagnostics,
runtime behavior, and audience data
in StreamPixels
- keep creator/operator route authorization local to StreamPixels after claim
verification
Landed:
- Heimdall handles Twitch/YouTube OAuth exchange and identity resolution
- Heimdall stores managed provider tokens and exposes current access-token
resolution to app backends through an app shared secret
- StreamPixels delegates viewer claim/link and creator connector OAuth starts
to Heimdall
- StreamPixels redeems completion codes through its service, then owns local
viewer profile linking or creator connector binding
Still to verify:
- real browser StreamPixels viewer claim/link
- real creator connector attach for Twitch and YouTube
- runtime polling/subscription behavior using Heimdall-resolved credentials
### 5. Capture the Yggdrasil deployment shape early
Goals:

View file

@ -161,8 +161,10 @@ Failure mode right now:
Launch note:
- Discord and Patreon are the implemented provider runtimes for Repixelizer
- GitHub, Twitch, and YouTube are catalogued for start URLs, but their
callback exchange/identity/entitlement runtimes are not implemented yet
- Twitch and YouTube now have generic OAuth exchange and identity runtimes for
StreamPixels viewer auth and managed creator connections
- GitHub is still catalogued for start URLs, but its callback runtime is not
implemented yet
- Repixelizer launch can configure Discord and Patreon on its provider buttons
### `GET /v1/oauth/{provider}/callback`
@ -187,8 +189,8 @@ Current status:
- if opener handoff is used and fails, the completion page still offers a
fallback return link carrying only the one-time completion code, not the
access token
- other configured providers still return "not implemented" at the runtime
adapter layer until their callback paths are added
- GitHub still returns "not implemented" at the runtime adapter layer until its
callback path is added
Success response for backend-callback handoff now includes:
@ -246,6 +248,7 @@ Current payload shape on success:
"appSlug": "repixelizer",
"mode": "sign_in",
"returnTo": "https://repixelizer.gamecult.org/app/",
"connection": null,
"account": {
"id": "acct_repixelizer_001",
"displayName": "Meta"
@ -276,6 +279,7 @@ Current payload shape on failure:
"appSlug": "repixelizer",
"mode": "sign_in",
"returnTo": "https://repixelizer.gamecult.org/app/",
"connection": null,
"error": "oauth_callback_failed",
"errorDescription": "..."
}
@ -348,6 +352,50 @@ Important behavior:
- app backends should redeem them server-side
- the browser should not keep the final access token in callback URL fragments
### `POST /v1/apps/{appSlug}/managed-credentials/resolve`
Purpose:
- let an app backend retrieve the current Heimdall-custodied access token for
a managed provider connection
- keep refresh-token custody inside Heimdall instead of app persistence
Authentication:
- requires `x-heimdall-app-secret`
- the configured secret comes from `GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET` or
the generic `GC_ACCESS_APP_SHARED_SECRET`
Request body:
```json
{
"accountId": "acct_streampixels_001",
"provider": "youtube"
}
```
Response:
```json
{
"accountId": "acct_streampixels_001",
"provider": "youtube",
"providerUserId": "channel-123",
"accessToken": "<provider-access-token>",
"tokenExpiresAt": "2026-04-26T13:00:00.000Z",
"scopes": ["openid", "profile", "email"]
}
```
Important behavior:
- this endpoint only works for providers listed in the app profile's
`managedConnectionProviders`
- it returns access tokens only; refresh tokens remain in Heimdall custody
- app-local binding, diagnostics, subscription setup, and runtime behavior stay
app-owned
### Reference local verifier seam
Purpose:
@ -465,7 +513,7 @@ The following are not landed yet:
- refresh/revocation flows
- admin/grant management surfaces
- provider callback runtimes beyond Discord
- GitHub callback runtime
## Next implementation move

View file

@ -44,6 +44,7 @@ Do not trust this file for the exact live HEAD. Always check git.
- `/v1/oauth/{provider}/callback`
- `/v1/apps/{appSlug}/auth-completions/redeem`
- `/v1/apps/{appSlug}/claims/issue`
- `/v1/apps/{appSlug}/managed-credentials/resolve`
- the Discord callback path now performs real code exchange, identity
resolution, auth persistence, entitlement evaluation, audit logging, and
claim issuance for the Repixelizer slice
@ -71,10 +72,12 @@ Do not trust this file for the exact live HEAD. Always check git.
enabled
- Repixelizer public auth-start returns Discord and Patreon authorization URLs
using the Heimdall callback and Repixelizer backend handoff
- GitHub, Twitch, and YouTube are catalogued providers, but their callback
runtime adapters still return "not implemented"
- StreamPixels is the migration target with useful existing auth seams that
should not be flattened into mush
- Twitch and YouTube now have generic OAuth exchange and identity runtimes
- GitHub is still catalogued, but its callback runtime is not implemented
- StreamPixels now delegates viewer and creator Twitch/YouTube OAuth to
Heimdall, redeems Heimdall completion codes server-side, writes its own
viewer sessions and connector bindings, and resolves current provider access
tokens from Heimdall without storing refresh tokens locally
- the intended first deployment shape is a Heimdall service on Yggdrasil behind
nginx, not an embedded cross-runtime shared library fantasy
@ -92,8 +95,19 @@ 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 real browser OAuth
verification:
If the user asks to continue on StreamPixels, the current next move is deploy
and real browser verification:
- configure Heimdall with Twitch/YouTube OAuth credentials and an app shared
secret for StreamPixels
- configure StreamPixels service with `GC_ACCESS_BASE_URL`,
`GC_ACCESS_INTERNAL_URL`, and the same `GC_ACCESS_APP_SHARED_SECRET`
- verify viewer claim/link through `/auth/connect`
- verify creator connector attach from an admin creator session
- confirm StreamPixels persists local profile/connector binding state while
Heimdall retains provider token custody
Repixelizer still needs its real browser OAuth verification:
- open `https://repixelizer.gamecult.org/app/`
- start Discord sign-in with an account that has the `KLTST` or

View file

@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { randomUUID, timingSafeEqual } from "node:crypto";
import cors from "@fastify/cors";
import Fastify, { type FastifyInstance } from "fastify";
import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js";
@ -229,6 +229,21 @@ function normalizeEntitlementPolicy(value: OAuthStartRequest["entitlementPolicy"
throw new Error("Invalid entitlement policy definition.");
}
function getSharedSecret(request: { headers: Record<string, string | string[] | undefined> }): string | undefined {
const header = request.headers["x-heimdall-app-secret"];
return Array.isArray(header) ? header[0] : header;
}
function secretMatches(expected: string | undefined, provided: string | undefined): boolean {
if (!expected || !provided) {
return false;
}
const expectedBuffer = Buffer.from(expected);
const providedBuffer = Buffer.from(provided);
return expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer);
}
function acceptsBackendPolicy(appSlug: AppSlug, handoff: OAuthHandoff): boolean {
if (handoff.kind !== "backend_callback") {
return false;
@ -525,6 +540,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
error: "provider_error",
errorDescription: request.query.error_description ?? request.query.error,
});
@ -569,6 +585,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
error: "missing_code",
});
}
@ -603,6 +620,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
redirectUri: callbackUrl,
});
const identity = await runtime.resolveIdentity({
config,
accessToken: tokenSet.accessToken,
});
const nowIso = new Date().toISOString();
@ -730,6 +748,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
account: accountSummary,
entitlements: entitlementEvaluation,
returnTo: statePayload.return_to,
connection: statePayload.connection,
...issued,
};
if (handoff.kind === "backend_callback") {
@ -743,6 +762,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
account: accountSummary,
entitlements: entitlementEvaluation,
...issued,
@ -757,6 +777,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
provider: request.params.provider,
mode: statePayload.mode,
attemptId: handoff.attemptId,
connection: statePayload.connection,
callbackUrl: handoff.callbackUrl,
},
createdAt: nowIso,
@ -868,6 +889,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
error: "oauth_callback_failed",
errorDescription: message,
});
@ -1009,5 +1031,78 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
);
app.post<{
Params: { appSlug: AppSlug };
Body: { accountId: string; provider: Provider };
}>(
"/v1/apps/:appSlug/managed-credentials/resolve",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
},
},
body: {
type: "object",
required: ["accountId", "provider"],
additionalProperties: false,
properties: {
accountId: { type: "string", minLength: 1 },
provider: { type: "string", enum: [...providers] },
},
},
},
},
async (request, reply) => {
const profile = getAppProfile(request.params.appSlug);
const providedSecret = getSharedSecret(request);
if (!secretMatches(config.appSharedSecrets[profile.slug], providedSecret)) {
reply.code(401);
return { error: "app_auth_required" };
}
if (!profile.managedConnectionProviders.includes(request.body.provider)) {
reply.code(400);
return {
error: "provider_not_managed_for_app",
provider: request.body.provider,
appSlug: profile.slug,
};
}
const linkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, request.body.provider);
if (!linkedIdentity?.accessTokenEncrypted) {
reply.code(404);
return { error: "managed_credential_not_found" };
}
const response: {
accountId: string;
provider: Provider;
providerUserId: string;
accessToken: string;
tokenExpiresAt?: string;
scopes: string[];
} = {
accountId: linkedIdentity.accountId,
provider: linkedIdentity.provider,
providerUserId: linkedIdentity.providerUserId,
accessToken: tokenCustody.decrypt(linkedIdentity.accessTokenEncrypted),
scopes: linkedIdentity.scopes,
};
if (linkedIdentity.tokenExpiresAt) {
response.tokenExpiresAt = linkedIdentity.tokenExpiresAt;
}
return response;
}
);
return app;
}

View file

@ -1,5 +1,5 @@
import { type IssuedAccessClaimResult } from "./claims.js";
import { type AppSlug, type OAuthMode, type Provider } from "./contracts.js";
import { type AppSlug, type OAuthConnectionBinding, type OAuthMode, type Provider } from "./contracts.js";
import { type EntitlementEvaluation } from "./oauth.js";
interface AccountSummary {
@ -16,6 +16,7 @@ interface BackendHandoffBase {
provider: Provider;
appSlug: AppSlug;
returnTo: string;
connection: OAuthConnectionBinding | null;
}
export interface BackendHandoffSuccessPayload extends BackendHandoffBase, IssuedAccessClaimResult {

View file

@ -1,4 +1,4 @@
import { providers, type Provider } from "./contracts.js";
import { appSlugs, providers, type AppSlug, type Provider } from "./contracts.js";
export interface ProviderClientConfig {
clientId?: string;
@ -25,6 +25,7 @@ export interface HeimdallConfig {
bootstrapSigningPrivateKeyOnMissing: boolean;
signingKeyId?: string;
tokenEncryptionKeyBase64?: string;
appSharedSecrets: Partial<Record<AppSlug, string>>;
storage: StorageConfig;
providers: Record<Provider, ProviderClientConfig>;
}
@ -86,6 +87,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig
const providersConfig = Object.fromEntries(
providers.map((provider) => [provider, readProviderConfig(env, provider)])
) as Record<Provider, ProviderClientConfig>;
const appSharedSecrets = Object.fromEntries(
appSlugs
.map((appSlug) => {
const envKey = `GC_ACCESS_APP_${appSlug.toUpperCase()}_SHARED_SECRET`;
return [appSlug, env[envKey] ?? env.GC_ACCESS_APP_SHARED_SECRET];
})
.filter(([, value]) => Boolean(value))
) as Partial<Record<AppSlug, string>>;
const config: HeimdallConfig = {
serviceName: "heimdall",
@ -97,6 +106,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig
stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600),
completionTtlSeconds: readInt(env.GC_ACCESS_COMPLETION_TTL_SECONDS, 300),
bootstrapSigningPrivateKeyOnMissing: readBoolean(env.GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP, false),
appSharedSecrets,
storage: {
backend: storageBackend,
applySchemaOnStartup: readBoolean(env.GC_ACCESS_APPLY_SCHEMA_ON_STARTUP, true),

View file

@ -46,7 +46,7 @@ export interface OAuthProviderRuntime {
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet>;
resolveIdentity(options: { accessToken: string }): Promise<ResolvedIdentity>;
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
evaluateEntitlements(options: {
config: HeimdallConfig;
callback: OAuthCallbackContext;
@ -173,6 +173,111 @@ async function exchangePatreonAuthorizationCode(options: {
return tokenSet;
}
async function exchangeTwitchAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.twitch;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Twitch OAuth is not fully configured.");
}
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string;
}>(
"https://id.twitch.tv/oauth2/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
code: options.code,
grant_type: "authorization_code",
redirect_uri: options.redirectUri,
}),
},
"Twitch token exchange failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: tokenResponse.scope?.split(/\s+/).filter(Boolean) ?? [],
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function exchangeYouTubeAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.youtube;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("YouTube OAuth is not fully configured.");
}
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string;
id_token?: string;
}>(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
code: options.code,
grant_type: "authorization_code",
redirect_uri: options.redirectUri,
}),
},
"YouTube token exchange failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: tokenResponse.scope?.split(/\s+/).filter(Boolean) ?? [],
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function resolveDiscordIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
const user = await fetchJson<{
id: string;
@ -288,6 +393,144 @@ async function resolvePatreonIdentity(options: { accessToken: string }): Promise
return identity;
}
async function resolveTwitchIdentity(options: {
config: HeimdallConfig;
accessToken: string;
}): Promise<ResolvedIdentity> {
const providerConfig = options.config.providers.twitch;
const payload = await fetchJson<{
data?: Array<{
id: string;
login?: string;
display_name?: string;
profile_image_url?: string;
email?: string;
}>;
}>(
"https://api.twitch.tv/helix/users",
{
headers: {
Authorization: `Bearer ${options.accessToken}`,
"Client-Id": providerConfig.clientId ?? "",
},
},
"Twitch identity lookup failed"
);
const user = payload.data?.[0];
if (!user?.id) {
throw new Error("Twitch identity response did not include a user id.");
}
const identity: ResolvedIdentity = {
provider: "twitch",
providerUserId: user.id,
profile: user as unknown as Record<string, unknown>,
};
if (user.login) {
identity.username = user.login;
}
if (user.display_name) {
identity.displayName = user.display_name;
}
if (user.email) {
identity.primaryEmail = user.email;
}
if (user.profile_image_url) {
identity.avatarUrl = user.profile_image_url;
}
return identity;
}
async function resolveYouTubeIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
const channelUrl = new URL("https://www.googleapis.com/youtube/v3/channels");
channelUrl.searchParams.set("part", "snippet");
channelUrl.searchParams.set("mine", "true");
const channelResponse = await fetch(channelUrl, {
headers: {
Authorization: `Bearer ${options.accessToken}`,
},
});
if (channelResponse.ok) {
const payload = (await channelResponse.json()) as {
items?: Array<{
id: string;
snippet?: {
title?: string;
customUrl?: string;
thumbnails?: {
default?: { url?: string };
medium?: { url?: string };
high?: { url?: string };
};
};
}>;
};
const channel = payload.items?.[0];
if (channel?.id) {
const identity: ResolvedIdentity = {
provider: "youtube",
providerUserId: channel.id,
username: channel.snippet?.customUrl ?? channel.snippet?.title ?? channel.id,
displayName: channel.snippet?.title ?? channel.id,
profile: channel as unknown as Record<string, unknown>,
};
const avatarUrl =
channel.snippet?.thumbnails?.high?.url ??
channel.snippet?.thumbnails?.medium?.url ??
channel.snippet?.thumbnails?.default?.url;
if (avatarUrl) {
identity.avatarUrl = avatarUrl;
}
return identity;
}
}
const userInfo = await fetchJson<{
sub?: string;
name?: string;
picture?: string;
email?: string;
}>(
"https://openidconnect.googleapis.com/v1/userinfo",
{
headers: {
Authorization: `Bearer ${options.accessToken}`,
},
},
"Google identity lookup failed"
);
if (!userInfo.sub) {
throw new Error("Google identity response did not include a subject.");
}
const identity: ResolvedIdentity = {
provider: "youtube",
providerUserId: userInfo.sub,
username: userInfo.email ?? userInfo.name ?? userInfo.sub,
displayName: userInfo.name ?? userInfo.email ?? userInfo.sub,
profile: userInfo as unknown as Record<string, unknown>,
};
if (userInfo.email) {
identity.primaryEmail = userInfo.email;
}
if (userInfo.picture) {
identity.avatarUrl = userInfo.picture;
}
return identity;
}
async function evaluateDiscordEntitlements(options: {
config: HeimdallConfig;
callback: OAuthCallbackContext;
@ -519,6 +762,22 @@ const patreonRuntime: OAuthProviderRuntime = {
evaluateEntitlements: evaluatePatreonEntitlements,
};
const twitchRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeTwitchAuthorizationCode,
resolveIdentity: resolveTwitchIdentity,
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
const youtubeRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeYouTubeAuthorizationCode,
resolveIdentity: resolveYouTubeIdentity,
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
const notImplementedRuntime: OAuthProviderRuntime = {
async exchangeAuthorizationCode() {
throw new Error("Provider callback exchange is not implemented yet.");
@ -538,7 +797,7 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
discord: discordRuntime,
patreon: patreonRuntime,
github: notImplementedRuntime,
twitch: notImplementedRuntime,
youtube: notImplementedRuntime,
twitch: twitchRuntime,
youtube: youtubeRuntime,
};
}

View file

@ -103,6 +103,16 @@ export class InMemoryStore implements HeimdallStore {
return null;
}
async findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null> {
for (const linkedIdentity of this.linkedIdentities.values()) {
if (linkedIdentity.accountId === accountId && linkedIdentity.provider === provider) {
return clone(linkedIdentity);
}
}
return null;
}
async upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity> {
const existing = [...this.linkedIdentities.values()].find(
(identity) => identity.provider === input.provider && identity.providerUserId === input.providerUserId

View file

@ -289,6 +289,22 @@ export class PostgresStore implements HeimdallStore {
return result.rowCount ? mapLinkedIdentityRow(expectRow(result.rows[0], "findStoredLinkedIdentity")) : null;
}
async findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null> {
const result = await this.pool.query<LinkedIdentityRow>(
`
SELECT *
FROM linked_identities
WHERE account_id = $1
AND provider = $2
ORDER BY updated_at DESC
LIMIT 1
`,
[accountId, provider]
);
return result.rowCount ? mapLinkedIdentityRow(expectRow(result.rows[0], "findStoredLinkedIdentityForAccount")) : null;
}
async upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity> {
const id = input.id ?? randomUUID();
const result = await this.pool.query<LinkedIdentityRow>(

View file

@ -155,6 +155,7 @@ export interface HeimdallStore {
touchAccount(accountId: string, at: string, updates?: { displayName?: string; primaryEmail?: string }): Promise<void>;
findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise<StoredAccount | null>;
findStoredLinkedIdentity(provider: Provider, providerUserId: string): Promise<StoredLinkedIdentity | null>;
findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null>;
upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity>;
listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]>;
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;

View file

@ -15,3 +15,4 @@
{"ts": "2026-04-29T12:06:26+00:00", "type": "config", "status": "accepted", "note": "Repixelizer Discord access now accepts either KLTST (861962027550244914) or the Patreon-synced Inner Sanctum role (1497585425491955894) via GC_ACCESS_APP_REPIXELIZER_DISCORD_ALLOWED_ROLE_IDS."}
{"ts": "2026-04-29T12:19:57+00:00", "type": "design", "status": "accepted", "note": "Repixelizer Discord role policy moved out of Heimdall app-specific env. Repixelizer owns the GameCult guild and role list through REPIXELIZER_ACCESS_DISCORD_* and sends a caller-owned discord_role_access policy in the trusted backend-callback OAuth start; Heimdall keeps provider OAuth credentials/callback mechanics and evaluates the supplied policy."}
{"ts": "2026-04-29T13:37:17+00:00", "type": "provider", "status": "accepted", "note": "Patreon OAuth for Repixelizer is enabled through Heimdall with API v2 identity lookup. Repixelizer owns the access rule and sends patreon_membership_access with requiredTierTitle=Inner Sanctum; Heimdall evaluates the signed-in user's currently entitled Patreon tiers by title instead of currency amount."}
{"ts": "2026-04-29T13:56:56+00:00", "type": "design", "status": "accepted", "note": "StreamPixels viewer and creator Twitch/YouTube OAuth now delegates to Heimdall. Heimdall owns provider OAuth, identity resolution, encrypted token custody, and app-authenticated access-token resolution; StreamPixels owns viewer profiles, creator memberships, connector bindings, diagnostics, EventSub/live-chat runtime behavior, and audience data."}

View file

@ -1,7 +1,7 @@
project:
name: Heimdall
status: active
phase: discord_launch
phase: streampixels_migration
premise: >
Build Heimdall as one reusable GameCult auth authority service for OAuth,
linked identities, local sessions, grants, and capability claims without
@ -12,9 +12,9 @@ objective:
Create a shared auth authority service that Repixelizer, StreamPixels, and
future GameCult-hosted experiments can consume.
current: >
Verify the first live Discord-backed Repixelizer auth flow in a real
browser session now that Heimdall and Repixelizer are deployed and wired on
Yggdrasil.
Migrate StreamPixels viewer and creator OAuth onto Heimdall while keeping
StreamPixels-owned viewer profiles, creator memberships, connector
bindings, runtime state, and audience data local.
constraints:
- Heimdall owns auth/control-plane authority; app-domain data stays app-owned by default.
@ -57,7 +57,7 @@ active_subgoals:
- Preserve the default boundary that app-domain data remains app-owned.
- Align the plan to Heimdall as a Yggdrasil-hosted authority service.
- Verify the Discord and Patreon Repixelizer auth paths with real provider credentials, stable signing, persistent storage, and live browser callback verification.
- Keep Patreon/GitHub/Twitch/YouTube as catalogued but not implemented until their runtime adapters land.
- Wire StreamPixels through Heimdall for Twitch/YouTube viewer identity and managed creator-provider credentials without moving StreamPixels domain state into Heimdall.
accepted_design:
- `docs/architecture.md` is the canonical shared architecture note.
@ -74,6 +74,7 @@ accepted_design:
- Repixelizer has consumed the Heimdall backend-callback flow in its hosted web layer; Heimdall no longer needs to wait on Repixelizer refactoring before live Discord launch work.
- Heimdall is deployed on Yggdrasil at `https://heimdall.gamecult.org` with nginx/TLS, Postgres storage, file-backed signing-key bootstrap, configured token custody, and passing public health/discovery/JWKS checks.
- Repixelizer is deployed on Yggdrasil at `https://repixelizer.gamecult.org` with `GC_ACCESS_MODE=heimdall`, `GC_ACCESS_ALLOWED_PROVIDERS=discord,patreon`, required access, queue protection enabled, app-owned Discord guild/role policy, and app-owned Patreon tier-title policy sent to Heimdall during backend-callback OAuth start.
- StreamPixels viewer and creator provider OAuth should start through Heimdall; StreamPixels redeems one-time completion codes, owns local profile/connector binding, and resolves current provider access tokens from Heimdall by app-authenticated managed-credential calls.
landed_baseline:
- Shared architecture extracted into its own repo and renamed to Heimdall.
@ -85,6 +86,8 @@ landed_baseline:
- Signing-key persistence/bootstrap, managed token encryption at rest, and a local verifier seam landed for the first real consumer integration pass.
- Direct backend callback handoff landed as the preferred same-host app-consumer path, with browser completion-code exchange retained as fallback.
- Repixelizer's hosted web layer now creates Heimdall auth attempts, receives direct backend callbacks, verifies Heimdall Ed25519 access tokens locally, and adopts httpOnly local sessions.
- Heimdall now has generic Twitch and YouTube OAuth callback runtimes plus an app-authenticated managed-credential resolve endpoint for app-owned connector runtimes.
- StreamPixels now starts viewer and creator Twitch/YouTube OAuth through Heimdall, redeems Heimdall completion codes server-side, writes its own viewer sessions and creator connector bindings, and leaves provider refresh-token custody in Heimdall.
rejected_paths:
- name: per_app_auth_copy_paste
@ -95,5 +98,5 @@ rejected_paths:
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
current_status:
summary: Heimdall has a live Yggdrasil deployment for the Repixelizer auth path: TypeScript/Fastify runtime behind nginx/TLS at `https://heimdall.gamecult.org`, Ed25519 JWT signing with bootstrapped file-backed key support, JWKS/discovery exposure, signed OAuth start-state handling, Postgres-backed auth/control-plane persistence, real Discord and Patreon callback runtimes for Repixelizer, AES-256-GCM token sealing for managed provider credentials at rest, direct backend callback delivery for Repixelizer, and the older one-time completion-code exchange retained as fallback. Repixelizer is deployed at `https://repixelizer.gamecult.org` in Heimdall mode with Discord and Patreon as public providers, required access enabled, queue protection enabled, app-owned Discord guild/role policy, and app-owned Patreon tier-title policy sent to Heimdall in trusted backend-callback OAuth starts. Public health/discovery/JWKS/config checks pass, and public auth-start returns provider authorization URLs using the Heimdall callback plus Repixelizer backend handoff. GitHub, Twitch, and YouTube are catalogued providers but their callback runtimes are still not implemented. 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: Run the real browser OAuth flow end to end from `https://repixelizer.gamecult.org/app/` with either Discord access (`KLTST` or the Patreon-synced `Inner Sanctum` role) or Patreon access (currently entitled tier titled `Inner Sanctum`), confirm Repixelizer sends its caller-owned provider policy, Heimdall receives the provider callback, confirms entitlement, delivers the backend callback to Repixelizer, Repixelizer adopts a local httpOnly session, and hosted/queue routes are gated correctly. Public OAuth start URLs should request only the scopes they actually need.
summary: Heimdall has a live Yggdrasil deployment for the Repixelizer auth path and now a local StreamPixels integration slice. The service has Ed25519 JWT signing/JWKS, signed OAuth state, Postgres-backed auth/control-plane persistence, encrypted managed provider token custody, real Discord and Patreon callback runtimes for Repixelizer, and generic Twitch/YouTube callback runtimes for StreamPixels identity and managed creator connections. StreamPixels now delegates Twitch/YouTube OAuth to Heimdall, redeems one-time completion codes through its service, writes its own httpOnly viewer session, stores local profile/creator connector binding state, and asks Heimdall for current provider access tokens when runtime code needs them. The key boundary still holds: Heimdall owns provider OAuth and credential custody; StreamPixels owns viewer profiles, creator memberships, connector config, diagnostics, EventSub/live-chat runtime behavior, and audience data.
next_action: Deploy/configure the StreamPixels-Heimdall slice with Heimdall Twitch/YouTube provider credentials plus `GC_ACCESS_APP_SHARED_SECRET` on both sides, then run real browser flows for viewer claim/link and creator Twitch/YouTube connector attach. Confirm StreamPixels stores only local binding/runtime state for Heimdall-managed connector credentials and still gates creator/operator routes from local memberships.

View file

@ -20,6 +20,9 @@ function createTestConfig(): HeimdallConfig {
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: {
streampixels: "streampixels-secret",
},
storage: {
backend: "memory",
applySchemaOnStartup: true,
@ -576,4 +579,80 @@ describe("Heimdall service", () => {
})
);
});
it("resolves app-managed provider credentials without exposing refresh custody", async () => {
const store = new InMemoryStore();
const app = await buildApp({
config: createTestConfig(),
store,
oauthRuntimes: {
twitch: {
async exchangeAuthorizationCode() {
return {
accessToken: "twitch-access-token",
refreshToken: "twitch-refresh-token",
tokenType: "Bearer",
scope: ["user:read:email"],
expiresAt: "2026-04-26T13:00:00.000Z",
raw: { source: "test" },
};
},
async resolveIdentity() {
return {
provider: "twitch",
providerUserId: "twitch-user-123",
username: "pixelpaladin",
displayName: "PixelPaladin",
profile: { id: "twitch-user-123" },
};
},
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
},
},
});
apps.push(app);
const startResponse = await app.inject({
method: "POST",
url: "/v1/oauth/twitch/start",
payload: {
appSlug: "streampixels",
mode: "connect",
returnTo: "https://streampixels.gamecult.org/auth/connect",
},
});
const stateToken = startResponse.json().stateToken as string;
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
const accountId = callbackResponse.json().account.id as string;
const credentialResponse = await app.inject({
method: "POST",
url: "/v1/apps/streampixels/managed-credentials/resolve",
headers: {
"x-heimdall-app-secret": "streampixels-secret",
},
payload: {
accountId,
provider: "twitch",
},
});
expect(credentialResponse.statusCode).toBe(200);
expect(credentialResponse.json()).toEqual(
expect.objectContaining({
accountId,
provider: "twitch",
providerUserId: "twitch-user-123",
accessToken: "twitch-access-token",
scopes: ["user:read:email"],
})
);
expect(credentialResponse.body).not.toContain("twitch-refresh-token");
});
});

View file

@ -17,6 +17,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
appSharedSecrets: {},
storage: {
backend: "memory",
applySchemaOnStartup: true,