From 207d3b9c87d631d37d6057205ce98790c87705fe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 9 Jun 2026 20:22:58 +0100 Subject: [PATCH] Add Bifrost Patreon support sync --- docs/app-profiles/bifrost.md | 30 ++++++ notes/fresh-workspace-handoff.md | 13 +++ src/app.ts | 174 ++++++++++++++++++++++++++++++- src/config.ts | 10 ++ src/oauth.ts | 163 +++++++++++++++++++++++------ state/evidence.jsonl | 1 + state/map.yaml | 6 +- tests/app.test.ts | 115 ++++++++++++++++++++ 8 files changed, 477 insertions(+), 35 deletions(-) diff --git a/docs/app-profiles/bifrost.md b/docs/app-profiles/bifrost.md index 8af9797..99d37b6 100644 --- a/docs/app-profiles/bifrost.md +++ b/docs/app-profiles/bifrost.md @@ -11,6 +11,8 @@ Heimdall owns: - provider token custody and refresh - Discord role entitlement evaluation - Patreon tier entitlement evaluation +- Patreon membership profile reads used for Bifrost support sync +- signed delivery of normalized patron support facts to Bifrost - signed app claims and backend handoff delivery Bifrost owns: @@ -46,3 +48,31 @@ policies because a browser caller could forge the policy. Heimdall does not own Bifrost ledgers, patron points, votes, payout proposals, or work routing. + +## Patron Support Sync + +Bifrost patronage uses the same linked Patreon identity and membership reader as +the Repixelizer entitlement path. There is not a second Patreon parser in +Bifrost. + +`POST /v1/apps/bifrost/patron-support/sync` is an app-authenticated Heimdall +backend route. Bifrost or an operator job calls it with: + +- `accountId`: the Heimdall account already linked through Patreon OAuth +- `requiredTierTitle`: the Bifrost support tier title, usually `Inner Sanctum` +- optional `currencyCode` and `supportedAtUtc` fallback values + +Heimdall refreshes the stored Patreon credential if needed, fetches the Patreon +identity profile with memberships and currently entitled tiers, finds an active +paid membership for the requested tier title, and POSTs a signed +`RecurringSupportSnapshot` fact to Bifrost's +`/heimdall/patron-support/events` endpoint. + +The outbound fact uses `X-Heimdall-Signature-256` with the shared +`GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET`. Bifrost verifies that HMAC, resolves +the Heimdall account id to its local user account, deduplicates by provider +event id, records the support event, and derives patron points locally. + +This route is a bridge from auth-owned Patreon evidence to Bifrost-owned +governance meaning. Heimdall still does not own points, votes, tiers, ledger +history, or payout policy. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 70b4b99..a75b468 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -92,6 +92,12 @@ Do not trust this file for the exact live HEAD. Always check git. Bifrost sends caller-owned KTLST Discord role and Patreon tier policies through trusted backend-callback OAuth starts; Heimdall owns provider OAuth/token custody and configured backend callback allowlisting. +- Bifrost patron support sync now reuses Heimdall's existing linked Patreon + identity and membership reader. `POST /v1/apps/bifrost/patron-support/sync` + is app-authenticated, refreshes the stored Patreon credential if needed, + reads active paid Patreon tier evidence, and emits a signed + `RecurringSupportSnapshot` to Bifrost's + `/heimdall/patron-support/events` intake. - Public Yggdrasil Heimdall has been deployed with the Spotiverse/Spotify code path, Spotify provider credentials, a Spotiverse shared secret, and an allowlisted loopback portal callback at @@ -171,14 +177,21 @@ Bifrost needs browser verification after deployment: - configure Heimdall with `GC_ACCESS_APP_BIFROST_BACKEND_CALLBACK_URLS=https://bifrost.gamecult.org/auth/heimdall/callback` +- configure Heimdall with `GC_ACCESS_APP_BIFROST_SHARED_SECRET`, + `GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT=https://bifrost.gamecult.org/heimdall/patron-support/events`, + and `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET` - configure Bifrost with `Heimdall__DiscordGuildId`, `Heimdall__DiscordAllowedRoleIds__0=`, and `Heimdall__PatreonTierTitle=Inner Sanctum` +- configure Bifrost with the matching `Heimdall__PatronSupportIntakeSecret` - start Discord sign-in from `https://bifrost.gamecult.org/` with a user that has the KTLST cult-member role - start Patreon sign-in with a user that has the configured active tier - confirm Bifrost creates/updates a local account and active standard membership while Heimdall keeps provider token custody +- trigger `/v1/apps/bifrost/patron-support/sync` for that linked Heimdall + account and confirm Bifrost records a Patreon recurring-support snapshot + without owning Patreon webhook or token custody ## Immediate Re-entry Instruction diff --git a/src/app.ts b/src/app.ts index 8feeb85..2c29710 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,4 @@ -import { randomUUID, timingSafeEqual } from "node:crypto"; +import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import cors from "@fastify/cors"; import Fastify, { type FastifyInstance } from "fastify"; import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js"; @@ -26,7 +26,12 @@ import { renderBrowserHandoffPage } from "./browser-handoff.js"; import { type HeimdallConfig, loadConfig } from "./config.js"; import { createTokenCustody, type TokenCustody } from "./custody.js"; import { grantFact } from "./facts.js"; -import { createOAuthRuntimeRegistry, type OAuthRuntimeRegistry } from "./oauth.js"; +import { + createOAuthRuntimeRegistry, + fetchPatreonIdentity, + summarizePatreonMemberships, + type OAuthRuntimeRegistry, +} from "./oauth.js"; import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; import { createStore, type HeimdallStore } from "./store/index.js"; @@ -320,6 +325,15 @@ function secretMatches(expected: string | undefined, provided: string | undefine return expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer); } +function signBifrostPatronSupportPayload(secret: string, body: string): string { + const signature = createHmac("sha256", secret).update(body).digest("hex"); + return `sha256=${signature}`; +} + +function centsToDecimalString(cents: number): string { + return (cents / 100).toFixed(2); +} + async function refreshLinkedIdentityCredentialIfNeeded(options: { config: HeimdallConfig; store: HeimdallStore; @@ -1420,6 +1434,162 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + "/v1/apps/:appSlug/patron-support/sync", + { + schema: { + params: { + type: "object", + required: ["appSlug"], + additionalProperties: false, + properties: { + appSlug: { type: "string", enum: [...appSlugs] }, + }, + }, + body: { + type: "object", + required: ["accountId", "requiredTierTitle"], + additionalProperties: false, + properties: { + accountId: { type: "string", minLength: 1 }, + requiredTierTitle: { type: "string", minLength: 1 }, + currencyCode: { type: "string", minLength: 3, maxLength: 3 }, + supportedAtUtc: { type: "string", minLength: 1 }, + }, + }, + }, + }, + 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.slug !== "bifrost") { + reply.code(400); + return { error: "patron_support_sync_only_available_for_bifrost", appSlug: profile.slug }; + } + + if (!config.bifrostPatronSupportEndpoint || !config.bifrostPatronSupportSecret) { + reply.code(503); + return { error: "bifrost_patron_support_not_configured" }; + } + + const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, "patreon"); + if (!storedLinkedIdentity?.accessTokenEncrypted) { + reply.code(404); + return { error: "patreon_identity_not_linked" }; + } + + const now = new Date().toISOString(); + const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({ + config, + store, + tokenCustody, + oauthRuntimes, + linkedIdentity: storedLinkedIdentity, + now, + }); + if (!linkedIdentity.accessTokenEncrypted) { + reply.code(404); + return { error: "patreon_identity_not_linked" }; + } + + const patreonProfile = await fetchPatreonIdentity(tokenCustody.decrypt(linkedIdentity.accessTokenEncrypted)); + const memberships = summarizePatreonMemberships(patreonProfile); + const activeMembership = memberships.find( + (membership) => + membership.isActivePaid && + membership.currentlyEntitledAmountCents > 0 && + membership.tierTitles.includes(request.body.requiredTierTitle) + ); + + if (!activeMembership) { + reply.code(404); + return { + error: "active_patreon_membership_not_found", + accountId: request.body.accountId, + requiredTierTitle: request.body.requiredTierTitle, + membershipCount: memberships.length, + }; + } + + const supportedAtUtc = request.body.supportedAtUtc ?? now; + const supportedDay = supportedAtUtc.slice(0, 10); + const currencyCode = activeMembership.campaignCurrency ?? request.body.currencyCode ?? "USD"; + const supportFact = { + heimdallAccountId: request.body.accountId, + provider: "Patreon", + providerEventId: [ + "patreon-membership-snapshot", + linkedIdentity.providerUserId, + activeMembership.memberId, + supportedDay, + activeMembership.currentlyEntitledAmountCents, + ].join(":"), + kind: "RecurringSupportSnapshot", + amount: Number(centsToDecimalString(activeMembership.currentlyEntitledAmountCents)), + currencyCode, + externalSupportId: `patreon-member:${activeMembership.memberId}`, + supportedAtUtc, + isCurrentRecurringSupport: true, + providerPayerId: linkedIdentity.providerUserId, + providerSubscriptionId: activeMembership.memberId, + notes: `Verified active Patreon membership for tier ${request.body.requiredTierTitle}.`, + }; + const body = JSON.stringify(supportFact); + const bifrostResponse = await fetch(config.bifrostPatronSupportEndpoint, { + method: "POST", + headers: { + "content-type": "application/json", + "x-heimdall-signature-256": signBifrostPatronSupportPayload(config.bifrostPatronSupportSecret, body), + }, + body, + }); + const bifrostBody = await bifrostResponse.text(); + + await store.createAuditEvent({ + accountId: request.body.accountId, + appSlug: "bifrost", + eventType: bifrostResponse.ok ? "bifrost_patron_support_synced" : "bifrost_patron_support_sync_failed", + eventPayloadJson: { + provider: "patreon", + requiredTierTitle: request.body.requiredTierTitle, + providerEventId: supportFact.providerEventId, + bifrostStatus: bifrostResponse.status, + bifrostResponse: bifrostBody, + }, + createdAt: now, + }); + + if (!bifrostResponse.ok) { + reply.code(502); + return { + error: "bifrost_patron_support_rejected", + statusCode: bifrostResponse.status, + detail: bifrostBody, + }; + } + + return { + status: "synced", + supportFact, + bifrostResponse: bifrostBody, + }; + } + ); + app.post<{ Params: { appSlug: AppSlug }; Body: { accountId: string; provider: Provider }; diff --git a/src/config.ts b/src/config.ts index 48d6c14..f34c7ff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,8 @@ export interface HeimdallConfig { tokenEncryptionKeyBase64?: string; appSharedSecrets: Partial>; appBackendCallbacks: Partial>; + bifrostPatronSupportEndpoint?: string; + bifrostPatronSupportSecret?: string; storage: StorageConfig; providers: Record; } @@ -147,6 +149,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig config.tokenEncryptionKeyBase64 = env.GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64; } + if (env.GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT) { + config.bifrostPatronSupportEndpoint = env.GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT; + } + + if (env.GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET) { + config.bifrostPatronSupportSecret = env.GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET; + } + if (env.GC_ACCESS_DATABASE_URL) { config.storage.databaseUrl = env.GC_ACCESS_DATABASE_URL; } diff --git a/src/oauth.ts b/src/oauth.ts index af2ffeb..564b7b8 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -632,13 +632,16 @@ async function resolveDiscordIdentity(options: { accessToken: string }): Promise return identity; } -async function fetchPatreonIdentity(accessToken: string): Promise> { +export async function fetchPatreonIdentity(accessToken: string): Promise> { const url = new URL("https://www.patreon.com/api/oauth2/v2/identity"); url.searchParams.set("include", "memberships.currently_entitled_tiers,memberships.campaign"); url.searchParams.set("fields[user]", "email,full_name,image_url,thumb_url,url,vanity"); - url.searchParams.set("fields[member]", "last_charge_status,patron_status"); + url.searchParams.set( + "fields[member]", + "currently_entitled_amount_cents,last_charge_date,last_charge_status,patron_status,pledge_relationship_start" + ); url.searchParams.set("fields[tier]", "title"); - url.searchParams.set("fields[campaign]", "creation_name,url"); + url.searchParams.set("fields[campaign]", "creation_name,currency,url"); return fetchJson>( url, @@ -1030,6 +1033,127 @@ function relatedPatreonTierIds(member: Record): string[] { .filter((id): id is string => id !== undefined); } +function relatedPatreonCampaignId(member: Record): string | undefined { + const relationships = member.relationships; + if (typeof relationships !== "object" || relationships === null) { + return undefined; + } + + const campaign = (relationships as Record).campaign; + if (typeof campaign !== "object" || campaign === null) { + return undefined; + } + + const data = (campaign as Record).data; + if (typeof data !== "object" || data === null) { + return undefined; + } + + const record = data as Record; + return record.type === "campaign" && typeof record.id === "string" ? record.id : undefined; +} + +function includedPatreonCampaignById(profile: Record): Map> { + const included = profile.included; + const campaigns = new Map>(); + if (!Array.isArray(included)) { + return campaigns; + } + + for (const entry of included) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const record = entry as Record; + if (record.type === "campaign" && typeof record.id === "string") { + campaigns.set(record.id, record); + } + } + + return campaigns; +} + +function patreonTierTitle(tier: Record): string | undefined { + const attributes = tier.attributes; + if (typeof attributes !== "object" || attributes === null) { + return undefined; + } + + const title = (attributes as Record).title; + return typeof title === "string" ? title : undefined; +} + +function patreonMemberAttributes(member: Record): Record { + const attributes = member.attributes; + return typeof attributes === "object" && attributes !== null ? attributes as Record : {}; +} + +function patreonMemberIsActivePaid(attributes: Record): boolean { + const patronStatus = attributes.patron_status; + const lastChargeStatus = attributes.last_charge_status; + return patronStatus === "active_patron" && (lastChargeStatus === undefined || lastChargeStatus === null || lastChargeStatus === "Paid"); +} + +export interface PatreonMembershipSummary { + memberId: string; + campaignId?: string; + campaignCurrency?: string; + tierTitles: string[]; + patronStatus?: string; + lastChargeStatus?: string; + currentlyEntitledAmountCents: number; + lastChargeDate?: string; + pledgeRelationshipStart?: string; + isActivePaid: boolean; +} + +export function summarizePatreonMemberships(profile: Record): PatreonMembershipSummary[] { + const tiersById = includedPatreonTierById(profile); + const campaignsById = includedPatreonCampaignById(profile); + + return includedPatreonMembers(profile) + .filter((member) => typeof member.id === "string") + .map((member) => { + const attributes = patreonMemberAttributes(member); + const campaignId = relatedPatreonCampaignId(member); + const campaign = campaignId ? campaignsById.get(campaignId) : undefined; + const campaignAttributes = + campaign && typeof campaign.attributes === "object" && campaign.attributes !== null + ? campaign.attributes as Record + : {}; + const campaignCurrency = + typeof campaignAttributes.currency === "string" + ? campaignAttributes.currency + : undefined; + const currentlyEntitledAmountCents = + typeof attributes.currently_entitled_amount_cents === "number" ? attributes.currently_entitled_amount_cents : 0; + const patronStatus = typeof attributes.patron_status === "string" ? attributes.patron_status : undefined; + const lastChargeStatus = + typeof attributes.last_charge_status === "string" ? attributes.last_charge_status : undefined; + const lastChargeDate = typeof attributes.last_charge_date === "string" ? attributes.last_charge_date : undefined; + const pledgeRelationshipStart = + typeof attributes.pledge_relationship_start === "string" ? attributes.pledge_relationship_start : undefined; + const tierTitles = relatedPatreonTierIds(member) + .map((tierId) => tiersById.get(tierId)) + .filter((tier): tier is Record => tier !== undefined) + .map((tier) => patreonTierTitle(tier)) + .filter((title): title is string => title !== undefined); + + return { + memberId: member.id as string, + ...(campaignId !== undefined ? { campaignId } : {}), + ...(campaignCurrency !== undefined ? { campaignCurrency } : {}), + tierTitles, + ...(patronStatus !== undefined ? { patronStatus } : {}), + ...(lastChargeStatus !== undefined ? { lastChargeStatus } : {}), + currentlyEntitledAmountCents, + ...(lastChargeDate !== undefined ? { lastChargeDate } : {}), + ...(pledgeRelationshipStart !== undefined ? { pledgeRelationshipStart } : {}), + isActivePaid: patreonMemberIsActivePaid(attributes), + }; + }); +} + async function evaluatePatreonEntitlements(options: { callback: OAuthCallbackContext; tokenSet: OAuthTokenSet; @@ -1041,33 +1165,10 @@ async function evaluatePatreonEntitlements(options: { const appSlug = options.callback.appSlug; const profile = await fetchPatreonIdentity(options.tokenSet.accessToken); - const members = includedPatreonMembers(profile); - const tiersById = includedPatreonTierById(profile); - const activeMembers = members.filter((member) => { - const attributes = member.attributes; - if (typeof attributes !== "object" || attributes === null) { - return false; - } - - const patronStatus = (attributes as Record).patron_status; - const lastChargeStatus = (attributes as Record).last_charge_status; - const relatedTiers = relatedPatreonTierIds(member) - .map((tierId) => tiersById.get(tierId)) - .filter((tier): tier is Record => tier !== undefined); - const tierTitleMatches = relatedTiers.some((tier) => { - const tierAttributes = tier.attributes; - return ( - typeof tierAttributes === "object" && - tierAttributes !== null && - (tierAttributes as Record).title === policy.requiredTierTitle - ); - }); - return ( - tierTitleMatches && - patronStatus === "active_patron" && - (lastChargeStatus === undefined || lastChargeStatus === null || lastChargeStatus === "Paid") - ); - }); + const members = summarizePatreonMemberships(profile); + const activeMembers = members.filter( + (member) => member.isActivePaid && member.tierTitles.includes(policy.requiredTierTitle) + ); const isAllowed = activeMembers.length > 0; return { @@ -1088,7 +1189,7 @@ async function evaluatePatreonEntitlements(options: { rawSummaryJson: { requiredTierTitle: policy.requiredTierTitle, memberCount: members.length, - entitledTierCount: tiersById.size, + entitledTierCount: new Set(members.flatMap((member) => member.tierTitles)).size, matchingMemberCount: activeMembers.length, }, }, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 6330f57..c01dd68 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -27,3 +27,4 @@ {"ts": "2026-06-02T18:25:05+00:00", "type": "implementation", "status": "accepted", "note": "Public Heimdall can now hand Spotiverse OAuth results to a configured portal callback URL via GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS; backend_callback handoffs are rejected unless the app callback is trusted, preserving token-bearing handoff boundaries."} {"ts": "2026-06-02T18:36:46+00:00", "type": "deployment", "status": "ok", "note": "Public Yggdrasil Heimdall was deployed with the Spotify/Spotiverse provider code, Spotify credentials from secrets/spotify.txt, a generated Spotiverse app shared secret, and GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS set to the Yggdrasil loopback reverse-forward callback. Local Spotiverse now points at public Heimdall, and the Yggdrasil SSH tunnel exposes 127.0.0.1:18796 back to local Spotiverse :8796."} {"ts": "2026-06-08T20:02:27+00:00", "type": "implementation", "status": "accepted", "note": "Added Bifrost as a Heimdall app profile for Discord/Patreon member access. Bifrost owns KTLST role and Patreon tier policies through backend-callback OAuth starts; Heimdall owns provider OAuth/token custody and generic configured backend callback allowlisting."} +{"ts": "2026-06-09T19:21:17+00:00", "type": "implementation", "status": "accepted", "note": "Bifrost patron support sync reuses Heimdall's existing linked Patreon identity and membership reader. Heimdall exposes app-authenticated /v1/apps/bifrost/patron-support/sync, refreshes Patreon custody when needed, maps active paid tier membership into a signed RecurringSupportSnapshot, and posts it to Bifrost's /heimdall/patron-support/events intake; Bifrost owns points, tiers, voting weight, and ledger meaning."} diff --git a/state/map.yaml b/state/map.yaml index f5870ca..391de09 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -80,6 +80,7 @@ accepted_design: - 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. - Bifrost is a Heimdall app profile for Discord/Patreon member access. Bifrost owns the KTLST Discord role policy and Patreon tier policy; Heimdall owns provider OAuth, token custody, entitlement evaluation, and configured backend callback trust. + - Bifrost patron support sync reuses Heimdall's existing linked Patreon identity and membership reader. Heimdall turns active paid Patreon tier evidence into signed support facts; Bifrost owns point/tier/vote derivation. landed_baseline: - Shared architecture extracted into its own repo and renamed to Heimdall. @@ -96,6 +97,7 @@ landed_baseline: - 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. - Spotiverse now has a Heimdall app profile and Spotify provider runtime for managed credential custody. - Bifrost now has a Heimdall app profile for Discord/Patreon member access, and backend callback trust is checked through configured app callback allowlists before legacy app-specific defaults. + - Heimdall exposes `POST /v1/apps/bifrost/patron-support/sync` as an app-authenticated bridge from linked Patreon membership evidence to Bifrost's signed patron support intake endpoint. - A read-only `gamecult.eve.provider_advertisement.v1` export now advertises Heimdall provider/app surfaces plus redacted CultCache witness document paths for Odin/Eve discovery. rejected_paths: @@ -107,5 +109,5 @@ rejected_paths: reason: reusing auth mechanics does not justify centralizing app-domain audience or product data current_status: - summary: Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels auth migration slice. Heimdall has Twitch/YouTube provider credentials, the shared StreamPixels app secret, generic Twitch/YouTube identity and managed-credential custody runtimes, and a live fix for Twitch token responses that return scopes as arrays. Live StreamPixels Twitch sign-in through Heimdall has succeeded and redeemed a completion code. StreamPixels delegates Twitch/YouTube OAuth to Heimdall, redeems one-time completion codes server-side, writes its own httpOnly viewer session, stores local profile/creator binding state, and asks Heimdall for current provider access tokens when runtime code needs them. Spotiverse is added as a Heimdall app profile with Spotify as a managed credential provider. Bifrost is added as a Heimdall app profile for Discord/Patreon member access through backend-callback handoff, with Bifrost owning KTLST role and Patreon tier policies while Heimdall owns provider OAuth/token custody and callback allowlist enforcement. The key boundary still holds: Heimdall owns provider OAuth and credential custody; host apps own local sessions, route/resource checks, runtime behavior, and app-domain data. - next_action: Run a real browser Spotiverse `/auth/login` flow through public Heimdall and the Yggdrasil reverse-forward callback, then verify `/v1/apps/spotiverse/managed-credentials/resolve` returns only an access-token projection. After that, return to the remaining StreamPixels browser verification. + summary: Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels auth migration slice. Heimdall has Twitch/YouTube provider credentials, the shared StreamPixels app secret, generic Twitch/YouTube identity and managed-credential custody runtimes, and a live fix for Twitch token responses that return scopes as arrays. Live StreamPixels Twitch sign-in through Heimdall has succeeded and redeemed a completion code. StreamPixels delegates Twitch/YouTube OAuth to Heimdall, redeems one-time completion codes server-side, writes its own httpOnly viewer session, stores local profile/creator binding state, and asks Heimdall for current provider access tokens when runtime code needs them. Spotiverse is added as a Heimdall app profile with Spotify as a managed credential provider. Bifrost is added as a Heimdall app profile for Discord/Patreon member access through backend-callback handoff, with Bifrost owning KTLST role and Patreon tier policies while Heimdall owns provider OAuth/token custody and callback allowlist enforcement. Bifrost patron support sync now reuses Heimdall's linked Patreon identity and membership reader to emit signed recurring-support facts to Bifrost; Bifrost still owns ledgers, patron points, voting weight, and audit meaning. The key boundary still holds: Heimdall owns provider OAuth and credential custody; host apps own local sessions, route/resource checks, runtime behavior, and app-domain data. + next_action: Deploy Heimdall with `GC_ACCESS_APP_BIFROST_SHARED_SECRET`, `GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT`, and `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET`, then trigger `/v1/apps/bifrost/patron-support/sync` for a linked Patreon account and verify Bifrost records the recurring support snapshot. After that, return to Spotiverse and StreamPixels browser verification. diff --git a/tests/app.test.ts b/tests/app.test.ts index fa35cc2..1275cea 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -112,6 +112,7 @@ function getHeimdallContext(app: FastifyInstance) { publicJwk: { alg: "EdDSA"; kid: string; use: "sig"; [key: string]: unknown }; }; tokenCustody: { + encrypt(plaintext: string): string; decrypt(ciphertext: string): string; }; store: InMemoryStore; @@ -476,6 +477,120 @@ describe("Heimdall service", () => { } }); + it("syncs a linked Patreon membership into a signed Bifrost support fact", async () => { + const baseConfig = createTestConfig(); + const config = { + ...baseConfig, + appSharedSecrets: { + ...baseConfig.appSharedSecrets, + bifrost: "bifrost-secret", + }, + bifrostPatronSupportEndpoint: "https://bifrost.gamecult.org/heimdall/patron-support/events", + bifrostPatronSupportSecret: "bifrost-support-secret", + }; + const store = new InMemoryStore(); + const app = await buildApp({ config, store }); + apps.push(app); + + const context = getHeimdallContext(app); + const now = "2026-06-09T17:00:00.000Z"; + await store.createAccount({ + id: "heimdall-account-123", + createdAt: now, + lastSeenAt: now, + displayName: "Patron", + }); + await store.upsertLinkedIdentity({ + accountId: "heimdall-account-123", + provider: "patreon", + providerUserId: "patreon-user-456", + accessTokenEncrypted: context.tokenCustody.encrypt("patreon-access-token"), + refreshTokenEncrypted: context.tokenCustody.encrypt("patreon-refresh-token"), + tokenExpiresAt: "2099-06-09T18:00:00.000Z", + scopes: ["identity", "identity[email]"], + profileJson: { id: "patreon-user-456" }, + createdAt: now, + updatedAt: now, + }); + + let bifrostBody = ""; + let bifrostSignature = ""; + globalThis.fetch = async (input, init) => { + const url = String(input); + if (url.startsWith("https://www.patreon.com/api/oauth2/v2/identity")) { + expect(init?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer patreon-access-token" })); + return new Response( + JSON.stringify({ + data: { id: "patreon-user-456", type: "user", attributes: { full_name: "Patron" } }, + included: [ + { + id: "member-789", + type: "member", + attributes: { + currently_entitled_amount_cents: 1500, + last_charge_status: "Paid", + patron_status: "active_patron", + }, + relationships: { + currently_entitled_tiers: { data: [{ id: "tier-inner", type: "tier" }] }, + campaign: { data: { id: "campaign-1", type: "campaign" } }, + }, + }, + { id: "tier-inner", type: "tier", attributes: { title: "Inner Sanctum" } }, + { id: "campaign-1", type: "campaign", attributes: { currency: "USD" } }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (url === "https://bifrost.gamecult.org/heimdall/patron-support/events") { + bifrostBody = String(init?.body ?? ""); + const headers = init?.headers as Record; + bifrostSignature = headers["x-heimdall-signature-256"] ?? ""; + return new Response("processed", { status: 200 }); + } + + return new Response("unexpected request", { status: 500 }); + }; + + const response = await app.inject({ + method: "POST", + url: "/v1/apps/bifrost/patron-support/sync", + headers: { + "x-heimdall-app-secret": "bifrost-secret", + }, + payload: { + accountId: "heimdall-account-123", + requiredTierTitle: "Inner Sanctum", + supportedAtUtc: now, + }, + }); + + expect(response.statusCode, response.body).toBe(200); + expect(bifrostSignature).toMatch(/^sha256=[0-9a-f]{64}$/); + expect(JSON.parse(bifrostBody)).toEqual({ + heimdallAccountId: "heimdall-account-123", + provider: "Patreon", + providerEventId: "patreon-membership-snapshot:patreon-user-456:member-789:2026-06-09:1500", + kind: "RecurringSupportSnapshot", + amount: 15, + currencyCode: "USD", + externalSupportId: "patreon-member:member-789", + supportedAtUtc: now, + isCurrentRecurringSupport: true, + providerPayerId: "patreon-user-456", + providerSubscriptionId: "member-789", + notes: "Verified active Patreon membership for tier Inner Sanctum.", + }); + expect(response.json()).toEqual( + expect.objectContaining({ + status: "synced", + bifrostResponse: "processed", + }) + ); + }); + it("redeems a completion code exactly once", async () => { const app = await buildApp({ config: createTestConfig(),