Add Bifrost Patreon support sync
This commit is contained in:
parent
18e2bf812e
commit
207d3b9c87
8 changed files with 477 additions and 35 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
174
src/app.ts
174
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<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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
163
src/oauth.ts
163
src/oauth.ts
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue