Heimdall/src/private-command-plane.ts

428 lines
20 KiB
TypeScript
Raw Normal View History

import { createHash, randomUUID } from "node:crypto";
import { decode, encode } from "@msgpack/msgpack";
import {
startCultNetOperationServer,
type CultNetOperationRequestMessage,
type CultNetOperationResponseMessage,
type CultNetOperationServer,
} from "cultnet-ts";
import { type FastifyInstance } from "fastify";
import { errorIdentity, getHeimdallRuntimeContext, refreshAppSession, startOAuthFlow, verifyRefreshToken } from "./app.js";
import { callerFromOpenedEnvelope } from "./app-caller.js";
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
import { getAppProfile } from "./app-profiles.js";
import { executeHeimdallAccessPlugin, HEIMDALL_ACCESS_PLUGIN_ID, type EvePluginAbiRequest } from "./access-plugin.js";
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
import { isAppSlug, oauthModes, providers, type AppSlug, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js";
import { type HeimdallConfig } from "./config.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "./private-command-security.js";
export const HEIMDALL_PRIVATE_COMMAND_SERVICE = "heimdall.private.commands";
export const HEIMDALL_PRIVATE_ENVELOPE_SCHEMA = "heimdall.private_command_envelope.v1";
interface CachedResult {
fingerprint: string;
status: string;
envelope: HeimdallPrivateEnvelope;
}
export async function startHeimdallPrivateCommandPlane(
app: FastifyInstance,
config: HeimdallConfig,
): Promise<CultNetOperationServer> {
const seenNonces = new Map<string, number>();
const inFlight = new Map<string, Promise<CachedResult>>();
return await startCultNetOperationServer({
runtimeId: config.daemonId,
host: config.privateCommandHost ?? "127.0.0.1",
port: config.privateCommandPort ?? 4101,
handler: async (request) => {
const now = Date.now();
for (const [nonce, expiry] of seenNonces) if (expiry <= now) seenNonces.delete(nonce);
try {
if (request.serviceId === HEIMDALL_ACCESS_PLUGIN_ID) {
if (request.payloadSchema !== "gamecult.eve.plugin_abi.request.v1" || request.payloadEncoding !== "messagepack-base64") {
throw new Error("Heimdall access plugin requires the Eve plugin ABI request schema.");
}
const pluginRequest = decode(Buffer.from(request.payload, "base64")) as EvePluginAbiRequest;
if (pluginRequest.operation !== request.operation) throw new Error("Plugin ABI operation disagrees with CultNet routing.");
const pluginResponse = executeHeimdallAccessPlugin(pluginRequest);
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status: pluginResponse.status,
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode(pluginResponse)).toString("base64"),
diagnostics: [],
sourceRuntimeId: config.daemonId,
};
}
if (request.serviceId !== HEIMDALL_PRIVATE_COMMAND_SERVICE) throw new Error("Unknown Heimdall private command service.");
if (request.payloadSchema !== HEIMDALL_PRIVATE_ENVELOPE_SCHEMA || request.payloadEncoding !== "messagepack-base64") {
throw new Error("Heimdall private commands require the typed encrypted MessagePack envelope.");
}
const envelope = decode(Buffer.from(request.payload, "base64")) as HeimdallPrivateEnvelope;
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
if (!isAppSlug(envelope.appSlug)) throw new Error("Unknown Heimdall app binding.");
const appSlug = envelope.appSlug as AppSlug;
const secret = config.appSharedSecrets[appSlug];
if (!secret) throw new Error("Heimdall app binding has no private command secret.");
if (envelope.operation !== request.operation) throw new Error("Private envelope operation disagrees with CultNet routing.");
const expectedContentSchema = request.operation === "heimdall.auth.begin"
? "heimdall.auth_begin_command.v1"
: request.operation === "heimdall.auth.complete"
? "heimdall.auth_complete_command.v1"
2026-08-22 01:27:28 +02:00
: request.operation === "heimdall.auth.refresh"
? "heimdall.auth_refresh_command.v1"
: request.operation === "heimdall.auth.logout"
? "heimdall.auth_logout_command.v1"
: undefined;
if (!expectedContentSchema || envelope.contentSchema !== expectedContentSchema) {
throw new Error("Private command content schema does not match its operation.");
}
const allowedRuntimeIds = config.appRuntimeIds?.[appSlug] ?? [`yggdrasil-${appSlug}`];
if (!request.sourceRuntimeId || !allowedRuntimeIds.includes(request.sourceRuntimeId)) {
throw new Error("Private command caller is not the configured app runtime.");
}
const payload = openPrivateEnvelope(envelope, secret, now);
const fingerprint = createHash("sha256").update(request.payload).digest("hex");
const resultKey = `${appSlug}:${envelope.idempotencyKey}`;
const store = getHeimdallRuntimeContext(app).store;
const persisted = await store.findPrivateCommandReceipt(appSlug, envelope.idempotencyKey);
if (persisted) {
if (persisted.requestFingerprint !== fingerprint) throw new Error("Idempotency key was reused with different command content.");
const cachedEnvelope = decode(Buffer.from(persisted.envelopeBase64, "base64")) as HeimdallPrivateEnvelope;
return response(request, persisted.status, cachedEnvelope, config.daemonId);
}
const pending = inFlight.get(resultKey);
if (pending) {
const cached = await pending;
if (cached.fingerprint !== fingerprint) throw new Error("Idempotency key was reused with different command content.");
return response(request, cached.status, cached.envelope, config.daemonId);
}
if (seenNonces.has(`${appSlug}:${envelope.nonce}`)) throw new Error("Private command nonce was already used.");
seenNonces.set(`${appSlug}:${envelope.nonce}`, Date.parse(envelope.expiresAt));
const execution = (async (): Promise<CachedResult> => {
const output = await executePrivateCommand(app, config, appSlug, request.operation, payload);
const sealed = sealPrivateEnvelope({
appSlug,
operation: request.operation,
contentSchema: output.payloadSchema,
idempotencyKey: envelope.idempotencyKey,
secret,
payload: output.payload,
});
const stored = await store.createPrivateCommandReceipt({
appSlug,
idempotencyKey: envelope.idempotencyKey,
requestFingerprint: fingerprint,
status: output.status,
contentSchema: output.payloadSchema,
envelopeBase64: Buffer.from(encode(sealed)).toString("base64"),
createdAt: sealed.issuedAt,
expiresAt: sealed.expiresAt,
});
return {
fingerprint: stored.requestFingerprint,
status: stored.status,
envelope: decode(Buffer.from(stored.envelopeBase64, "base64")) as HeimdallPrivateEnvelope,
};
})();
inFlight.set(resultKey, execution);
let cachedResult: CachedResult;
try {
cachedResult = await execution;
} finally {
if (inFlight.get(resultKey) === execution) inFlight.delete(resultKey);
}
return response(request, cachedResult.status, cachedResult.envelope, config.daemonId);
} catch (error) {
// The reply's diagnostics travel outside the sealed envelope, and the
// caller logs them. Errors caught here carry input: JSON.parse quotes
// a provider's body, a store error names the database. So the
// diagnostic is fixed text plus the error's class and code; anything
// more lives only in a sealed payload the plane authored.
const { errorClass, errorCode } = errorIdentity(error);
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status: "denied",
payloadSchema: "gamecult.cultnet.operation_failure.v1",
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode({ code: "private-command-denied", message: "Heimdall denied the private command." })).toString("base64"),
diagnostics: [`Heimdall denied the private command (${errorClass}${errorCode ? `, ${errorCode}` : ""}).`],
sourceRuntimeId: config.daemonId,
};
}
},
});
}
async function executePrivateCommand(
app: FastifyInstance,
config: HeimdallConfig,
appSlug: AppSlug,
operation: string,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
if (operation === "heimdall.auth.begin") return await beginAuth(app, config, appSlug, payload);
if (operation === "heimdall.auth.complete") return await completeAuth(app, appSlug, payload);
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
if (operation === "heimdall.auth.refresh") return await refreshAuth(app, appSlug, payload);
if (operation === "heimdall.auth.logout") return await logoutAuth(app, config, appSlug, payload);
throw new Error(`Unsupported Heimdall private operation '${operation}'.`);
}
async function logoutAuth(
app: FastifyInstance,
config: HeimdallConfig,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const refreshToken = String(payload.refreshToken ?? "");
if (!refreshToken) throw new Error("Auth logout requires the app's encrypted refresh claim.");
const context = getHeimdallRuntimeContext(app);
const claim = verifyRefreshToken(refreshToken, appSlug, config, context.keys);
if (!claim) throw new Error("Auth logout received an invalid or expired refresh claim.");
const revokedAt = new Date().toISOString();
const session = await context.store.revokeSession(
appSlug,
claim.sid,
claim.account_id,
claim.access_revision,
revokedAt,
);
if (!session || session.accessRevision !== claim.access_revision + 1) {
throw new Error("Auth logout could not revoke the exact session custody claim.");
}
await context.store.createAuditEvent({
accountId: claim.account_id,
sessionId: claim.sid,
appSlug,
eventType: "session_revoked",
eventPayloadJson: { previousAccessRevision: claim.access_revision, accessRevision: session.accessRevision },
createdAt: revokedAt,
});
return {
status: "accepted",
payloadSchema: "heimdall.auth_logout_receipt.v1",
payload: {
schema: "heimdall.auth_logout_receipt.v1",
status: "revoked",
sessionId: claim.sid,
accessRevision: session.accessRevision,
revokedAt,
},
};
}
2026-08-22 01:27:28 +02:00
async function refreshAuth(
app: FastifyInstance,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const refreshToken = String(payload.refreshToken ?? "");
if (!refreshToken) throw new Error("Auth refresh requires the app's encrypted refresh claim.");
const entitlementPolicy = parseEntitlementPolicy(payload.entitlementPolicy);
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
// The plane already authenticated this call as `appSlug` by opening the
// envelope with that app's shared secret and checking sourceRuntimeId; it
// gets its AppCaller from the one other constructor that authority owns
// (app-caller.ts) instead of writing the literal here, and calls the same
// handler function the HTTP route uses rather than forging the HTTP header
// back at itself.
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
const context = getHeimdallRuntimeContext(app);
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
await requireEntitlementPolicyIfProfileDemandsIt(appSlug, entitlementPolicy, "refresh");
const result = await refreshAppSession(context, appSlug, callerFromOpenedEnvelope(appSlug), {
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
refreshToken,
...(entitlementPolicy ? { entitlementPolicy } : {}),
2026-08-22 01:27:28 +02:00
});
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
const refreshedPayload = result.body as Record<string, unknown>;
if (result.statusCode !== 201) {
2026-08-22 01:27:28 +02:00
return {
status: "accepted",
2026-08-22 01:27:28 +02:00
payloadSchema: "heimdall.auth_refresh_receipt.v1",
payload: {
schema: "heimdall.auth_refresh_receipt.v1",
status: "denied",
error: String(refreshedPayload.error ?? "refresh_denied"),
},
};
}
return {
status: "accepted",
payloadSchema: "heimdall.auth_refresh_receipt.v1",
payload: { ...refreshedPayload, schema: "heimdall.auth_refresh_receipt.v1", status: "authenticated" },
};
}
async function beginAuth(
app: FastifyInstance,
config: HeimdallConfig,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const provider = String(payload.provider ?? "discord") as Provider;
const mode = String(payload.mode ?? "sign_in") as OAuthMode;
const returnTo = String(payload.returnTo ?? "");
if (!providers.includes(provider) || !oauthModes.includes(mode) || !returnTo) throw new Error("Auth begin payload is incomplete.");
const entitlementPolicy = parseEntitlementPolicy(payload.entitlementPolicy);
const store = getHeimdallRuntimeContext(app).store;
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
await requireEntitlementPolicyIfProfileDemandsIt(appSlug, entitlementPolicy, "authentication");
const now = new Date();
const attempt = await store.createAuthAttempt({
handle: randomUUID(),
appSlug,
provider,
mode,
returnTo,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + config.stateTtlSeconds * 1000).toISOString(),
});
// Same authority, same non-HTTP entry as refreshAuth above.
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
const context = getHeimdallRuntimeContext(app);
R21.1: bind the browser_completion attempt handle to the private plane The public /v1/oauth/:provider/start route read handoff.attemptId straight out of the request body and folded it into the signed state token unconditionally. Since the private command plane's beginAuth mints an attempt handle server-side and later redeems by that handle (consumeAuthCompletionByAttempt), anyone who learned a pending handle could carry it through their own OAuth flow and bind their own identity to it -- the app's completeAuth(H) would then authenticate the victim's session as the attacker. Same family as the takeover chain closed in the prior pass: a caller-chosen identifier trusted as authority. normalizeOAuthHandoff now always strips attemptId from a browser_completion handoff; a public caller has no attempt to correlate and gets its completion code directly. The only path an attempt handle can reach the state token is startOAuthFlow's new trustedBrowserAttemptId option, which is a TypeScript function argument set by beginAuth from the handle it just minted -- never a value parsed out of a request body. Also adds the uniqueness constraint the by-attempt redemption needed: a partial unique index on (app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL, mirrored in InMemoryStore. Without it, consumeAuthCompletionByAttempt's UPDATE could match more than one row and silently discard all but the one it returned. Flips Soul's attempt-handle-binding it.fails to a passing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:43:33 +02:00
const start = await startOAuthFlow(
{ config: context.config, keys: context.keys, store: context.store },
provider,
callerFromOpenedEnvelope(appSlug),
{
appSlug,
mode,
returnTo,
handoff: { kind: "browser_completion" },
...(entitlementPolicy ? { entitlementPolicy } : {}),
},
{ trustedBrowserAttemptId: attempt.handle }
);
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
const startPayload = start.body as Record<string, unknown>;
if (start.statusCode !== 201) {
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
await context.store.updateAuthAttempt(appSlug, attempt.handle, {
status: "denied",
at: new Date().toISOString(),
denialCode: String(startPayload.error ?? "oauth_start_denied"),
});
throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied."));
}
const authorizationUrl = String(startPayload.authorizationUrl ?? "");
const authorizationOrigin = new URL(authorizationUrl).origin;
return {
status: "accepted",
payloadSchema: "heimdall.auth_begin_receipt.v1",
payload: {
schema: "heimdall.auth_begin_receipt.v1",
status: "pending",
handle: attempt.handle,
expiresAt: attempt.expiresAt,
navigation: {
url: authorizationUrl,
allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin],
},
},
};
}
async function completeAuth(
app: FastifyInstance,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const handle = String(payload.handle ?? "");
if (!handle) throw new Error("Auth completion requires an opaque attempt handle.");
const store = getHeimdallRuntimeContext(app).store;
const attempt = await store.findAuthAttempt(appSlug, handle);
if (!attempt) throw new Error("Auth attempt is unknown for this app.");
const now = new Date().toISOString();
if (attempt.expiresAt <= now && attempt.status !== "completed") {
await store.updateAuthAttempt(appSlug, handle, { status: "expired", at: now });
return authCompletion("denied", { handle, error: "expired_attempt" });
}
if (attempt.status === "pending") return authCompletion("pending", { handle });
if (attempt.status !== "completed") {
return authCompletion("denied", { handle, error: attempt.denialCode ?? `attempt_${attempt.status}` });
}
// Consume by the attempt handle directly rather than forging an HTTP
// self-call into the public redeem route: the plane already authenticated
// `appSlug`, and the completion code is a secret this process never needs
// to see, let alone pass back through its own front door.
const completion = await store.consumeAuthCompletionByAttempt(appSlug, handle, now);
if (!completion) {
return authCompletion("denied", { handle, error: "invalid_or_expired_completion" });
}
await store.createAuditEvent({
accountId: completion.accountId,
sessionId: completion.sessionId,
appSlug: completion.appSlug,
eventType: "auth_completion_redeemed",
eventPayloadJson: {
provider: completion.provider,
mode: completion.mode,
},
createdAt: now,
});
await store.updateAuthAttempt(appSlug, handle, { status: "consumed", at: now });
return authCompletion("authenticated", { handle, ...completion.payloadJson });
}
function authCompletion(status: string, values: Record<string, unknown>) {
return {
// CultNet reports whether the private command executed. Authentication state
// remains inside the typed receipt so callers can decode pending and denied
// outcomes instead of mistaking them for transport failures.
status: "accepted",
payloadSchema: "heimdall.auth_completion_receipt.v1",
payload: { ...values, schema: "heimdall.auth_completion_receipt.v1", status },
};
}
function parseEntitlementPolicy(value: unknown): OAuthEntitlementPolicy | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
if (record.kind === "discord_role_access" && typeof record.guildId === "string" && Array.isArray(record.allowedRoleIds)) {
const allowedRoleIds = record.allowedRoleIds.filter((item): item is string => typeof item === "string" && Boolean(item));
return allowedRoleIds.length ? { kind: "discord_role_access", guildId: record.guildId, allowedRoleIds } : undefined;
}
if (record.kind === "patreon_membership_access" && typeof record.requiredTierTitle === "string") {
return { kind: "patreon_membership_access", requiredTierTitle: record.requiredTierTitle };
}
return undefined;
}
/**
* Ghostlight requires its caller-owned Discord role policy on every begin and
* refresh; that used to be a literal `appSlug === "ghostlight"` branch here.
* The requirement is now data on the app's profile
* (AppProfile.requiredEntitlementPolicyKind), so a future app with the same
* need declares it in its profile instead of adding another branch.
*/
async function requireEntitlementPolicyIfProfileDemandsIt(
appSlug: AppSlug,
entitlementPolicy: OAuthEntitlementPolicy | undefined,
action: "authentication" | "refresh",
): Promise<void> {
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
const profile = getAppProfile(appSlug);
const requiredKind = profile?.requiredEntitlementPolicyKind;
if (requiredKind && entitlementPolicy?.kind !== requiredKind) {
throw new Error(`${appSlug} ${action} requires its caller-owned ${requiredKind} policy.`);
}
}
function response(
request: CultNetOperationRequestMessage,
status: string,
envelope: HeimdallPrivateEnvelope,
runtimeId: string,
): CultNetOperationResponseMessage {
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status,
payloadSchema: HEIMDALL_PRIVATE_ENVELOPE_SCHEMA,
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode(envelope)).toString("base64"),
diagnostics: [],
sourceRuntimeId: runtimeId,
};
}