From 18e2bf812eca46b8d7740c1dd666daedc17f1fbf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 8 Jun 2026 21:04:51 +0100 Subject: [PATCH] Add Bifrost Heimdall app profile --- docs/app-profiles/bifrost.md | 48 +++++++++++++++++ notes/fresh-workspace-handoff.md | 17 ++++++ src/app-profiles.ts | 27 ++++++++++ src/app.ts | 14 +++-- src/contracts.ts | 2 +- src/oauth.ts | 26 ++++----- state/evidence.jsonl | 1 + state/map.yaml | 4 +- tests/app.test.ts | 92 ++++++++++++++++++++++++++++++++ tests/verse-witness.test.ts | 1 + 10 files changed, 206 insertions(+), 26 deletions(-) create mode 100644 docs/app-profiles/bifrost.md diff --git a/docs/app-profiles/bifrost.md b/docs/app-profiles/bifrost.md new file mode 100644 index 0000000..8af9797 --- /dev/null +++ b/docs/app-profiles/bifrost.md @@ -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. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 20e7019..70b4b99 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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=`, 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 diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 4251ead..e5b0c70 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -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 = { repixelizer: repixelizerProfile, streampixels: streampixelsProfile, spotiverse: spotiverseProfile, + bifrost: bifrostProfile, }; export function getAppProfile(appSlug: AppSlug): AppProfile { diff --git a/src/app.ts b/src/app.ts index e7ee39c..8feeb85 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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; diff --git a/src/contracts.ts b/src/contracts.ts index 9f34c33..df03b00 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -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]; diff --git a/src/oauth.ts b/src/oauth.ts index e81ca47..af2ffeb 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -888,14 +888,11 @@ async function evaluateDiscordEntitlements(options: { callback: OAuthCallbackContext; tokenSet: OAuthTokenSet; }): Promise { - 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 { - 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, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index f8c02d3..6330f57 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/map.yaml b/state/map.yaml index 107aba8..f5870ca 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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. diff --git a/tests/app.test.ts b/tests/app.test.ts index fca3159..fa35cc2 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -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({ diff --git a/tests/verse-witness.test.ts b/tests/verse-witness.test.ts index 1f79a92..cd91132 100644 --- a/tests/verse-witness.test.ts +++ b/tests/verse-witness.test.ts @@ -36,6 +36,7 @@ describe("Heimdall Verse witness advertisement", () => { "repixelizer", "streampixels", "spotiverse", + "bifrost", ]); expect(advertisement.documents.map((document) => document.schemaId)).toEqual([...heimdallWitnessSchemaIds]); });