diff --git a/docs/app-profiles/repixelizer.md b/docs/app-profiles/repixelizer.md index 6f62a53..9c69564 100644 --- a/docs/app-profiles/repixelizer.md +++ b/docs/app-profiles/repixelizer.md @@ -49,8 +49,7 @@ job_cancel_own = app_access + resource ownership check ```text app_access = - discord.allowed_role - || patreon.allowed_tier + entitlement.app_access || grant.global_member || grant.app_access diff --git a/docs/architecture.md b/docs/architecture.md index 1ad2b09..12334bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -294,14 +294,22 @@ AppAccessProfile { Example rule style: ```text -app_access = discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access +app_access = entitlement.app_access || grant.global_member || grant.app_access queue_submit = app_access admin_access = grant.operator || grant.admin_access ``` -The important part is not the provider names in the example. It is that app -profiles choose which identity facts, entitlement snapshots, and local grants -feed each capability. +The important part is not naming a provider in every rule. It is that app +profiles choose which identity facts, app-facing entitlement facts, snapshots, +and local grants feed each capability. + +Preferred fact-vocabulary rule: + +- signed claim facts should prefer app-facing names such as + `entitlement.app_access` +- provider-specific evidence such as Discord role ids or Patreon tiers should + usually stay in entitlement snapshots and linked-identity metadata unless a + consumer truly needs that exact upstream detail in the claim ## Request path discipline diff --git a/docs/service-contract.md b/docs/service-contract.md index 12613db..ea2672e 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -184,7 +184,7 @@ Request body: { "accountId": "acct_repixelizer_001", "displayName": "Meta", - "facts": ["discord.allowed_role", "grant.operator"], + "facts": ["entitlement.app_access", "grant.operator"], "linkedIdentities": [ { "provider": "discord", @@ -247,6 +247,9 @@ Important current rule: - Heimdall only grants fully shared capabilities directly - hybrid capabilities still require app-local checks after claim verification +- claim facts should prefer app-facing entitlement names over provider-branded + trivia when a provider-specific detail is not actually needed by the + consumer Examples: diff --git a/src/app-profiles.ts b/src/app-profiles.ts index ff75ef6..a547f7e 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -1,4 +1,5 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "./contracts.js"; +import { entitlementFacts, grantFacts, identityFacts } from "./facts.js"; export type CapabilityMode = "shared" | "hybrid"; @@ -34,7 +35,7 @@ function hasAnyFact(facts: Set, values: readonly string[]): boolean { const repixelizerProfile: AppProfile = { slug: "repixelizer", displayName: "Repixelizer", - profileVersion: "2026-04-26", + profileVersion: "2026-04-26.1", identityProviders: ["discord", "patreon"], entitlementSources: ["discord", "patreon"], managedConnectionProviders: [], @@ -43,8 +44,7 @@ const repixelizerProfile: AppProfile = { key: "app_access", mode: "shared", summary: "May load the protected hosted GUI.", - sharedRule: - "discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access", + sharedRule: "entitlement.app_access || grant.global_member || grant.app_access", }, { key: "queue_submit", @@ -74,17 +74,16 @@ const repixelizerProfile: AppProfile = { evaluateSharedCapabilities(context) { const capabilities: string[] = []; const appAccess = hasAnyFact(context.facts, [ - "discord.allowed_role", - "patreon.allowed_tier", - "grant.global_member", - "grant.app_access", + entitlementFacts.appAccess, + grantFacts.globalMember, + grantFacts.appAccess, ]); if (appAccess) { capabilities.push("app_access", "queue_submit"); } - if (hasAnyFact(context.facts, ["grant.operator", "grant.admin_access"])) { + if (hasAnyFact(context.facts, [grantFacts.operator, grantFacts.adminAccess])) { capabilities.push("admin_access"); } @@ -104,7 +103,7 @@ const streampixelsProfile: AppProfile = { key: "viewer_access", mode: "shared", summary: "Authenticated viewer session for control-plane surfaces.", - sharedRule: "identity.authenticated", + sharedRule: identityFacts.authenticated, }, { key: "creator_access", @@ -124,17 +123,17 @@ const streampixelsProfile: AppProfile = { key: "operator_access", mode: "shared", summary: "Global operator access.", - sharedRule: "grant.operator", + sharedRule: grantFacts.operator, }, ], evaluateSharedCapabilities(context) { const capabilities: string[] = []; - if (context.facts.has("identity.authenticated")) { + if (context.facts.has(identityFacts.authenticated)) { capabilities.push("viewer_access"); } - if (context.facts.has("grant.operator")) { + if (context.facts.has(grantFacts.operator)) { capabilities.push("operator_access"); } diff --git a/src/app.ts b/src/app.ts index 534d06a..34f1b18 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import { import { mapIssueClaimRequest, issueAccessClaim, type IssueAccessClaimInput } from "./claims.js"; import { getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js"; import { type HeimdallConfig, loadConfig } from "./config.js"; +import { grantFact } from "./facts.js"; import { createOAuthRuntimeRegistry, type OAuthRuntimeRegistry } from "./oauth.js"; import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; @@ -108,7 +109,7 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay } function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] { - return grants.map((grant) => `grant.${grant.capability}`); + return grants.map((grant) => grantFact(grant.capability)); } function prefersHtml(acceptHeader: string | undefined): boolean { diff --git a/src/claims.ts b/src/claims.ts index a6fcd9d..ad7814c 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -7,6 +7,7 @@ import { } from "./contracts.js"; import { getAppProfile } from "./app-profiles.js"; import { type HeimdallConfig } from "./config.js"; +import { identityFacts } from "./facts.js"; import { signJwt, type RuntimeKeyMaterial } from "./signing.js"; import { type CreateSessionInput, type HeimdallStore } from "./store/types.js"; @@ -26,7 +27,7 @@ function normalizeFacts(accountId: string, linkedIdentities: LinkedIdentityInput const normalized = new Set(facts); if (accountId.trim() || linkedIdentities.length) { - normalized.add("identity.authenticated"); + normalized.add(identityFacts.authenticated); } return [...normalized].sort(); diff --git a/src/facts.ts b/src/facts.ts new file mode 100644 index 0000000..535a61d --- /dev/null +++ b/src/facts.ts @@ -0,0 +1,21 @@ +// Shared claim facts should describe app-facing auth state. +// Provider-specific detail belongs in linked identities and entitlement snapshots. + +export function grantFact(capability: string): string { + return `grant.${capability}`; +} + +export const identityFacts = { + authenticated: "identity.authenticated", +} as const; + +export const entitlementFacts = { + appAccess: "entitlement.app_access", +} as const; + +export const grantFacts = { + globalMember: grantFact("global_member"), + appAccess: grantFact("app_access"), + adminAccess: grantFact("admin_access"), + operator: grantFact("operator"), +} as const; diff --git a/src/oauth.ts b/src/oauth.ts index 89affc5..3383464 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,6 +1,7 @@ import { Buffer } from "node:buffer"; import { type AppSlug, type OAuthConnectionBinding, type Provider } from "./contracts.js"; import { type HeimdallConfig } from "./config.js"; +import { entitlementFacts } from "./facts.js"; import { type CreateEntitlementSnapshotInput } from "./store/types.js"; export interface OAuthTokenSet { @@ -211,10 +212,10 @@ async function evaluateDiscordEntitlements(options: { }; const roles = member.roles ?? []; const matchedRoles = allowedRoleIds.filter((roleId) => roles.includes(roleId)); - const facts = ["discord.in_guild"]; + const facts: string[] = []; if (matchedRoles.length > 0) { - facts.push("discord.allowed_role"); + facts.push(entitlementFacts.appAccess); } return { @@ -229,7 +230,7 @@ async function evaluateDiscordEntitlements(options: { reasonCode: matchedRoles.length > 0 ? "matched_role" : "missing_role", reasonDetail: matchedRoles.length > 0 - ? `Matched Discord roles: ${matchedRoles.join(", ")}` + ? "Matched a configured Repixelizer access role." : allowedRoleIds.length ? "User is in the guild but lacks the configured Repixelizer access role." : "No Repixelizer Discord role ids are configured.", diff --git a/state/evidence.jsonl b/state/evidence.jsonl index a2848ef..57adb48 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -5,3 +5,4 @@ {"ts": "2026-04-26T11:00:02+00:00", "type": "design", "status": "accepted", "note": "Heimdall's provider model splits shared OAuth and credential custody from app-owned binding semantics and runtime use. Different provider mixes change adapter inventory and claim vocabulary, not the core service boundary."} {"ts": "2026-04-26T11:24:39+00:00", "type": "design", "status": "accepted", "note": "The first Heimdall implementation pass landed as a standalone TypeScript/Fastify service skeleton with Ed25519 JWT signing, JWKS discovery, signed OAuth state, and app-profile-driven claim issuance for Repixelizer and StreamPixels."} {"ts": "2026-04-26T11:49:41+00:00", "type": "implementation", "status": "accepted", "note": "The first stateful Heimdall slice landed durable auth-control-plane persistence plus a real Discord callback path for Repixelizer, with accounts, linked identities, sessions, grants, entitlement snapshots, and audit events stored behind in-memory/Postgres backends."} +{"ts": "2026-04-26T13:58:45+00:00", "type": "design", "status": "accepted", "note": "Heimdall claim facts should prefer provider-agnostic, app-facing entitlement names such as entitlement.app_access. Provider-specific evidence like Discord role matches stays in linked identities and entitlement snapshots unless a consumer truly needs that upstream detail in the claim surface."} diff --git a/tests/app.test.ts b/tests/app.test.ts index 6a92ad3..f7836fe 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; import { buildApp } from "../src/app.js"; import { type HeimdallConfig } from "../src/config.js"; +import { entitlementFacts, grantFacts, identityFacts } from "../src/facts.js"; import { type OAuthProviderRuntime } from "../src/oauth.js"; import { verifyJwt } from "../src/signing.js"; @@ -58,7 +59,7 @@ function createMockDiscordRuntime(): OAuthProviderRuntime { }, async evaluateEntitlements({ callback }) { return { - facts: ["discord.in_guild", "discord.allowed_role"], + facts: [entitlementFacts.appAccess], snapshots: [ { accountId: callback.accountId, @@ -204,7 +205,7 @@ describe("Heimdall service", () => { primaryEmail: "meta@gamecult.org", }) ); - expect(payload.entitlements.facts).toEqual(expect.arrayContaining(["discord.allowed_role"])); + expect(payload.entitlements.facts).toEqual(expect.arrayContaining([entitlementFacts.appAccess])); const verified = verifyJwt(payload.accessToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); @@ -216,10 +217,41 @@ describe("Heimdall service", () => { account_id: payload.account.id, }) ); + expect(verified.payload.facts).toEqual( + expect.arrayContaining([identityFacts.authenticated, entitlementFacts.appAccess]) + ); expect(verified.payload.capabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"])); } }); + it("issues direct claims from provider-agnostic entitlement facts", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/apps/repixelizer/claims/issue", + payload: { + accountId: "acct_repixelizer_001", + displayName: "Meta", + facts: [entitlementFacts.appAccess, grantFacts.operator], + linkedIdentities: [ + { + provider: "discord", + providerUserId: "123456789", + username: "meta", + }, + ], + }, + }); + + expect(response.statusCode).toBe(201); + const payload = response.json(); + expect(payload.sharedCapabilities).toEqual( + expect.arrayContaining(["app_access", "queue_submit", "admin_access"]) + ); + }); + it("redirects browser callbacks back to the app with a token fragment", async () => { const app = await buildApp({ config: createTestConfig(),