Private command diagnostics carry class and code, never a message

Soul pass 4, F1: a provider's 2xx refresh answer that was not JSON made
JSON.parse quote the text around the fault, a refresh-token fragment among
it, and the plane returned that message in the reply's diagnostics. Those
travel outside the sealed envelope and Ghostlight logs them.

The plane's diagnostic is now fixed text plus the error's class and code,
through errorIdentity, which the failed-callback audit event now shares.
Every provider body Heimdall parses goes through readProviderJson: a
non-2xx answer or a body that is not JSON becomes a ProviderResponseError
(status and code, provider_status or provider_body_not_json) and no
parser message survives. This replaces ProviderHttpError and the separate
parses in the Discord guild lookup and the YouTube channel lookup.

Soul's canary probe is committed inverted: a refresh against a malformed
2xx body and against a 400 whose body holds the canary; no byte of it may
appear in the reply or its decoded failure payload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 17:07:16 +02:00
commit 698f5dfc0e
5 changed files with 180 additions and 29 deletions

View file

@ -249,6 +249,19 @@ function relayedProviderText(value: string | undefined, bound: number): string |
return printable ? printable : undefined;
}
/**
* What may be recorded or answered about a caught error: its class and its
* code, each only when it is a short identifier. Never its message, which
* carries whatever the thrower quoted (a host, a database, a provider's body).
*/
export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } {
const code = (error as { code?: unknown } | null)?.code;
return {
errorClass: error instanceof Error && /^[A-Za-z]{1,64}$/.test(error.name) ? error.name : "Error",
errorCode: typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : null,
};
}
function prefersHtml(acceptHeader: string | undefined): boolean {
if (!acceptHeader) {
return false;
@ -1333,9 +1346,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
// resolver error names a constraint, the host or the database, and
// JSON.parse quotes the provider's response body. The audit event
// keeps the error's class, its code, and a provider's HTTP status.
const code = (error as { code?: unknown }).code;
const status = (error as { status?: unknown }).status;
const errorClass = error instanceof Error && /^[A-Za-z]{1,64}$/.test(error.name) ? error.name : "Error";
const failure = "The provider callback could not be completed.";
const nowIso = new Date().toISOString();
await denyBrowserAttempt("oauth_callback_failed");
@ -1345,8 +1356,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
errorClass,
errorCode: typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : null,
...errorIdentity(error),
providerStatus: typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,
},
createdAt: nowIso,

View file

@ -60,29 +60,42 @@ export interface OAuthProviderRuntime {
}
/**
* A provider answered with a non-2xx status. It carries the status and never
* the response body: a provider's body can quote the request, and whatever
* catches this may record or print it.
* A provider answer Heimdall cannot use: a non-2xx status, or a body that is
* not JSON. It carries the status and a code, never the body or the parser's
* message: a provider's body can quote the request or hold tokens (JSON.parse
* quotes the text around the fault), and whatever catches this may record,
* print or relay it.
*/
export class ProviderHttpError extends Error {
export class ProviderResponseError extends Error {
readonly status: number;
readonly code: "provider_status" | "provider_body_not_json";
constructor(status: number, failure: string) {
super(`${failure} (status ${status}).`);
this.name = "ProviderHttpError";
constructor(status: number, code: ProviderResponseError["code"], failure: string) {
super(`${failure} (status ${status}, ${code}).`);
this.name = "ProviderResponseError";
this.status = status;
this.code = code;
}
}
async function fetchJson<T>(input: string | URL, init: RequestInit, fallbackError: string): Promise<T> {
const response = await fetch(input, init);
/** The JSON body of a provider's answer; refuses any other answer by status and code alone. */
async function readProviderJson<T>(response: Response, failure: string): Promise<T> {
const text = await response.text();
if (!response.ok) {
throw new ProviderHttpError(response.status, fallbackError);
throw new ProviderResponseError(response.status, "provider_status", failure);
}
if (!text) {
return {} as T;
}
try {
return JSON.parse(text) as T;
} catch {
throw new ProviderResponseError(response.status, "provider_body_not_json", failure);
}
}
return (text ? JSON.parse(text) : {}) as T;
async function fetchJson<T>(input: string | URL, init: RequestInit, failure: string): Promise<T> {
return readProviderJson<T>(await fetch(input, init), failure);
}
function normalizeScopes(scope: string | string[] | undefined): string[] {
@ -783,7 +796,7 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise
});
if (channelResponse.ok) {
const payload = (await channelResponse.json()) as {
const payload = await readProviderJson<{
items?: Array<{
id: string;
snippet?: {
@ -796,7 +809,7 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise
};
};
}>;
};
}>(channelResponse, "YouTube channel lookup failed");
const channel = payload.items?.[0];
if (channel?.id) {
@ -937,16 +950,11 @@ async function evaluateDiscordEntitlements(options: {
};
}
if (!response.ok) {
throw new ProviderHttpError(response.status, "Discord guild member lookup failed");
}
const text = await response.text();
const member = (text ? JSON.parse(text) : {}) as {
const member = await readProviderJson<{
nick?: string;
roles?: string[];
joined_at?: string;
};
}>(response, "Discord guild member lookup failed");
const roles = member.roles ?? [];
const matchedRoles = allowedRoleIds.filter((roleId) => roles.includes(roleId));
const facts: string[] = [];

View file

@ -7,7 +7,7 @@ import {
type CultNetOperationServer,
} from "cultnet-ts";
import { type FastifyInstance } from "fastify";
import { getHeimdallRuntimeContext, refreshAppSession, startOAuthFlow, verifyRefreshToken } from "./app.js";
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";
@ -138,6 +138,12 @@ export async function startHeimdallPrivateCommandPlane(
}
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,
@ -147,7 +153,7 @@ export async function startHeimdallPrivateCommandPlane(
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: [error instanceof Error ? error.message : "Private command failed."],
diagnostics: [`Heimdall denied the private command (${errorClass}${errorCode ? `, ${errorCode}` : ""}).`],
sourceRuntimeId: config.daemonId,
};
}

View file

@ -1542,13 +1542,29 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
expect(failed[0]!.eventPayloadJson).toEqual({
provider: "discord",
mode: "sign_in",
errorClass: "ProviderHttpError",
errorCode: null,
errorClass: "ProviderResponseError",
errorCode: "provider_status",
providerStatus: 401,
});
expect(JSON.stringify(failed)).not.toContain("CANARY");
});
it("keeps a 2xx body that is not JSON out of the event, by class and code alone", async () => {
const failed = await auditAfterTokenExchangeAnswers(
() => new Response('{"access_token":"CANARYaccess","refresh_token":CANARYrefresh}', { status: 200 })
);
expect(failed).toHaveLength(1);
expect(failed[0]!.eventPayloadJson).toEqual({
provider: "discord",
mode: "sign_in",
errorClass: "ProviderResponseError",
errorCode: "provider_body_not_json",
providerStatus: 200,
});
expect(JSON.stringify(failed)).not.toContain("CANARY");
});
it("keeps no status that is not an HTTP status", async () => {
const store = new InMemoryStore();
const app = await buildApp({

View file

@ -0,0 +1,111 @@
// A private command's reply travels partly outside its sealed envelope: the
// status, the failure payload and the diagnostics are plaintext, and the
// calling app logs the diagnostics. Soul pass 4 found a provider's refresh
// token there: a 2xx refresh answer that is not JSON made JSON.parse quote the
// text around the fault, and the plane returned that message as a diagnostic.
// These tests drive the real Discord runtime with only fetch stubbed.
import { decode, encode } from "@msgpack/msgpack";
import { invokeCultNetOperation } from "cultnet-ts";
import { afterEach, expect, it, vi } from "vitest";
import { buildApp } from "../src/app.js";
import { type HeimdallConfig } from "../src/config.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { InMemoryStore } from "../src/store/index.js";
const secret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = [];
afterEach(async () => {
vi.unstubAllGlobals();
await Promise.all(resources.splice(0).reverse().map((r) => r.close()));
});
function config(): HeimdallConfig {
return {
serviceName: "heimdall", host: "127.0.0.1", port: 4100, privateCommandHost: "127.0.0.1", privateCommandPort: 0,
workspaceRoot: "/tmp", dataRoot: "/tmp/.hd", cultCachePath: "/tmp/.hd/t.cc",
publicBaseUrl: "https://heimdall.gamecult.org", issuer: "https://heimdall.gamecult.org", daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined, idunnHealthContract: "heimdall.cultnet-rudp-provider-health", providerHealthIdentityPath: "/tmp/.hd/p.cc",
sessionTtlSeconds: 3600, refreshTtlSeconds: 3600, stateTtlSeconds: 600, completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
storage: { backend: "memory", applySchemaOnStartup: true },
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {} },
} as HeimdallConfig;
}
async function command(endpoint: unknown, operation: string, contentSchema: string, key: string, payload: Record<string, unknown>) {
const envelope = sealPrivateEnvelope({ appSlug: "ghostlight", operation, contentSchema, idempotencyKey: key, secret, payload });
return invokeCultNetOperation(endpoint as never, {
schemaVersion: "cultnet.operation_request.v0", messageId: `m-${key}`, serviceId: "heimdall.private.commands", operation,
payloadSchema: "heimdall.private_command_envelope.v1", payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode(envelope)).toString("base64"), sourceRuntimeId: "yggdrasil-ghostlight",
} as never, { runtimeId: "yggdrasil-ghostlight" });
}
const open = (reply: { payload: string }) =>
openPrivateEnvelope(decode(Buffer.from(reply.payload, "base64")) as HeimdallPrivateEnvelope, secret);
/** A signed-in Ghostlight session, then a refresh whose provider answer is `refreshAnswer`. */
async function refreshAgainst(refreshAnswer: () => Response) {
let refreshing = false;
vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => {
const u = String(url);
if (u.endsWith("/oauth2/token")) {
if (new URLSearchParams(String(init?.body)).get("grant_type") === "refresh_token") {
refreshing = true;
return refreshAnswer();
}
return new Response(
JSON.stringify({ access_token: "first-access", refresh_token: "first-refresh", token_type: "Bearer", expires_in: 60, scope: "identify" }),
{ status: 200 }
);
}
if (u.endsWith("/users/@me")) return new Response(JSON.stringify({ id: "u1", username: "u1" }), { status: 200 });
if (u.includes("/member")) return new Response(JSON.stringify({ roles: ["r"] }), { status: 200 });
return new Response("{}", { status: 404 });
});
const cfg = config();
const app = await buildApp({ config: cfg, store: new InMemoryStore() });
resources.push(app);
const plane = await startHeimdallPrivateCommandPlane(app, cfg);
resources.push(plane);
const policy = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] };
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1",
{ provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy }));
const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
headers: { accept: "application/json" },
});
expect(callback.statusCode, callback.body).toBe(201);
const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));
expect(done.status).toBe("authenticated");
const reply = await command(plane.endpoint, "heimdall.auth.refresh", "heimdall.auth_refresh_command.v1", "k3",
{ refreshToken: done.refreshToken, entitlementPolicy: policy });
expect(refreshing).toBe(true);
return reply;
}
/** Every plaintext byte of a reply: the message itself and its decoded failure payload. */
function plaintext(reply: { payload: string }): string {
return JSON.stringify(reply) + JSON.stringify(decode(Buffer.from(reply.payload, "base64")));
}
it("keeps a provider's malformed 2xx refresh body out of every plaintext part of the reply", async () => {
const reply = await refreshAgainst(
() => new Response('{"access_token":"CANARYaccess0123456789","refresh_token":CANARYrefresh0123456789,"token_type":"Bearer"}', { status: 200 })
);
expect(reply.status).toBe("denied");
expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_body_not_json)."]);
expect(plaintext(reply)).not.toContain("CANARY");
});
it("keeps a provider's refusal body out of every plaintext part of the reply", async () => {
const reply = await refreshAgainst(() => new Response('{"error":"invalid_grant","echo":"CANARYrefused"}', { status: 400 }));
expect(reply.status).toBe("denied");
expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_status)."]);
expect(plaintext(reply)).not.toContain("CANARY");
});