diff --git a/docs/app-profiles/ghostlight.md b/docs/app-profiles/ghostlight.md new file mode 100644 index 0000000..7d433f4 --- /dev/null +++ b/docs/app-profiles/ghostlight.md @@ -0,0 +1,25 @@ +# Ghostlight Dungeon Access Profile + +Ghostlight Dungeon reuses Heimdall's browser-completion Discord sign-in path. +Heimdall owns provider OAuth, local account identity, signed access claims, and +refresh. Ghostlight verifies claims locally and owns campaigns, campaign +membership, current campaign selection, and every world mutation. + +## App identity + +- `app_slug`: `ghostlight` +- public URL: `https://yggdrasil.gamecult.org/ghostlight/` +- identity provider: Discord +- public-demo rule: any Heimdall-authenticated Discord identity receives + `app_access`; no GameCult guild role is required + +## Capabilities + +- `app_access`: shared capability granting entry to the demo +- `campaign_play`: hybrid capability; Ghostlight must also prove that the + authenticated account owns the selected campaign + +The browser never carries provider tokens. It receives a one-time completion +code, Ghostlight redeems it server-to-server, verifies the resulting +`aud=ghostlight` claim against Heimdall JWKS, and establishes app-local campaign +authority. diff --git a/src/app-profiles.ts b/src/app-profiles.ts index e5b0c70..0666f90 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -197,11 +197,38 @@ const bifrostProfile: AppProfile = { }, }; +const ghostlightProfile: AppProfile = { + slug: "ghostlight", + displayName: "Ghostlight Dungeon", + profileVersion: "2026-08-19", + identityProviders: ["discord"], + entitlementSources: [], + managedConnectionProviders: [], + capabilities: [ + { + key: "app_access", + mode: "shared", + summary: "May enter the public Ghostlight Dungeon demo.", + sharedRule: identityFacts.authenticated, + }, + { + key: "campaign_play", + mode: "hybrid", + summary: "May play campaigns owned by the authenticated Ghostlight account.", + localRequirement: "Ghostlight must combine app_access with local campaign ownership.", + }, + ], + evaluateSharedCapabilities(context) { + return context.facts.has(identityFacts.authenticated) ? ["app_access"] : []; + }, +}; + export const appProfiles: Record = { repixelizer: repixelizerProfile, streampixels: streampixelsProfile, spotiverse: spotiverseProfile, bifrost: bifrostProfile, + ghostlight: ghostlightProfile, }; export function getAppProfile(appSlug: AppSlug): AppProfile { diff --git a/src/contracts.ts b/src/contracts.ts index df03b00..cbfb211 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", "bifrost"] as const; +export const appSlugs = ["repixelizer", "streampixels", "spotiverse", "bifrost", "ghostlight"] as const; export type AppSlug = (typeof appSlugs)[number]; diff --git a/tests/verse-runtime.test.ts b/tests/verse-runtime.test.ts index b009592..7c1b05a 100644 --- a/tests/verse-runtime.test.ts +++ b/tests/verse-runtime.test.ts @@ -37,7 +37,7 @@ describe("Heimdall verse runtime publication", () => { runtime: { storageBackend: "postgres", configuredProviders: ["discord", "twitch"], - appProfileCount: 4, + appProfileCount: 5, }, }); expect(bySchema.get("heimdall.command_boundary.v1")).toMatchObject({ @@ -56,7 +56,7 @@ describe("Heimdall verse runtime publication", () => { expect(bySchema.get("idunn.daemon_health")).toEqual([ "yggdrasil-heimdall", "active", - "Heimdall auth runtime active; storage=postgres; providers=2/6; apps=4; healthTransport=CultNet/RUDP", + "Heimdall auth runtime active; storage=postgres; providers=2/6; apps=5; healthTransport=CultNet/RUDP", "2026-06-16T17:10:00.000Z", "heimdall.cultnet-rudp-provider-health", "daemon-published", diff --git a/tests/verse-witness.test.ts b/tests/verse-witness.test.ts index cd91132..e3d9c31 100644 --- a/tests/verse-witness.test.ts +++ b/tests/verse-witness.test.ts @@ -37,6 +37,7 @@ describe("Heimdall Verse witness advertisement", () => { "streampixels", "spotiverse", "bifrost", + "ghostlight", ]); expect(advertisement.documents.map((document) => document.schemaId)).toEqual([...heimdallWitnessSchemaIds]); });