Add Bifrost Patreon support sync

This commit is contained in:
Meta Vens 2026-06-09 20:22:58 +01:00
commit 207d3b9c87
8 changed files with 477 additions and 35 deletions

View file

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

View file

@ -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=<KTLST-role-id>`, 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

View file

@ -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<FastifyIn
}
);
app.post<{
Params: { appSlug: AppSlug };
Body: {
accountId: string;
requiredTierTitle: string;
currencyCode?: string;
supportedAtUtc?: string;
};
}>(
"/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 };

View file

@ -28,6 +28,8 @@ export interface HeimdallConfig {
tokenEncryptionKeyBase64?: string;
appSharedSecrets: Partial<Record<AppSlug, string>>;
appBackendCallbacks: Partial<Record<AppSlug, string[]>>;
bifrostPatronSupportEndpoint?: string;
bifrostPatronSupportSecret?: string;
storage: StorageConfig;
providers: Record<Provider, ProviderClientConfig>;
}
@ -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;
}

View file

@ -632,13 +632,16 @@ async function resolveDiscordIdentity(options: { accessToken: string }): Promise
return identity;
}
async function fetchPatreonIdentity(accessToken: string): Promise<Record<string, unknown>> {
export async function fetchPatreonIdentity(accessToken: string): Promise<Record<string, unknown>> {
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<Record<string, unknown>>(
url,
@ -1030,6 +1033,127 @@ function relatedPatreonTierIds(member: Record<string, unknown>): string[] {
.filter((id): id is string => id !== undefined);
}
function relatedPatreonCampaignId(member: Record<string, unknown>): string | undefined {
const relationships = member.relationships;
if (typeof relationships !== "object" || relationships === null) {
return undefined;
}
const campaign = (relationships as Record<string, unknown>).campaign;
if (typeof campaign !== "object" || campaign === null) {
return undefined;
}
const data = (campaign as Record<string, unknown>).data;
if (typeof data !== "object" || data === null) {
return undefined;
}
const record = data as Record<string, unknown>;
return record.type === "campaign" && typeof record.id === "string" ? record.id : undefined;
}
function includedPatreonCampaignById(profile: Record<string, unknown>): Map<string, Record<string, unknown>> {
const included = profile.included;
const campaigns = new Map<string, Record<string, unknown>>();
if (!Array.isArray(included)) {
return campaigns;
}
for (const entry of included) {
if (typeof entry !== "object" || entry === null) {
continue;
}
const record = entry as Record<string, unknown>;
if (record.type === "campaign" && typeof record.id === "string") {
campaigns.set(record.id, record);
}
}
return campaigns;
}
function patreonTierTitle(tier: Record<string, unknown>): string | undefined {
const attributes = tier.attributes;
if (typeof attributes !== "object" || attributes === null) {
return undefined;
}
const title = (attributes as Record<string, unknown>).title;
return typeof title === "string" ? title : undefined;
}
function patreonMemberAttributes(member: Record<string, unknown>): Record<string, unknown> {
const attributes = member.attributes;
return typeof attributes === "object" && attributes !== null ? attributes as Record<string, unknown> : {};
}
function patreonMemberIsActivePaid(attributes: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>
: {};
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<string, unknown> => 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<string, unknown>).patron_status;
const lastChargeStatus = (attributes as Record<string, unknown>).last_charge_status;
const relatedTiers = relatedPatreonTierIds(member)
.map((tierId) => tiersById.get(tierId))
.filter((tier): tier is Record<string, unknown> => tier !== undefined);
const tierTitleMatches = relatedTiers.some((tier) => {
const tierAttributes = tier.attributes;
return (
typeof tierAttributes === "object" &&
tierAttributes !== null &&
(tierAttributes as Record<string, unknown>).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,
},
},

View file

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

View file

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

View file

@ -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<string, string>;
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(),