Heimdall/src/private-command-plane.ts

427 lines
19 KiB
TypeScript

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";
import { getAppProfile } from "./app-profiles.js";
import { executeHeimdallAccessPlugin, HEIMDALL_ACCESS_PLUGIN_ID, type EvePluginAbiRequest } from "./access-plugin.js";
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;
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"
: 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);
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,
},
};
}
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);
// 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.
const context = getHeimdallRuntimeContext(app);
await requireEntitlementPolicyIfProfileDemandsIt(appSlug, entitlementPolicy, "refresh");
const result = await refreshAppSession(context, appSlug, callerFromOpenedEnvelope(appSlug), {
refreshToken,
...(entitlementPolicy ? { entitlementPolicy } : {}),
});
const refreshedPayload = result.body as Record<string, unknown>;
if (result.statusCode !== 201) {
return {
status: "accepted",
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;
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.
const context = getHeimdallRuntimeContext(app);
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 }
);
const startPayload = start.body as Record<string, unknown>;
if (start.statusCode !== 201) {
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 ?? "");
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: [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> {
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,
};
}