Add Bifrost Heimdall app profile

This commit is contained in:
Meta Vens 2026-06-08 21:04:51 +01:00
commit 18e2bf812e
10 changed files with 206 additions and 26 deletions

View file

@ -0,0 +1,48 @@
# Bifrost App Profile
Bifrost uses Heimdall for shared GameCult membership signals while keeping
Bifrost-domain state local.
## Ownership
Heimdall owns:
- Discord and Patreon OAuth
- provider token custody and refresh
- Discord role entitlement evaluation
- Patreon tier entitlement evaluation
- signed app claims and backend handoff delivery
Bifrost owns:
- local `UserAccount` and membership records
- work, motions, ledgers, patron support events, and governance weight
- the decision to turn a Heimdall member-access claim into active Bifrost
membership
- provider-derived patron credit rules and receipt projection
## Current Access Policy
Bifrost accepts two Heimdall entitlement policies:
- Discord role access for the GameCult guild, with the deployment supplying the
KTLST/cult-member role id as the allowed role.
- Patreon membership access for the configured support tier title, currently
`Inner Sanctum` by default.
Both policies are caller-owned by Bifrost and must use `backend_callback`
handoff. Browser-completion handoffs must not carry app-owned entitlement
policies because a browser caller could forge the policy.
## Runtime Flow
1. Bifrost creates an auth attempt and asks Heimdall to start Discord or Patreon
OAuth with a server-owned entitlement policy.
2. Heimdall completes provider OAuth, evaluates the entitlement, and delivers
the result to `https://bifrost.gamecult.org/auth/heimdall/callback`.
3. Bifrost's browser waits at `/auth/heimdall/wait`.
4. When the callback arrives, Bifrost creates or updates its local user account,
activates standard membership, and issues its own app cookie.
Heimdall does not own Bifrost ledgers, patron points, votes, payout proposals,
or work routing.

View file

@ -88,6 +88,10 @@ Do not trust this file for the exact live HEAD. Always check git.
local Heimdall account binding and resolves current Spotify access through
`/v1/apps/spotiverse/managed-credentials/resolve` without storing provider
refresh tokens.
- Bifrost now has a `bifrost` app profile for Discord/Patreon member access.
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.
- 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
@ -163,6 +167,19 @@ Patreon access is title-gated on the currently entitled tier title
`Inner Sanctum`; do not use currency amount as the durable policy because
currencies are weird little traps.
Bifrost needs browser verification after deployment:
- configure Heimdall with
`GC_ACCESS_APP_BIFROST_BACKEND_CALLBACK_URLS=https://bifrost.gamecult.org/auth/heimdall/callback`
- configure Bifrost with `Heimdall__DiscordGuildId`,
`Heimdall__DiscordAllowedRoleIds__0=<KTLST-role-id>`, and
`Heimdall__PatreonTierTitle=Inner Sanctum`
- 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
## Immediate Re-entry Instruction
After compaction, first rehydrate and reorient from the listed files and git

View file

@ -171,10 +171,37 @@ const spotiverseProfile: AppProfile = {
},
};
const bifrostProfile: AppProfile = {
slug: "bifrost",
displayName: "Bifrost",
profileVersion: "2026-06-08",
identityProviders: ["discord", "patreon"],
entitlementSources: ["discord", "patreon"],
managedConnectionProviders: [],
capabilities: [
{
key: "member_access",
mode: "shared",
summary: "May enter the Bifrost member alpha through a Heimdall-verified GameCult membership signal.",
sharedRule: "entitlement.app_access || grant.global_member || grant.app_access",
},
],
evaluateSharedCapabilities(context) {
return hasAnyFact(context.facts, [
entitlementFacts.appAccess,
grantFacts.globalMember,
grantFacts.appAccess,
])
? ["member_access"]
: [];
},
};
export const appProfiles: Record<AppSlug, AppProfile> = {
repixelizer: repixelizerProfile,
streampixels: streampixelsProfile,
spotiverse: spotiverseProfile,
bifrost: bifrostProfile,
};
export function getAppProfile(appSlug: AppSlug): AppProfile {

View file

@ -463,18 +463,16 @@ function acceptsBackendCallback(config: HeimdallConfig, appSlug: AppSlug, handof
return false;
}
if ((config.appBackendCallbacks[appSlug] ?? []).includes(handoff.callbackUrl)) {
return true;
}
if (appSlug === "repixelizer") {
return (
handoff.callbackUrl === "https://repixelizer.gamecult.org/api/auth/heimdall/callback" ||
(config.appBackendCallbacks.repixelizer ?? []).includes(handoff.callbackUrl)
);
return handoff.callbackUrl === "https://repixelizer.gamecult.org/api/auth/heimdall/callback";
}
if (appSlug === "spotiverse") {
return (
handoff.callbackUrl === "http://127.0.0.1:8796/auth/heimdall/callback" ||
(config.appBackendCallbacks.spotiverse ?? []).includes(handoff.callbackUrl)
);
return handoff.callbackUrl === "http://127.0.0.1:8796/auth/heimdall/callback";
}
return false;

View file

@ -2,7 +2,7 @@ export const providers = ["discord", "patreon", "github", "twitch", "youtube", "
export type Provider = (typeof providers)[number];
export const appSlugs = ["repixelizer", "streampixels", "spotiverse"] as const;
export const appSlugs = ["repixelizer", "streampixels", "spotiverse", "bifrost"] as const;
export type AppSlug = (typeof appSlugs)[number];

View file

@ -888,14 +888,11 @@ async function evaluateDiscordEntitlements(options: {
callback: OAuthCallbackContext;
tokenSet: OAuthTokenSet;
}): Promise<EntitlementEvaluation> {
if (options.callback.appSlug !== "repixelizer") {
return { facts: [], snapshots: [] };
}
const policy = options.callback.entitlementPolicy;
if (!policy || policy.kind !== "discord_role_access") {
return { facts: [], snapshots: [] };
}
const appSlug = options.callback.appSlug;
const guildId = policy.guildId;
const allowedRoleIds = policy.allowedRoleIds;
@ -915,7 +912,7 @@ async function evaluateDiscordEntitlements(options: {
{
accountId: options.callback.accountId,
provider: "discord",
scope: `repixelizer:discord_member:${guildId}`,
scope: `${appSlug}:discord_member:${guildId}`,
evaluatedAt: new Date().toISOString(),
isAllowed: false,
reasonCode: "not_in_guild",
@ -950,16 +947,16 @@ async function evaluateDiscordEntitlements(options: {
{
accountId: options.callback.accountId,
provider: "discord",
scope: `repixelizer:discord_role_access:${guildId}`,
scope: `${appSlug}:discord_role_access:${guildId}`,
evaluatedAt: new Date().toISOString(),
isAllowed: matchedRoles.length > 0,
reasonCode: matchedRoles.length > 0 ? "matched_role" : "missing_role",
reasonDetail:
matchedRoles.length > 0
? "Matched a configured Repixelizer access role."
? `Matched a configured ${appSlug} access role.`
: allowedRoleIds.length
? "User is in the guild but lacks the configured Repixelizer access role."
: "Repixelizer did not supply any Discord role ids in its entitlement policy.",
? `User is in the guild but lacks the configured ${appSlug} access role.`
: `${appSlug} did not supply any Discord role ids in its entitlement policy.`,
rawSummaryJson: {
guildId,
roles,
@ -1037,14 +1034,11 @@ async function evaluatePatreonEntitlements(options: {
callback: OAuthCallbackContext;
tokenSet: OAuthTokenSet;
}): Promise<EntitlementEvaluation> {
if (options.callback.appSlug !== "repixelizer") {
return { facts: [], snapshots: [] };
}
const policy = options.callback.entitlementPolicy;
if (!policy || policy.kind !== "patreon_membership_access") {
return { facts: [], snapshots: [] };
}
const appSlug = options.callback.appSlug;
const profile = await fetchPatreonIdentity(options.tokenSet.accessToken);
const members = includedPatreonMembers(profile);
@ -1082,15 +1076,15 @@ async function evaluatePatreonEntitlements(options: {
{
accountId: options.callback.accountId,
provider: "patreon",
scope: "repixelizer:patreon_membership_access",
scope: `${appSlug}:patreon_membership_access`,
evaluatedAt: new Date().toISOString(),
isAllowed,
reasonCode: isAllowed ? "active_membership" : members.length ? "inactive_membership" : "missing_membership",
reasonDetail: isAllowed
? "Matched an active Patreon membership for Repixelizer access."
? `Matched an active Patreon membership for ${appSlug} access.`
: members.length
? "Patreon membership data was present but did not meet the active access policy."
: "Patreon did not return a membership matching the Repixelizer access policy.",
: `Patreon did not return a membership matching the ${appSlug} access policy.`,
rawSummaryJson: {
requiredTierTitle: policy.requiredTierTitle,
memberCount: members.length,

View file

@ -26,3 +26,4 @@
{"ts": "2026-06-02T15:25:08+00:00", "type": "implementation", "status": "accepted", "note": "Spotiverse now consumes Spotify as a Heimdall-managed credential: Heimdall owns Spotify OAuth/token custody, and Spotiverse resolves current access via /v1/apps/spotiverse/managed-credentials/resolve without storing provider refresh tokens."}
{"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."}

View file

@ -79,6 +79,7 @@ accepted_design:
- Discord and Patreon provider access tokens can refresh inside Heimdall before Repixelizer entitlement re-evaluation.
- 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.
landed_baseline:
- Shared architecture extracted into its own repo and renamed to Heimdall.
@ -94,6 +95,7 @@ landed_baseline:
- Heimdall now has generic Twitch and YouTube OAuth callback runtimes plus an app-authenticated managed-credential resolve endpoint for app-owned connector runtimes.
- StreamPixels now starts viewer and creator Twitch/YouTube OAuth through Heimdall, redeems Heimdall completion codes server-side, writes its own viewer sessions and creator connector bindings, and leaves provider refresh-token custody in Heimdall.
- 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.
- 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:
@ -105,5 +107,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 connector binding state, and asks Heimdall for current provider access tokens when runtime code needs them. Spotiverse is now added as a Heimdall app profile with Spotify as a managed credential provider, and Yggdrasil Heimdall has Spotify provider credentials plus a Spotiverse shared secret and loopback portal callback allowlist. Spotiverse should resolve access tokens from Heimdall and must not own Spotify refresh-token custody. Repixelizer access has a signed Heimdall refresh-token path. 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.
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.

View file

@ -26,6 +26,7 @@ function createTestConfig(): HeimdallConfig {
spotiverse: "spotiverse-secret",
},
appBackendCallbacks: {
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
spotiverse: ["https://spotiverse-portal.gamecult.org/auth/heimdall/callback"],
},
storage: {
@ -322,6 +323,97 @@ describe("Heimdall service", () => {
}
});
it("builds Bifrost Discord OAuth start state with caller-owned cult member role policy", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: {
appSlug: "bifrost",
mode: "sign_in",
returnTo: "https://bifrost.gamecult.org/auth/heimdall/wait?attemptId=bifrost-attempt-123&returnTo=%2FApp",
handoff: {
kind: "backend_callback",
attemptId: "bifrost-attempt-123",
callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback",
},
entitlementPolicy: {
kind: "discord_role_access",
guildId: "gamecult-guild",
allowedRoleIds: ["role-ktlst"],
},
},
});
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
expect.objectContaining({
typ: "heimdall_oauth_state",
provider: "discord",
app_slug: "bifrost",
entitlement_policy: {
kind: "discord_role_access",
guildId: "gamecult-guild",
allowedRoleIds: ["role-ktlst"],
},
})
);
}
});
it("builds Bifrost Patreon OAuth start state with caller-owned tier policy", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "POST",
url: "/v1/oauth/patreon/start",
payload: {
appSlug: "bifrost",
mode: "sign_in",
returnTo: "https://bifrost.gamecult.org/auth/heimdall/wait?attemptId=bifrost-attempt-123&returnTo=%2FApp",
handoff: {
kind: "backend_callback",
attemptId: "bifrost-attempt-123",
callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback",
},
entitlementPolicy: {
kind: "patreon_membership_access",
requiredTierTitle: "Inner Sanctum",
},
},
});
expect(response.statusCode).toBe(201);
const payload = response.json();
const authorizationUrl = new URL(payload.authorizationUrl as string);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
expect.objectContaining({
typ: "heimdall_oauth_state",
provider: "patreon",
app_slug: "bifrost",
entitlement_policy: {
kind: "patreon_membership_access",
requiredTierTitle: "Inner Sanctum",
},
})
);
}
});
it("completes a provider callback and issues a verified Repixelizer claim", async () => {
const store = new InMemoryStore();
const app = await buildApp({

View file

@ -36,6 +36,7 @@ describe("Heimdall Verse witness advertisement", () => {
"repixelizer",
"streampixels",
"spotiverse",
"bifrost",
]);
expect(advertisement.documents.map((document) => document.schemaId)).toEqual([...heimdallWitnessSchemaIds]);
});