From 698f5dfc0ea5af4e5781fdcedd81224b068adb09 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 17:07:16 +0200 Subject: [PATCH] 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 --- src/app.ts | 18 +++- src/oauth.ts | 50 ++++++---- src/private-command-plane.ts | 10 +- tests/app.test.ts | 20 +++- tests/private-command-diagnostics.test.ts | 111 ++++++++++++++++++++++ 5 files changed, 180 insertions(+), 29 deletions(-) create mode 100644 tests/private-command-diagnostics.test.ts diff --git a/src/app.ts b/src/app.ts index 670320a..a63117c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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= 100 && status <= 599 ? status : null, }, createdAt: nowIso, diff --git a/src/oauth.ts b/src/oauth.ts index 0d3872c..33ae495 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -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(input: string | URL, init: RequestInit, fallbackError: string): Promise { - 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(response: Response, failure: string): Promise { 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(input: string | URL, init: RequestInit, failure: string): Promise { + return readProviderJson(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[] = []; diff --git a/src/private-command-plane.ts b/src/private-command-plane.ts index bfa346b..c1912f0 100644 --- a/src/private-command-plane.ts +++ b/src/private-command-plane.ts @@ -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, }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index a529150..374a3f4 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -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({ diff --git a/tests/private-command-diagnostics.test.ts b/tests/private-command-diagnostics.test.ts new file mode 100644 index 0000000..3204e10 --- /dev/null +++ b/tests/private-command-diagnostics.test.ts @@ -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 }> = []; +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) { + 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"); +});