Delete claims/issue: zero callers, arbitrary-facts oracle bypassing entitlement evaluation

R1: no GC service calls this route. Its shape (caller-supplied facts handed
straight to issueAccessClaim) is a second entitlement authority even when
gated, so authenticating it would preserve the oracle behind a password.
Deletion-only commit: route, discovery advertisement, mapper, contract type,
positive test, and doc mentions in architecture.md/service-contract.md/
fresh-workspace-handoff.md.
This commit is contained in:
Meta Vens 2026-09-08 13:14:52 +02:00
commit a999d48b6d
7 changed files with 10 additions and 194 deletions

View file

@ -470,7 +470,6 @@ Everything else is internal by the design already written down:
|---|---|
| `/.well-known/heimdall-configuration` | GC apps, mesh-reachable |
| `/v1/apps` | GC apps, mesh-reachable |
| `/v1/apps/{appSlug}/claims/issue` | GC app backends, mesh-reachable |
| `/healthz` | local probes |
The private command plane is not part of this question. It is

View file

@ -65,7 +65,6 @@ Current shape includes:
- `configurationUri`
- `oauthStartEndpoint`
- `oauthCallbackEndpoint`
- `claimIssueEndpoint`
- `supportedProviders`
- `apps`
@ -497,63 +496,6 @@ Current helper contract:
This is a reference seam, not a magical shared middleware product yet.
### `POST /v1/apps/{appSlug}/claims/issue`
Purpose:
- issue a signed Heimdall access claim for one app after identity/grant facts
have already been resolved
This is currently the concrete claim contract and integration seam for early app
binding work.
Request body:
```json
{
"accountId": "acct_repixelizer_001",
"displayName": "Meta",
"facts": ["entitlement.app_access", "grant.operator"],
"linkedIdentities": [
{
"provider": "discord",
"providerUserId": "123456789",
"username": "meta"
}
],
"accessRevision": 1,
"ttlSeconds": 3600
}
```
Response:
```json
{
"session": {
"accountId": "acct_repixelizer_001",
"sessionId": "uuid",
"appSlug": "repixelizer",
"accessRevision": 1,
"expiresAt": "2026-05-26T12:00:00.000Z"
},
"accessToken": "<signed-jwt>",
"refreshToken": "<signed-refresh-jwt>",
"refresh": {
"expiresAt": "2026-05-26T12:00:00.000Z"
},
"claimSet": {},
"verification": {
"issuer": "https://heimdall.gamecult.org",
"jwksUri": "https://heimdall.gamecult.org/.well-known/jwks.json",
"alg": "EdDSA",
"kid": "ed25519-..."
},
"sharedCapabilities": ["app_access", "queue_submit", "admin_access"],
"hybridCapabilities": []
}
```
## Access claim shape
Current claim payload fields:

View file

@ -72,7 +72,6 @@ Do not trust this file for the exact live HEAD. Always check git.
- `/v1/oauth/{provider}/start`
- `/v1/oauth/{provider}/callback`
- `/v1/apps/{appSlug}/auth-completions/redeem`
- `/v1/apps/{appSlug}/claims/issue`
- `/v1/apps/{appSlug}/sessions/refresh`
- `/v1/apps/{appSlug}/managed-credentials/resolve`
- the Discord callback path now performs real code exchange, identity

View file

@ -21,7 +21,7 @@ import {
type OAuthStartRequest,
type Provider,
} from "./contracts.js";
import { mapIssueClaimRequest, issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
import { builtInAppProfiles, getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
import {
AppRegistrationError,
@ -79,7 +79,6 @@ function buildDiscovery(config: HeimdallConfig) {
configurationUri: `${config.publicBaseUrl}/.well-known/heimdall-configuration`,
oauthStartEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/start`,
oauthCallbackEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/callback`,
claimIssueEndpoint: `${config.publicBaseUrl}/v1/apps/{appSlug}/claims/issue`,
supportedProviders: providers.map((provider) => ({
key: provider,
displayName: providerCatalog[provider].displayName,
@ -1333,63 +1332,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
);
app.post<{ Params: { appSlug: AppSlug }; Body: import("./contracts.js").IssueClaimRequest }>(
"/v1/apps/:appSlug/claims/issue",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
},
},
body: {
type: "object",
required: ["accountId"],
additionalProperties: false,
properties: {
accountId: { type: "string", minLength: 1 },
sessionId: { type: "string", minLength: 1 },
displayName: { type: "string", minLength: 1 },
facts: {
type: "array",
items: { type: "string" },
uniqueItems: true,
},
accessRevision: { type: "integer", minimum: 1 },
ttlSeconds: { type: "integer", minimum: 60, maximum: 86400 },
linkedIdentities: {
type: "array",
items: {
type: "object",
required: ["provider", "providerUserId"],
additionalProperties: false,
properties: {
provider: { type: "string", enum: [...providers] },
providerUserId: { type: "string", minLength: 1 },
username: { type: "string" },
displayName: { type: "string" },
},
},
},
},
},
},
},
async (request, reply) => {
const issued = await issueAccessClaim({
config,
keys,
store,
input: mapIssueClaimRequest(request.params.appSlug, request.body),
});
reply.code(201);
return issued;
}
);
app.post<{ Params: { appSlug: AppSlug }; Body: RefreshSessionRequest }>(
"/v1/apps/:appSlug/sessions/refresh",
{

View file

@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
import {
type AccessClaimPayload,
type AppSlug,
type IssueClaimRequest,
type LinkedIdentityInput,
type RefreshTokenPayload,
} from "./contracts.js";
@ -181,30 +180,3 @@ export async function issueAccessClaim(options: {
hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
};
}
export function mapIssueClaimRequest(appSlug: AppSlug, request: IssueClaimRequest): IssueAccessClaimInput {
const input: IssueAccessClaimInput = {
appSlug,
accountId: request.accountId,
linkedIdentities: request.linkedIdentities ?? [],
facts: request.facts ?? [],
};
if (request.displayName !== undefined) {
input.displayName = request.displayName;
}
if (request.sessionId !== undefined) {
input.sessionId = request.sessionId;
}
if (request.accessRevision !== undefined) {
input.accessRevision = request.accessRevision;
}
if (request.ttlSeconds !== undefined) {
input.ttlSeconds = request.ttlSeconds;
}
return input;
}

View file

@ -103,16 +103,6 @@ export interface LinkedIdentityInput {
displayName?: string;
}
export interface IssueClaimRequest {
accountId: string;
sessionId?: string;
displayName?: string;
facts?: string[];
linkedIdentities?: LinkedIdentityInput[];
accessRevision?: number;
ttlSeconds?: number;
}
export interface RedeemAuthCompletionRequest {
completionCode: string;
}

View file

@ -2,10 +2,9 @@ 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 { entitlementFacts, identityFacts } from "../src/facts.js";
import { createOAuthRuntimeRegistry, type OAuthProviderRuntime } from "../src/oauth.js";
import { verifyJwt } from "../src/signing.js";
import { createHeimdallAccessTokenVerifier } from "../src/verifier.js";
import { InMemoryStore } from "../src/store/index.js";
function createTestConfig(): HeimdallConfig {
@ -834,51 +833,24 @@ describe("Heimdall service", () => {
expect(verified.valid).toBe(true);
});
it("issues direct claims from provider-agnostic entitlement facts", async () => {
it("no longer exposes a route to mint claims from caller-supplied 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",
},
],
},
payload: { accountId: "probe" },
});
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.sharedCapabilities).toEqual(
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
);
expect(response.statusCode).toBe(404);
expect(app.printRoutes()).not.toContain("claims/issue");
const verifier = createHeimdallAccessTokenVerifier({
issuer: "https://heimdall.gamecult.org",
appSlug: "repixelizer",
jwks: {
keys: [getHeimdallContext(app).keys.publicJwk],
},
const discoveryResponse = await app.inject({
method: "GET",
url: "/.well-known/heimdall-configuration",
});
expect(verifier.verify(payload.accessToken as string)).toEqual(
expect.objectContaining({
valid: true,
claimSet: expect.objectContaining({
aud: "repixelizer",
app: expect.objectContaining({
slug: "repixelizer",
}),
}),
})
);
expect(discoveryResponse.json()).not.toHaveProperty("claimIssueEndpoint");
});
it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => {