Add Bifrost Heimdall app profile
This commit is contained in:
parent
38ed95667a
commit
18e2bf812e
10 changed files with 206 additions and 26 deletions
48
docs/app-profiles/bifrost.md
Normal file
48
docs/app-profiles/bifrost.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
14
src/app.ts
14
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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
26
src/oauth.ts
26
src/oauth.ts
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ describe("Heimdall Verse witness advertisement", () => {
|
|||
"repixelizer",
|
||||
"streampixels",
|
||||
"spotiverse",
|
||||
"bifrost",
|
||||
]);
|
||||
expect(advertisement.documents.map((document) => document.schemaId)).toEqual([...heimdallWitnessSchemaIds]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue