From 5e5d83ebc36dbb5eb81f5455e998f4e68f165c16 Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 00:43:10 -0700 Subject: [PATCH 01/12] Add safe bridge debug reports --- js/packages/core/src/index.ts | 8 +- js/packages/core/src/lib/debug.test.ts | 128 ++++++ js/packages/core/src/lib/debug.ts | 501 ++++++++++++++++++++++ js/packages/core/src/request.ts | 245 ++++++++++- js/packages/core/src/transports/native.ts | 107 ++++- rust/core/src/bridge.rs | 164 +++++-- rust/core/src/error.rs | 11 +- rust/core/src/wasm_bindings.rs | 63 ++- 8 files changed, 1164 insertions(+), 63 deletions(-) create mode 100644 js/packages/core/src/lib/debug.test.ts diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 101ddc61..74c67f84 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -75,7 +75,13 @@ export type { IDKitErrorCode } from "./types/result"; // Utilities export { isReactNative, isWeb, isNode } from "./lib/platform"; export { isInWorldApp } from "./transports/native"; -export { isDebug, setDebug } from "./lib/debug"; +export { + isDebug, + setDebug, + setDebugReportHandler, + type IDKitDebugReport, + type IDKitDebugReportHandler, +} from "./lib/debug"; // Session utilities export { getSessionCommitment } from "./session"; diff --git a/js/packages/core/src/lib/debug.test.ts b/js/packages/core/src/lib/debug.test.ts new file mode 100644 index 00000000..5142bc65 --- /dev/null +++ b/js/packages/core/src/lib/debug.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + attachDebugReportToError, + createIDKitDebugReport, + setDebug, + setDebugReportHandler, + updateDebugReport, + type IDKitDebugReport, +} from "./debug"; + +describe("safe IDKit debug reports", () => { + afterEach(() => { + setDebug(false); + setDebugReportHandler(null); + vi.restoreAllMocks(); + }); + + it("only creates safe reports in debug mode", () => { + const options = { + mode: "request" as const, + transportKind: "bridge" as const, + config: { + app_id: "app_staging_test", + action: "claim", + bridge_url: + "https://bridge.example/request?access_token=bridge-token&proof=0xproof", + return_to: "https://rp.example/callback?token=return-token", + }, + connectorURI: + "https://world.org/verify?t=wld&i=req-123&k=bridge-key&c=ABC123", + payload: { + app_id: "app_staging_test", + proof_request: { + signature: "0xsig", + nonce: "0xnonce", + requests: [ + { + proof: "0xproof", + nullifier_hash: "0xnullifier", + safe_field: "safe", + }, + ], + }, + }, + }; + + expect(createIDKitDebugReport(options)).toBeUndefined(); + + setDebug(true); + const report = createIDKitDebugReport(options)!; + const serialized = JSON.stringify(report); + const payload = report.request.payload_before_transport as any; + + expect(report.transport.bridge_url).toContain( + "access_token=%5Bredacted%5D", + ); + expect(report.request.return_to).toContain("token=%5Bredacted%5D"); + expect(report.transport.connector_uri_redacted).toContain( + "k=%5Bredacted%5D", + ); + expect(report.transport.connector_uri_redacted).toContain( + "c=%5Bredacted%5D", + ); + expect(payload.proof_request.signature).toMatchObject({ + redacted: true, + }); + expect(payload.proof_request.nonce).toMatchObject({ redacted: true }); + expect(payload.proof_request.requests[0].proof).toMatchObject({ + redacted: true, + }); + expect(payload.proof_request.requests[0].nullifier_hash).toMatchObject({ + redacted: true, + }); + expect(payload.proof_request.requests[0].safe_field).toBe("safe"); + expect(report.request.payload_before_transport_sha256).toMatch(/^sha256:/); + + for (const secret of [ + "bridge-token", + "bridge-key", + "ABC123", + "0xsig", + "0xnonce", + "0xproof", + "0xnullifier", + "return-token", + ]) { + expect(serialized).not.toContain(secret); + } + }); + + it("emits cloned updates and replaces raw error debug payloads", () => { + setDebug(true); + const handler = vi.fn(); + setDebugReportHandler(handler); + + const report = createIDKitDebugReport({ + mode: "request", + transportKind: "bridge", + config: { + app_id: "app_staging_test", + }, + })!; + + updateDebugReport(report, { + status: "error", + connectorURI: "https://world.org/verify?t=wld&i=req-123&k=secret", + errorCode: "connection_failed", + }); + + const emitted = handler.mock.calls[0][0] as IDKitDebugReport; + expect(emitted).not.toBe(report); + expect(emitted.lifecycle.error_code).toBe("connection_failed"); + expect(emitted.transport.connector_uri_redacted).not.toContain("secret"); + + const error = new Error("boom") as Error & { + debugPayload?: unknown; + debugReport?: IDKitDebugReport; + }; + error.debugPayload = { proof_request: { signature: "0xsig" } }; + + attachDebugReportToError(error, report); + + expect(error.debugPayload).toBeUndefined(); + expect(error.debugReport).toEqual(report); + expect(Object.keys(error)).not.toContain("debugReport"); + expect(JSON.stringify(error)).not.toContain("0xsig"); + }); +}); diff --git a/js/packages/core/src/lib/debug.ts b/js/packages/core/src/lib/debug.ts index 13c04f35..705a9bb8 100644 --- a/js/packages/core/src/lib/debug.ts +++ b/js/packages/core/src/lib/debug.ts @@ -1,5 +1,138 @@ +import { sha256 } from "@noble/hashes/sha2"; +import { bytesToHex } from "@noble/hashes/utils"; +import packageJson from "../../package.json"; + let _debug = false; +const REDACTED = "[redacted]"; + +const sensitiveKeyPatterns = [ + /(^|_|\b)k($|_|\b)/i, + /key/i, + /secret/i, + /token/i, + /signature/i, + /nonce/i, + /^iv$/i, + /invite.?code/i, + /^code$/i, + /cookie/i, + /authorization/i, + /auth/i, + /proof$/i, + /nullifier/i, +]; + +const sensitiveQueryParams = new Set([ + "k", + "key", + "c", + "code", + "invite_code", + "access_token", + "refresh_token", + "token", + "secret", + "auth", + "authorization", + "signature", + "nonce", + "iv", + "proof", + "proof_response", + "nullifier", + "nullifier_hash", +]); + +export type IDKitDebugReportStatus = + | "created" + | "sent" + | "waiting_for_connection" + | "awaiting_confirmation" + | "success" + | "error" + | "cancelled" + | "timeout"; + +export type IDKitDebugTransportKind = + | "bridge" + | "invite_code_bridge" + | "native"; + +export type IDKitDebugRequestMode = + | "request" + | "invite_code_request" + | "create_session" + | "prove_session"; + +export type IDKitDebugRuntimePlatform = + | "web" + | "world_app_ios" + | "world_app_android" + | "unknown"; + +export type IDKitDebugRedactedValue = { + redacted: true; + reason: "sensitive"; + sha256?: string; + length?: number; +}; + +export type IDKitDebugReport = { + schema_version: 1; + created_at: string; + sdk: { + package_name: string; + package_version: string; + }; + runtime: { + platform: IDKitDebugRuntimePlatform; + in_world_app: boolean; + user_agent?: string; + }; + request: { + mode: IDKitDebugRequestMode; + app_id?: string; + action?: string; + environment?: string; + return_to?: string; + allow_legacy_proofs?: boolean; + require_user_presence?: boolean; + payload_before_transport?: unknown; + payload_before_transport_sha256?: string; + payload_before_transport_size_bytes?: number; + signal_hashes?: Record; + legacy_signal_hash?: string; + }; + transport: { + kind: IDKitDebugTransportKind; + bridge_url?: string; + bridge_host?: string; + request_id?: string; + connector_uri_redacted?: string; + connector_uri_sha256?: string; + native_command_version?: 1 | 2; + native_platform?: "ios" | "android" | "unknown"; + }; + lifecycle: { + created_request_at?: string; + sent_to_transport_at?: string; + response_received_at?: string; + updated_at: string; + status: IDKitDebugReportStatus; + error_code?: string; + error_message?: string; + }; + redaction: { + level: "safe"; + excluded: string[]; + }; +}; + +export type IDKitDebugReportHandler = (report: IDKitDebugReport) => void; + +let _debugReportHandler: IDKitDebugReportHandler | null = null; + export function isDebug(): boolean { if (_debug) return true; return typeof window !== "undefined" && Boolean((window as any).IDKIT_DEBUG); @@ -8,3 +141,371 @@ export function isDebug(): boolean { export function setDebug(enabled: boolean): void { _debug = enabled; } + +export function setDebugReportHandler( + handler: IDKitDebugReportHandler | null, +): void { + _debugReportHandler = handler; +} + +export function emitDebugReport(report: IDKitDebugReport | undefined): void { + if (!report || !isDebug() || !_debugReportHandler) { + return; + } + + _debugReportHandler(cloneDebugReport(report)!); +} + +export function cloneDebugReport( + report: IDKitDebugReport | undefined, +): IDKitDebugReport | undefined { + if (!report) { + return undefined; + } + + return cloneValue(report) as IDKitDebugReport; +} + +export function createIDKitDebugReport(options: { + mode: IDKitDebugRequestMode; + transportKind: IDKitDebugTransportKind; + config: { + app_id?: string; + action?: string; + environment?: string; + return_to?: string; + allow_legacy_proofs?: boolean; + require_user_presence?: boolean; + bridge_url?: string; + }; + payload?: unknown; + signalHashes?: Record; + legacySignalHash?: string; + requestId?: string; + connectorURI?: string; + nativeCommandVersion?: 1 | 2; + nativePlatform?: "ios" | "android" | "unknown"; +}): IDKitDebugReport | undefined { + if (!isDebug()) { + return undefined; + } + + const now = new Date().toISOString(); + const payloadJson = + options.payload === undefined + ? undefined + : stableStringify(options.payload); + + return { + schema_version: 1, + created_at: now, + sdk: { + package_name: packageJson.name, + package_version: packageJson.version, + }, + runtime: getRuntimeDebugInfo(), + request: { + mode: options.mode, + app_id: options.config.app_id, + action: options.config.action, + environment: options.config.environment ?? "production", + return_to: redactUrl(options.config.return_to), + allow_legacy_proofs: options.config.allow_legacy_proofs, + require_user_presence: options.config.require_user_presence, + payload_before_transport: + options.payload === undefined + ? undefined + : redactDebugValue(options.payload), + payload_before_transport_sha256: + payloadJson === undefined ? undefined : fingerprintString(payloadJson), + payload_before_transport_size_bytes: + payloadJson === undefined + ? undefined + : new TextEncoder().encode(payloadJson).byteLength, + signal_hashes: options.signalHashes, + legacy_signal_hash: options.legacySignalHash, + }, + transport: { + kind: options.transportKind, + bridge_url: redactUrl(options.config.bridge_url), + bridge_host: getUrlHost(options.config.bridge_url), + request_id: options.requestId, + connector_uri_redacted: redactUrl(options.connectorURI), + connector_uri_sha256: options.connectorURI + ? fingerprintString(options.connectorURI) + : undefined, + native_command_version: options.nativeCommandVersion, + native_platform: options.nativePlatform, + }, + lifecycle: { + created_request_at: now, + updated_at: now, + status: "created", + }, + redaction: { + level: "safe", + excluded: [ + "encryption keys", + "bridge decrypt key", + "encryption nonce or iv", + "full connector uri", + "invite code", + "encrypted request body", + "encrypted bridge response", + "decrypted proof response", + "proof values", + "nullifier values", + "cookies", + "auth headers", + "access tokens", + "private keys", + "backend signing secrets", + ], + }, + }; +} + +export function updateDebugReport( + report: IDKitDebugReport | undefined, + update: { + status?: IDKitDebugReportStatus; + requestId?: string; + connectorURI?: string; + sentToTransportAt?: string; + responseReceivedAt?: string; + errorCode?: string; + errorMessage?: string; + }, +): IDKitDebugReport | undefined { + if (!report) { + return undefined; + } + + if (update.requestId) { + report.transport.request_id = update.requestId; + } + if (update.connectorURI) { + report.transport.connector_uri_redacted = redactUrl(update.connectorURI); + report.transport.connector_uri_sha256 = fingerprintString( + update.connectorURI, + ); + } + + report.lifecycle.updated_at = new Date().toISOString(); + report.lifecycle.status = update.status ?? report.lifecycle.status; + + if (update.sentToTransportAt) { + report.lifecycle.sent_to_transport_at = update.sentToTransportAt; + } + if (update.responseReceivedAt) { + report.lifecycle.response_received_at = update.responseReceivedAt; + } + if (update.errorCode) { + report.lifecycle.error_code = update.errorCode; + } + if (update.errorMessage) { + report.lifecycle.error_message = update.errorMessage; + } + + emitDebugReport(report); + return report; +} + +export function attachDebugReportToError( + error: T, + report: IDKitDebugReport | undefined, +): T { + if ( + !report || + (typeof error !== "object" && typeof error !== "function") || + error === null + ) { + return error; + } + + deleteRawDebugPayload(error); + + Object.defineProperty(error, "debugReport", { + configurable: true, + enumerable: false, + value: cloneDebugReport(report), + }); + + return error; +} + +function deleteRawDebugPayload(error: object): void { + if (!("debugPayload" in error)) { + return; + } + + try { + delete (error as { debugPayload?: unknown }).debugPayload; + return; + } catch { + // Fall through to the non-enumerable overwrite below. + } + + try { + Object.defineProperty(error, "debugPayload", { + configurable: true, + enumerable: false, + value: undefined, + }); + } catch { + // Best effort: never let debug-report attachment fail because cleanup did. + } +} + +function getRuntimeDebugInfo(): IDKitDebugReport["runtime"] { + const userAgent = + typeof navigator !== "undefined" ? navigator.userAgent : undefined; + const worldApp = + typeof window !== "undefined" ? (window as any).WorldApp : undefined; + const isWorldApp = Boolean(worldApp); + + return { + platform: getRuntimePlatform(isWorldApp), + in_world_app: isWorldApp, + user_agent: userAgent, + }; +} + +function getRuntimePlatform(inWorldApp: boolean): IDKitDebugRuntimePlatform { + if (!inWorldApp) { + return typeof window === "undefined" ? "unknown" : "web"; + } + + const w = window as any; + if (w.webkit?.messageHandlers?.minikit) { + return "world_app_ios"; + } + if (w.Android) { + return "world_app_android"; + } + return "unknown"; +} + +function shouldRedactKey(key: string): boolean { + return sensitiveKeyPatterns.some((pattern) => pattern.test(key)); +} + +function redactDebugValue(value: unknown, key = ""): unknown { + if (shouldRedactKey(key)) { + return makeRedactedValue(value); + } + + if (value instanceof Uint8Array) { + return { + type: "Uint8Array", + length: value.byteLength, + sha256: fingerprintBytes(value), + }; + } + + if (Array.isArray(value)) { + return value.map((item) => redactDebugValue(item)); + } + + if (value && typeof value === "object") { + const object = value as Record; + const entries = Object.entries(object).map(([childKey, childValue]) => [ + childKey, + redactDebugValue(childValue, childKey), + ]); + return Object.fromEntries(entries); + } + + return value; +} + +function makeRedactedValue(value: unknown): IDKitDebugRedactedValue { + const valueString = stableStringify(value); + return { + redacted: true, + reason: "sensitive", + sha256: fingerprintString(valueString), + length: valueString.length, + }; +} + +function redactUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + try { + const url = new URL(value); + for (const key of Array.from(url.searchParams.keys())) { + if (sensitiveQueryParams.has(key.toLowerCase())) { + url.searchParams.set(key, REDACTED); + } + } + if (url.hash) { + url.hash = REDACTED; + } + return url.toString(); + } catch { + return value; + } +} + +function getUrlHost(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + try { + return new URL(value).host; + } catch { + return undefined; + } +} + +function fingerprintString(value: string): string { + return `sha256:${fingerprintBytes(new TextEncoder().encode(value))}`; +} + +function fingerprintBytes(value: Uint8Array): string { + return bytesToHex(sha256(value)); +} + +function stableStringify(value: unknown): string { + return JSON.stringify(normalizeForJson(value)) ?? "undefined"; +} + +function normalizeForJson(value: unknown): unknown { + if (typeof value === "bigint") { + return value.toString(); + } + + if (value instanceof Uint8Array) { + return { + type: "Uint8Array", + data: Array.from(value), + }; + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeForJson(item)); + } + + if (value && typeof value === "object") { + const object = value as Record; + return Object.fromEntries( + Object.keys(object) + .sort() + .map((childKey) => [childKey, normalizeForJson(object[childKey])]), + ); + } + + return value; +} + +function cloneValue(value: unknown): unknown { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + + return JSON.parse(stableStringify(value)); +} diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index d5c3a71a..dfee6790 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -23,6 +23,15 @@ import { createNativeRequest, type BuilderConfig, } from "./transports/native"; +import { + attachDebugReportToError, + cloneDebugReport, + createIDKitDebugReport, + updateDebugReport, + type IDKitDebugReport, + type IDKitDebugRequestMode, + type IDKitDebugTransportKind, +} from "./lib/debug"; /** Options for pollUntilCompletion() */ export interface WaitOptions { @@ -70,6 +79,8 @@ export interface IDKitRequest { pollOnce(): Promise; /** Poll continuously until completion or timeout */ pollUntilCompletion(options?: WaitOptions): Promise; + /** Safe, shareable report for debugging this request when debug mode is enabled */ + getDebugReport(): IDKitDebugReport | undefined; } /** @@ -112,6 +123,88 @@ async function pollUntilCompletionLoop( } } +function requestModeFromConfig(config: BuilderConfig): IDKitDebugRequestMode { + if (config.type === "createSession") return "create_session"; + if (config.type === "proveSession") return "prove_session"; + return "request"; +} + +function getWasmDebugPayload(wasmRequest: unknown): unknown { + const candidate = wasmRequest as { debugPayload?: () => unknown }; + if (typeof candidate.debugPayload !== "function") { + return undefined; + } + + try { + return candidate.debugPayload(); + } catch { + return undefined; + } +} + +function getErrorDebugPayload(error: unknown): unknown { + if ( + (typeof error !== "object" && typeof error !== "function") || + error === null + ) { + return undefined; + } + + return (error as { debugPayload?: unknown }).debugPayload; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function updateDebugReportFromStatus( + report: IDKitDebugReport | undefined, + status: Status, +): void { + if (status.type === "confirmed") { + updateDebugReport(report, { + status: "success", + responseReceivedAt: new Date().toISOString(), + }); + return; + } + + if (status.type === "failed") { + updateDebugReport(report, { + status: "error", + responseReceivedAt: new Date().toISOString(), + errorCode: status.error, + }); + return; + } + + updateDebugReport(report, { + status: status.type, + }); +} + +function attachBridgeCreationDebugReport( + error: unknown, + config: BuilderConfig, + mode: IDKitDebugRequestMode, + transportKind: IDKitDebugTransportKind, +): never { + const report = createIDKitDebugReport({ + mode, + transportKind, + config, + payload: getErrorDebugPayload(error), + }); + updateDebugReport(report, { + status: "error", + errorMessage: getErrorMessage(error), + }); + throw attachDebugReportToError(error, report); +} + /** * Internal request implementation (bridge/WASM path) */ @@ -119,11 +212,29 @@ class IDKitRequestImpl implements IDKitRequest { private wasmRequest: WasmModule.IDKitRequest; private _connectorURI: string; private _requestId: string; - - constructor(wasmRequest: WasmModule.IDKitRequest) { + private debugReport?: IDKitDebugReport; + + constructor( + wasmRequest: WasmModule.IDKitRequest, + config: BuilderConfig, + mode: IDKitDebugRequestMode, + transportKind: IDKitDebugTransportKind, + ) { this.wasmRequest = wasmRequest; this._connectorURI = wasmRequest.connectUrl(); this._requestId = wasmRequest.requestId(); + this.debugReport = createIDKitDebugReport({ + mode, + transportKind, + config, + payload: getWasmDebugPayload(wasmRequest), + requestId: this._requestId, + connectorURI: this._connectorURI, + }); + updateDebugReport(this.debugReport, { + status: "sent", + sentToTransportAt: new Date().toISOString(), + }); } get connectorURI(): string { @@ -135,12 +246,26 @@ class IDKitRequestImpl implements IDKitRequest { } async pollOnce(): Promise { - return (await this.wasmRequest.pollForStatus()) as Status; + try { + const status = (await this.wasmRequest.pollForStatus()) as Status; + updateDebugReportFromStatus(this.debugReport, status); + return status; + } catch (error) { + updateDebugReport(this.debugReport, { + status: "error", + errorMessage: getErrorMessage(error), + }); + throw attachDebugReportToError(error, this.debugReport); + } } pollUntilCompletion(options?: WaitOptions): Promise { return pollUntilCompletionLoop(() => this.pollOnce(), options); } + + getDebugReport(): IDKitDebugReport | undefined { + return cloneDebugReport(this.debugReport); + } } /** @@ -163,6 +288,8 @@ export interface IDKitInviteCodeRequest { pollOnce(): Promise; /** Poll continuously until completion or timeout */ pollUntilCompletion(options?: WaitOptions): Promise; + /** Safe, shareable report for debugging this request when debug mode is enabled */ + getDebugReport(): IDKitDebugReport | undefined; } /** @@ -175,12 +302,28 @@ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { private _connectorURI: string; private _expiresAt: number; private _requestId: string; + private debugReport?: IDKitDebugReport; - constructor(wasmRequest: WasmModule.IDKitInviteCodeRequest) { + constructor( + wasmRequest: WasmModule.IDKitInviteCodeRequest, + config: BuilderConfig, + ) { this.wasmRequest = wasmRequest; this._connectorURI = wasmRequest.connectUrl(); this._expiresAt = wasmRequest.expiresAt(); this._requestId = wasmRequest.requestId(); + this.debugReport = createIDKitDebugReport({ + mode: "invite_code_request", + transportKind: "invite_code_bridge", + config, + payload: getWasmDebugPayload(wasmRequest), + requestId: this._requestId, + connectorURI: this._connectorURI, + }); + updateDebugReport(this.debugReport, { + status: "sent", + sentToTransportAt: new Date().toISOString(), + }); } get connectorURI(): string { @@ -196,12 +339,26 @@ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { } async pollOnce(): Promise { - return (await this.wasmRequest.pollForStatus()) as Status; + try { + const status = (await this.wasmRequest.pollForStatus()) as Status; + updateDebugReportFromStatus(this.debugReport, status); + return status; + } catch (error) { + updateDebugReport(this.debugReport, { + status: "error", + errorMessage: getErrorMessage(error), + }); + throw attachDebugReportToError(error, this.debugReport); + } } pollUntilCompletion(options?: WaitOptions): Promise { return pollUntilCompletionLoop(() => this.pollOnce(), options); } + + getDebugReport(): IDKitDebugReport | undefined { + return cloneDebugReport(this.debugReport); + } } // ───────────────────────────────────────────────────────────────────────────── @@ -617,10 +774,24 @@ class IDKitBuilder { // Bridge path — WASM const wasmBuilder = createWasmBuilderFromConfig(this.config); - const wasmRequest = (await wasmBuilder.constraints( - constraints, - )) as unknown as WasmModule.IDKitRequest; - return new IDKitRequestImpl(wasmRequest); + try { + const wasmRequest = (await wasmBuilder.constraints( + constraints, + )) as unknown as WasmModule.IDKitRequest; + return new IDKitRequestImpl( + wasmRequest, + this.config, + requestModeFromConfig(this.config), + "bridge", + ); + } catch (error) { + attachBridgeCreationDebugReport( + error, + this.config, + requestModeFromConfig(this.config), + "bridge", + ); + } } /** @@ -696,10 +867,24 @@ class IDKitBuilder { // Bridge path — WASM const wasmBuilder = createWasmBuilderFromConfig(this.config); - const wasmRequest = (await wasmBuilder.preset( - preset, - )) as unknown as WasmModule.IDKitRequest; - return new IDKitRequestImpl(wasmRequest); + try { + const wasmRequest = (await wasmBuilder.preset( + preset, + )) as unknown as WasmModule.IDKitRequest; + return new IDKitRequestImpl( + wasmRequest, + this.config, + requestModeFromConfig(this.config), + "bridge", + ); + } catch (error) { + attachBridgeCreationDebugReport( + error, + this.config, + requestModeFromConfig(this.config), + "bridge", + ); + } } } @@ -737,10 +922,19 @@ class IDKitInviteCodeBuilder { await initIDKit(); const wasmBuilder = createWasmBuilderFromConfig(this.config); - const wasmRequest = (await wasmBuilder.constraintsWithInviteCode( - constraints, - )) as unknown as WasmModule.IDKitInviteCodeRequest; - return new IDKitInviteCodeRequestImpl(wasmRequest); + try { + const wasmRequest = (await wasmBuilder.constraintsWithInviteCode( + constraints, + )) as unknown as WasmModule.IDKitInviteCodeRequest; + return new IDKitInviteCodeRequestImpl(wasmRequest, this.config); + } catch (error) { + attachBridgeCreationDebugReport( + error, + this.config, + "invite_code_request", + "invite_code_bridge", + ); + } } /** @@ -762,10 +956,19 @@ class IDKitInviteCodeBuilder { await initIDKit(); const wasmBuilder = createWasmBuilderFromConfig(this.config); - const wasmRequest = (await wasmBuilder.presetWithInviteCode( - preset, - )) as unknown as WasmModule.IDKitInviteCodeRequest; - return new IDKitInviteCodeRequestImpl(wasmRequest); + try { + const wasmRequest = (await wasmBuilder.presetWithInviteCode( + preset, + )) as unknown as WasmModule.IDKitInviteCodeRequest; + return new IDKitInviteCodeRequestImpl(wasmRequest, this.config); + } catch (error) { + attachBridgeCreationDebugReport( + error, + this.config, + "invite_code_request", + "invite_code_bridge", + ); + } } } diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index ab66a80a..93a8101c 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -25,7 +25,15 @@ import type { IDKitResult } from "../types/result"; import { IDKitErrorCodes } from "../types/result"; import type { IDKitResultV3, IntegrityBundle } from "../lib/wasm"; import { WasmModule } from "../lib/wasm"; -import { isDebug } from "../lib/debug"; +import { + cloneDebugReport, + createIDKitDebugReport, + emitDebugReport, + isDebug, + updateDebugReport, + type IDKitDebugReport, + type IDKitDebugRequestMode, +} from "../lib/debug"; const MINIAPP_VERIFY_ACTION = "miniapp-verify-action"; @@ -85,6 +93,49 @@ export interface BuilderConfig { environment?: string; } +function requestModeFromConfig(config: BuilderConfig): IDKitDebugRequestMode { + if (config.type === "createSession") return "create_session"; + if (config.type === "proveSession") return "prove_session"; + return "request"; +} + +function getNativePlatform(): "ios" | "android" | "unknown" { + const w = window as any; + if (w.webkit?.messageHandlers?.minikit) return "ios"; + if (w.Android) return "android"; + return "unknown"; +} + +function nativeSendLogDetails( + report: IDKitDebugReport | undefined, + requestId: string, + version: 1 | 2, +) { + return { + command: "verify", + version, + requestId, + payload_sha256: report?.request.payload_before_transport_sha256, + payload_size_bytes: report?.request.payload_before_transport_size_bytes, + }; +} + +function nativeResponseLogDetails(payload: unknown) { + const p = payload as Record; + return { + status: p?.status, + error_code: p?.error_code, + protocol_version: p?.protocol_version, + has_proof_response: p?.proof_response != null, + proof_response_count: Array.isArray(p?.proof_response?.responses) + ? p.proof_response.responses.length + : undefined, + verification_count: Array.isArray(p?.verifications) + ? p.verifications.length + : undefined, + }; +} + // ───────────────────────────────────────────────────────────────────────────── // Native IDKit request // ───────────────────────────────────────────────────────────────────────────── @@ -119,14 +170,26 @@ export function createNativeRequest( console.warn( "[IDKit] Native: request already in flight, reusing active request", ); + emitDebugReport(_activeNativeRequest.getDebugReport()); return _activeNativeRequest; } + const debugReport = createIDKitDebugReport({ + mode: requestModeFromConfig(config), + transportKind: "native", + config, + payload: wasmPayload, + signalHashes, + legacySignalHash, + nativeCommandVersion: version, + nativePlatform: getNativePlatform(), + }); const request = new NativeIDKitRequest( wasmPayload, config, signalHashes, legacySignalHash, version, + debugReport, ); _activeNativeRequest = request; return request; @@ -141,6 +204,7 @@ class NativeIDKitRequest implements IDKitRequest { private resolveFn: ((result: IDKitCompletionResult) => void) | null = null; private messageHandler: ((event: MessageEvent) => void) | null = null; private miniKitHandler: ((payload: any) => void) | null = null; + private debugReport?: IDKitDebugReport; constructor( wasmPayload: unknown, @@ -148,9 +212,13 @@ class NativeIDKitRequest implements IDKitRequest { signalHashes: Record = {}, legacySignalHash: string, version: 1 | 2 = 2, + debugReport?: IDKitDebugReport, ) { this.requestId = crypto.randomUUID?.() ?? `native-${Date.now()}-${++_requestCounter}`; + this.debugReport = updateDebugReport(debugReport, { + requestId: this.requestId, + }); // Never rejects — all outcomes (success, error, cancel, timeout) resolve. this.resultPromise = new Promise((resolve) => { @@ -159,8 +227,15 @@ class NativeIDKitRequest implements IDKitRequest { const handleIncomingPayload = (responsePayload: any) => { if (this.completionResult) return; + updateDebugReport(this.debugReport, { + responseReceivedAt: new Date().toISOString(), + }); + if (isDebug()) - console.debug("[IDKit] Native: received response", responsePayload); + console.debug( + "[IDKit] Native: received response", + nativeResponseLogDetails(responsePayload), + ); if (responsePayload?.status === "error") { if (isDebug()) @@ -251,16 +326,24 @@ class NativeIDKitRequest implements IDKitRequest { if (isDebug()) console.debug( `[IDKit] Native: sending verify command (version=${version}, platform=ios)`, - sendPayload, + nativeSendLogDetails(this.debugReport, this.requestId, version), ); w.webkit.messageHandlers.minikit.postMessage(sendPayload); + updateDebugReport(this.debugReport, { + status: "sent", + sentToTransportAt: new Date().toISOString(), + }); } else if (w.Android) { if (isDebug()) console.debug( `[IDKit] Native: sending verify command (version=${version}, platform=android)`, - sendPayload, + nativeSendLogDetails(this.debugReport, this.requestId, version), ); w.Android.postMessage(JSON.stringify(sendPayload)); + updateDebugReport(this.debugReport, { + status: "sent", + sentToTransportAt: new Date().toISOString(), + }); } else { if (isDebug()) console.warn( @@ -290,6 +373,18 @@ class NativeIDKitRequest implements IDKitRequest { result.success === true ? "success" : `error=${result.error}`, ); this.completionResult = result; + const status = + result.success === true + ? "success" + : result.error === IDKitErrorCodes.Cancelled + ? "cancelled" + : result.error === IDKitErrorCodes.Timeout + ? "timeout" + : "error"; + updateDebugReport(this.debugReport, { + status, + errorCode: result.success === true ? undefined : result.error, + }); this.cleanup(); this.resolveFn?.(result); if (_activeNativeRequest === this) { @@ -297,6 +392,10 @@ class NativeIDKitRequest implements IDKitRequest { } } + getDebugReport(): IDKitDebugReport | undefined { + return cloneDebugReport(this.debugReport); + } + cancel(): void { this.complete({ success: false, error: IDKitErrorCodes.Cancelled }); } diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 0f043b50..8700d915 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -508,6 +508,8 @@ pub struct BridgeConnection { /// Cached signal hashes of the request /// Used to add the `signal_hash` back to the idkit response for convenience cached_signal_hashes: CachedSignalHashes, + /// Request payload built by IDKit before bridge encryption/wrapping. + debug_payload: serde_json::Value, /// Action identifier (only for uniqueness proofs) action: Option, /// Action description (only if provided in input) @@ -731,29 +733,43 @@ impl BridgeConnection { // Send to bridge let client = reqwest::Client::builder() .user_agent(format!("idkit-core/{}", env!("CARGO_PKG_VERSION"))) - .build()?; + .build() + .map_err(|e| { + bridge_request_failed(format!("Bridge client build failed: {e}"), &payload_value) + })?; let response = client .post(bridge_url.join("/request")?) .json(&body) .send() - .await?; + .await + .map_err(|e| { + bridge_request_failed(format!("Bridge request failed: {e}"), &payload_value) + })?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(Error::BridgeError(format!( - "Bridge request failed with status {}: {}", - status, - if body.is_empty() { - "no error details" - } else { - &body - } - ))); + return Err(bridge_request_failed( + format!( + "Bridge request failed with status {}: {}", + status, + if body.is_empty() { + "no error details" + } else { + &body + } + ), + &payload_value, + )); } - let create_response: BridgeCreateResponse = response.json().await?; + let create_response: BridgeCreateResponse = response.json().await.map_err(|e| { + bridge_request_failed( + format!("Failed to parse bridge response: {e}"), + &payload_value, + ) + })?; // Extract action from kind for result let action = match ¶ms.kind { @@ -772,6 +788,7 @@ impl BridgeConnection { app_id, client, cached_signal_hashes, + debug_payload: payload_value, action, action_description: params.action_description, nonce: params.rp_context.nonce.clone(), @@ -1062,6 +1079,15 @@ impl BridgeConnection { &self.request_id } + /// Returns the pre-encryption request payload for safe SDK debug reporting. + /// + /// This does not include bridge encryption keys, IVs, invite codes, or + /// encrypted request/response bodies. + #[must_use] + pub fn debug_payload(&self) -> &serde_json::Value { + &self.debug_payload + } + /// Unix-seconds expiry of the unredeemed code, if this connection was /// created in invite-code mode. #[must_use] @@ -1077,6 +1103,13 @@ enum CreateCodeError { Other(Error), } +fn bridge_request_failed(message: impl Into, debug_payload: &serde_json::Value) -> Error { + Error::BridgeRequestFailed { + message: message.into(), + debug_payload: Box::new(debug_payload.clone()), + } +} + impl From for CreateCodeError { fn from(e: Error) -> Self { Self::Other(e) @@ -1142,14 +1175,18 @@ async fn try_create_invite_code_request( let client = reqwest::Client::builder() .user_agent(format!("idkit-core/{}", env!("CARGO_PKG_VERSION"))) .build() - .map_err(Error::from)?; + .map_err(|e| { + bridge_request_failed(format!("Bridge client build failed: {e}"), &payload_value) + })?; let response = client .post(bridge_url.join("/request")?) .json(&body) .send() .await - .map_err(|e| Error::BridgeError(format!("Bridge request failed: {e}")))?; + .map_err(|e| { + bridge_request_failed(format!("Bridge request failed: {e}"), &payload_value) + })?; if response.status() == reqwest::StatusCode::CONFLICT { return Err(CreateCodeError::Conflict); @@ -1157,14 +1194,17 @@ async fn try_create_invite_code_request( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(Error::BridgeError(format!( - "Bridge /request (code) failed with status {status}: {}", - if body.is_empty() { - "no error details" - } else { - &body - } - )) + return Err(bridge_request_failed( + format!( + "Bridge /request (code) failed with status {status}: {}", + if body.is_empty() { + "no error details" + } else { + &body + } + ), + &payload_value, + ) .into()); } @@ -1175,15 +1215,20 @@ async fn try_create_invite_code_request( // the World App side and we'd fail in a confusing way much later in the // poll loop. Catching the mismatch here surfaces the contract violation // at creation time. - let echoed: BridgeCreateResponse = response - .json() - .await - .map_err(|e| Error::BridgeError(format!("Failed to parse bridge response: {e}")))?; + let echoed: BridgeCreateResponse = response.json().await.map_err(|e| { + bridge_request_failed( + format!("Failed to parse bridge response: {e}"), + &payload_value, + ) + })?; if echoed.request_id != request_id { - return Err(Error::BridgeError(format!( - "Bridge echoed mismatched request_id (sent {request_id}, got {})", - echoed.request_id - )) + return Err(bridge_request_failed( + format!( + "Bridge echoed mismatched request_id (sent {request_id}, got {})", + echoed.request_id + ), + &payload_value, + ) .into()); } @@ -1210,6 +1255,7 @@ async fn try_create_invite_code_request( app_id: params.app_id.as_str().to_string(), client, cached_signal_hashes, + debug_payload: payload_value, action, action_description: params.action_description.clone(), nonce: params.rp_context.nonce.clone(), @@ -1727,7 +1773,7 @@ impl From for StatusWrapper { fn to_app_error(error: &Error) -> AppError { match error { Error::InvalidConfiguration(_) => AppError::MalformedRequest, - Error::BridgeError(_) => AppError::ConnectionFailed, + Error::BridgeError(_) | Error::BridgeRequestFailed { .. } => AppError::ConnectionFailed, Error::Json(_) => AppError::UnexpectedResponse, Error::Crypto(_) => AppError::UnexpectedResponse, Error::Base64(_) => AppError::UnexpectedResponse, @@ -1748,7 +1794,10 @@ fn to_app_error(error: &Error) -> AppError { #[cfg(feature = "ffi")] fn is_networking_error(error: &Error) -> bool { match error { - Error::Timeout | Error::ConnectionFailed | Error::BridgeError(_) => true, + Error::Timeout + | Error::ConnectionFailed + | Error::BridgeError(_) + | Error::BridgeRequestFailed { .. } => true, #[cfg(any(feature = "bridge", feature = "bridge-wasm"))] Error::Http(err) => err.is_timeout() || err.is_request(), _ => false, @@ -2391,6 +2440,42 @@ mod tests { assert_eq!(status, Status::Failed(AppError::InvalidRpSignature)); } + #[test] + fn test_bridge_request_failed_carries_debug_payload_snapshot() { + let mut payload = serde_json::json!({ + "app_id": "app_test", + "action": "test-action", + "proof_request": { + "signature": "0xsig", + "nonce": "0xnonce" + } + }); + + let error = bridge_request_failed("network failure", &payload); + payload["app_id"] = serde_json::json!("app_mutated"); + + match error { + Error::BridgeRequestFailed { + message, + debug_payload, + } => { + assert_eq!(message, "network failure"); + assert_eq!(debug_payload["app_id"], "app_test"); + assert_eq!(debug_payload["proof_request"]["signature"], "0xsig"); + assert_eq!(debug_payload["proof_request"]["nonce"], "0xnonce"); + } + other => panic!("expected BridgeRequestFailed, got {other:?}"), + } + } + + #[cfg(feature = "ffi")] + #[test] + fn test_bridge_request_failed_maps_to_connection_failed_app_error() { + let error = bridge_request_failed("network failure", &serde_json::json!({})); + + assert_eq!(to_app_error(&error), AppError::ConnectionFailed); + } + #[test] fn test_bridge_response_error_deserialization() { let json = r#"{"error_code": "user_rejected"}"#; @@ -3256,6 +3341,7 @@ mod tests { signal_hashes: std::collections::HashMap::new(), legacy_signal_hash: String::new(), }, + debug_payload: serde_json::json!({"app_id": "app_test"}), action: Some("test-action".to_string()), action_description: None, nonce: "0x01".to_string(), @@ -3283,4 +3369,18 @@ mod tests { assert!(!url.contains("return_to=")); } + + #[test] + fn test_debug_payload_is_separate_from_connect_url_secrets() { + let connection = sample_connection(None); + let connect_url = connection.connect_url(); + let debug_payload = connection.debug_payload(); + + assert!(connect_url.contains("k=")); + assert!(debug_payload.get("k").is_none()); + assert!(debug_payload.get("key").is_none()); + assert!(debug_payload.get("iv").is_none()); + assert!(debug_payload.get("payload").is_none()); + assert!(debug_payload.get("invite_code").is_none()); + } } diff --git a/rust/core/src/error.rs b/rust/core/src/error.rs index 76f82dcf..83d0c72d 100644 --- a/rust/core/src/error.rs +++ b/rust/core/src/error.rs @@ -16,6 +16,13 @@ pub enum Error { #[error("Bridge error: {0}")] BridgeError(String), + /// Bridge request creation failed after the pre-encryption payload was built. + #[error("Bridge error: {message}")] + BridgeRequestFailed { + message: String, + debug_payload: Box, + }, + /// JSON serialization/deserialization error #[error("JSON error: {0}")] Json(#[from] serde_json::Error), @@ -272,7 +279,9 @@ impl From for IdkitError { details: err.to_string(), }, Error::InvalidProof(message) => Self::InvalidProof { details: message }, - Error::BridgeError(message) => Self::BridgeError { details: message }, + Error::BridgeError(message) | Error::BridgeRequestFailed { message, .. } => { + Self::BridgeError { details: message } + } Error::AppError(app_err) => Self::AppError { details: app_err.to_string(), }, diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index 76ce37cd..189db1c7 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -29,6 +29,23 @@ pub fn init_wasm() { }); } +fn bridge_create_error_to_js(error: crate::Error) -> JsValue { + let js_error = js_sys::Error::new(&format!("Failed: {error}")); + + if let crate::Error::BridgeRequestFailed { debug_payload, .. } = &error { + let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + if let Ok(value) = debug_payload.serialize(&serializer) { + let _ = js_sys::Reflect::set( + js_error.as_ref(), + &JsValue::from_str("debugPayload"), + &value, + ); + } + } + + js_error.into() +} + /// WASM wrapper for `CredentialRequest` #[wasm_bindgen(js_name = CredentialRequestWasm)] pub struct CredentialRequestWasm(CredentialRequest); @@ -1014,7 +1031,7 @@ impl IDKitBuilderWasm { let params = config.to_params(Some(constraints))?; let connection = crate::bridge::BridgeConnection::create(params) .await - .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; + .map_err(bridge_create_error_to_js)?; Ok(JsValue::from(IDKitRequest { inner: Rc::new(RefCell::new(Some(connection))), @@ -1032,7 +1049,7 @@ impl IDKitBuilderWasm { let params = config.to_params_from_preset(preset)?; let connection = crate::bridge::BridgeConnection::create(params) .await - .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; + .map_err(bridge_create_error_to_js)?; Ok(JsValue::from(IDKitRequest { inner: Rc::new(RefCell::new(Some(connection))), @@ -1051,7 +1068,7 @@ impl IDKitBuilderWasm { let params = config.to_params(Some(constraints))?; let connection = crate::bridge::BridgeConnection::create_for_invite_code(params) .await - .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; + .map_err(bridge_create_error_to_js)?; Ok(JsValue::from(IDKitInviteCodeRequest { inner: Rc::new(RefCell::new(Some(connection))), @@ -1070,7 +1087,7 @@ impl IDKitBuilderWasm { let params = config.to_params_from_preset(preset)?; let connection = crate::bridge::BridgeConnection::create_for_invite_code(params) .await - .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; + .map_err(bridge_create_error_to_js)?; Ok(JsValue::from(IDKitInviteCodeRequest { inner: Rc::new(RefCell::new(Some(connection))), @@ -1210,6 +1227,25 @@ impl IDKitRequest { .map(|s| s.request_id().to_string()) } + /// Returns the pre-encryption payload for safe debug report generation. + /// + /// # Errors + /// + /// Returns an error if the request has been closed or serialization fails. + #[wasm_bindgen(js_name = debugPayload)] + pub fn debug_payload(&self) -> Result { + let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + self.inner + .borrow() + .as_ref() + .ok_or_else(|| JsValue::from_str("Request closed")) + .and_then(|s| { + s.debug_payload() + .serialize(&serializer) + .map_err(|e| JsValue::from_str(&format!("Serialization failed: {e}"))) + }) + } + /// Polls the bridge for the current status (non-blocking) /// /// Returns a status object with type: @@ -1303,6 +1339,25 @@ impl IDKitInviteCodeRequest { .map(|s| s.request_id().to_string()) } + /// Returns the pre-encryption payload for safe debug report generation. + /// + /// # Errors + /// + /// Returns an error if the request has been closed or serialization fails. + #[wasm_bindgen(js_name = debugPayload)] + pub fn debug_payload(&self) -> Result { + let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + self.inner + .borrow() + .as_ref() + .ok_or_else(|| JsValue::from_str("Request closed")) + .and_then(|s| { + s.debug_payload() + .serialize(&serializer) + .map_err(|e| JsValue::from_str(&format!("Serialization failed: {e}"))) + }) + } + /// Polls the bridge for the current status (non-blocking). /// /// Mirrors `IDKitRequest::pollForStatus` exactly — same status shape, From 492721374aa8050c9e5f4b7a7f4edb5a28d525d8 Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 10:31:02 -0700 Subject: [PATCH 02/12] Make debug reports include raw debug data --- js/packages/core/src/lib/debug.test.ts | 50 +++---- js/packages/core/src/lib/debug.ts | 162 +++------------------- js/packages/core/src/request.ts | 11 +- js/packages/core/src/transports/native.ts | 8 +- rust/core/src/bridge.rs | 2 +- rust/core/src/wasm_bindings.rs | 4 +- 6 files changed, 51 insertions(+), 186 deletions(-) diff --git a/js/packages/core/src/lib/debug.test.ts b/js/packages/core/src/lib/debug.test.ts index 5142bc65..d6662320 100644 --- a/js/packages/core/src/lib/debug.test.ts +++ b/js/packages/core/src/lib/debug.test.ts @@ -6,16 +6,17 @@ import { setDebugReportHandler, updateDebugReport, type IDKitDebugReport, + requestModeFromConfig, } from "./debug"; -describe("safe IDKit debug reports", () => { +describe("IDKit debug reports", () => { afterEach(() => { setDebug(false); setDebugReportHandler(null); vi.restoreAllMocks(); }); - it("only creates safe reports in debug mode", () => { + it("only creates raw reports in debug mode", () => { const options = { mode: "request" as const, transportKind: "bridge" as const, @@ -51,26 +52,17 @@ describe("safe IDKit debug reports", () => { const serialized = JSON.stringify(report); const payload = report.request.payload_before_transport as any; - expect(report.transport.bridge_url).toContain( - "access_token=%5Bredacted%5D", - ); - expect(report.request.return_to).toContain("token=%5Bredacted%5D"); - expect(report.transport.connector_uri_redacted).toContain( - "k=%5Bredacted%5D", - ); - expect(report.transport.connector_uri_redacted).toContain( - "c=%5Bredacted%5D", + expect(report.transport.bridge_url).toContain("access_token=bridge-token"); + expect(report.transport.bridge_url).toContain("proof=0xproof"); + expect(report.request.return_to).toContain("token=return-token"); + expect(report.transport.connector_uri).toContain("k=bridge-key"); + expect(report.transport.connector_uri).toContain("c=ABC123"); + expect(payload.proof_request.signature).toBe("0xsig"); + expect(payload.proof_request.nonce).toBe("0xnonce"); + expect(payload.proof_request.requests[0].proof).toBe("0xproof"); + expect(payload.proof_request.requests[0].nullifier_hash).toBe( + "0xnullifier", ); - expect(payload.proof_request.signature).toMatchObject({ - redacted: true, - }); - expect(payload.proof_request.nonce).toMatchObject({ redacted: true }); - expect(payload.proof_request.requests[0].proof).toMatchObject({ - redacted: true, - }); - expect(payload.proof_request.requests[0].nullifier_hash).toMatchObject({ - redacted: true, - }); expect(payload.proof_request.requests[0].safe_field).toBe("safe"); expect(report.request.payload_before_transport_sha256).toMatch(/^sha256:/); @@ -84,11 +76,11 @@ describe("safe IDKit debug reports", () => { "0xnullifier", "return-token", ]) { - expect(serialized).not.toContain(secret); + expect(serialized).toContain(secret); } }); - it("emits cloned updates and replaces raw error debug payloads", () => { + it("emits cloned updates and attaches reports without enumerating them on errors", () => { setDebug(true); const handler = vi.fn(); setDebugReportHandler(handler); @@ -110,7 +102,7 @@ describe("safe IDKit debug reports", () => { const emitted = handler.mock.calls[0][0] as IDKitDebugReport; expect(emitted).not.toBe(report); expect(emitted.lifecycle.error_code).toBe("connection_failed"); - expect(emitted.transport.connector_uri_redacted).not.toContain("secret"); + expect(emitted.transport.connector_uri).toContain("secret"); const error = new Error("boom") as Error & { debugPayload?: unknown; @@ -125,4 +117,14 @@ describe("safe IDKit debug reports", () => { expect(Object.keys(error)).not.toContain("debugReport"); expect(JSON.stringify(error)).not.toContain("0xsig"); }); + + it("maps builder config request types into debug report modes", () => { + expect(requestModeFromConfig({ type: "request" })).toBe("request"); + expect(requestModeFromConfig({ type: "createSession" })).toBe( + "create_session", + ); + expect(requestModeFromConfig({ type: "proveSession" })).toBe( + "prove_session", + ); + }); }); diff --git a/js/packages/core/src/lib/debug.ts b/js/packages/core/src/lib/debug.ts index 705a9bb8..6c6feab3 100644 --- a/js/packages/core/src/lib/debug.ts +++ b/js/packages/core/src/lib/debug.ts @@ -4,46 +4,6 @@ import packageJson from "../../package.json"; let _debug = false; -const REDACTED = "[redacted]"; - -const sensitiveKeyPatterns = [ - /(^|_|\b)k($|_|\b)/i, - /key/i, - /secret/i, - /token/i, - /signature/i, - /nonce/i, - /^iv$/i, - /invite.?code/i, - /^code$/i, - /cookie/i, - /authorization/i, - /auth/i, - /proof$/i, - /nullifier/i, -]; - -const sensitiveQueryParams = new Set([ - "k", - "key", - "c", - "code", - "invite_code", - "access_token", - "refresh_token", - "token", - "secret", - "auth", - "authorization", - "signature", - "nonce", - "iv", - "proof", - "proof_response", - "nullifier", - "nullifier_hash", -]); - export type IDKitDebugReportStatus = | "created" | "sent" @@ -71,13 +31,6 @@ export type IDKitDebugRuntimePlatform = | "world_app_android" | "unknown"; -export type IDKitDebugRedactedValue = { - redacted: true; - reason: "sensitive"; - sha256?: string; - length?: number; -}; - export type IDKitDebugReport = { schema_version: 1; created_at: string; @@ -109,7 +62,7 @@ export type IDKitDebugReport = { bridge_url?: string; bridge_host?: string; request_id?: string; - connector_uri_redacted?: string; + connector_uri?: string; connector_uri_sha256?: string; native_command_version?: 1 | 2; native_platform?: "ios" | "android" | "unknown"; @@ -123,10 +76,6 @@ export type IDKitDebugReport = { error_code?: string; error_message?: string; }; - redaction: { - level: "safe"; - excluded: string[]; - }; }; export type IDKitDebugReportHandler = (report: IDKitDebugReport) => void; @@ -166,6 +115,14 @@ export function cloneDebugReport( return cloneValue(report) as IDKitDebugReport; } +export function requestModeFromConfig(config: { + type: "request" | "createSession" | "proveSession"; +}): IDKitDebugRequestMode { + if (config.type === "createSession") return "create_session"; + if (config.type === "proveSession") return "prove_session"; + return "request"; +} + export function createIDKitDebugReport(options: { mode: IDKitDebugRequestMode; transportKind: IDKitDebugTransportKind; @@ -195,6 +152,10 @@ export function createIDKitDebugReport(options: { options.payload === undefined ? undefined : stableStringify(options.payload); + const payload = + options.payload === undefined + ? undefined + : normalizeForJson(options.payload); return { schema_version: 1, @@ -209,13 +170,10 @@ export function createIDKitDebugReport(options: { app_id: options.config.app_id, action: options.config.action, environment: options.config.environment ?? "production", - return_to: redactUrl(options.config.return_to), + return_to: options.config.return_to, allow_legacy_proofs: options.config.allow_legacy_proofs, require_user_presence: options.config.require_user_presence, - payload_before_transport: - options.payload === undefined - ? undefined - : redactDebugValue(options.payload), + payload_before_transport: payload, payload_before_transport_sha256: payloadJson === undefined ? undefined : fingerprintString(payloadJson), payload_before_transport_size_bytes: @@ -227,10 +185,10 @@ export function createIDKitDebugReport(options: { }, transport: { kind: options.transportKind, - bridge_url: redactUrl(options.config.bridge_url), + bridge_url: options.config.bridge_url, bridge_host: getUrlHost(options.config.bridge_url), request_id: options.requestId, - connector_uri_redacted: redactUrl(options.connectorURI), + connector_uri: options.connectorURI, connector_uri_sha256: options.connectorURI ? fingerprintString(options.connectorURI) : undefined, @@ -242,26 +200,6 @@ export function createIDKitDebugReport(options: { updated_at: now, status: "created", }, - redaction: { - level: "safe", - excluded: [ - "encryption keys", - "bridge decrypt key", - "encryption nonce or iv", - "full connector uri", - "invite code", - "encrypted request body", - "encrypted bridge response", - "decrypted proof response", - "proof values", - "nullifier values", - "cookies", - "auth headers", - "access tokens", - "private keys", - "backend signing secrets", - ], - }, }; } @@ -285,7 +223,7 @@ export function updateDebugReport( report.transport.request_id = update.requestId; } if (update.connectorURI) { - report.transport.connector_uri_redacted = redactUrl(update.connectorURI); + report.transport.connector_uri = update.connectorURI; report.transport.connector_uri_sha256 = fingerprintString( update.connectorURI, ); @@ -386,70 +324,6 @@ function getRuntimePlatform(inWorldApp: boolean): IDKitDebugRuntimePlatform { return "unknown"; } -function shouldRedactKey(key: string): boolean { - return sensitiveKeyPatterns.some((pattern) => pattern.test(key)); -} - -function redactDebugValue(value: unknown, key = ""): unknown { - if (shouldRedactKey(key)) { - return makeRedactedValue(value); - } - - if (value instanceof Uint8Array) { - return { - type: "Uint8Array", - length: value.byteLength, - sha256: fingerprintBytes(value), - }; - } - - if (Array.isArray(value)) { - return value.map((item) => redactDebugValue(item)); - } - - if (value && typeof value === "object") { - const object = value as Record; - const entries = Object.entries(object).map(([childKey, childValue]) => [ - childKey, - redactDebugValue(childValue, childKey), - ]); - return Object.fromEntries(entries); - } - - return value; -} - -function makeRedactedValue(value: unknown): IDKitDebugRedactedValue { - const valueString = stableStringify(value); - return { - redacted: true, - reason: "sensitive", - sha256: fingerprintString(valueString), - length: valueString.length, - }; -} - -function redactUrl(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - - try { - const url = new URL(value); - for (const key of Array.from(url.searchParams.keys())) { - if (sensitiveQueryParams.has(key.toLowerCase())) { - url.searchParams.set(key, REDACTED); - } - } - if (url.hash) { - url.hash = REDACTED; - } - return url.toString(); - } catch { - return value; - } -} - function getUrlHost(value: string | undefined): string | undefined { if (!value) { return undefined; diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index dfee6790..9f6b1ed5 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -27,6 +27,7 @@ import { attachDebugReportToError, cloneDebugReport, createIDKitDebugReport, + requestModeFromConfig, updateDebugReport, type IDKitDebugReport, type IDKitDebugRequestMode, @@ -79,7 +80,7 @@ export interface IDKitRequest { pollOnce(): Promise; /** Poll continuously until completion or timeout */ pollUntilCompletion(options?: WaitOptions): Promise; - /** Safe, shareable report for debugging this request when debug mode is enabled */ + /** Debug report for this request when debug mode is enabled. May include sensitive data. */ getDebugReport(): IDKitDebugReport | undefined; } @@ -123,12 +124,6 @@ async function pollUntilCompletionLoop( } } -function requestModeFromConfig(config: BuilderConfig): IDKitDebugRequestMode { - if (config.type === "createSession") return "create_session"; - if (config.type === "proveSession") return "prove_session"; - return "request"; -} - function getWasmDebugPayload(wasmRequest: unknown): unknown { const candidate = wasmRequest as { debugPayload?: () => unknown }; if (typeof candidate.debugPayload !== "function") { @@ -288,7 +283,7 @@ export interface IDKitInviteCodeRequest { pollOnce(): Promise; /** Poll continuously until completion or timeout */ pollUntilCompletion(options?: WaitOptions): Promise; - /** Safe, shareable report for debugging this request when debug mode is enabled */ + /** Debug report for this request when debug mode is enabled. May include sensitive data. */ getDebugReport(): IDKitDebugReport | undefined; } diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index 93a8101c..27ff4605 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -30,9 +30,9 @@ import { createIDKitDebugReport, emitDebugReport, isDebug, + requestModeFromConfig, updateDebugReport, type IDKitDebugReport, - type IDKitDebugRequestMode, } from "../lib/debug"; const MINIAPP_VERIFY_ACTION = "miniapp-verify-action"; @@ -93,12 +93,6 @@ export interface BuilderConfig { environment?: string; } -function requestModeFromConfig(config: BuilderConfig): IDKitDebugRequestMode { - if (config.type === "createSession") return "create_session"; - if (config.type === "proveSession") return "prove_session"; - return "request"; -} - function getNativePlatform(): "ios" | "android" | "unknown" { const w = window as any; if (w.webkit?.messageHandlers?.minikit) return "ios"; diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 8700d915..dea66ded 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -1079,7 +1079,7 @@ impl BridgeConnection { &self.request_id } - /// Returns the pre-encryption request payload for safe SDK debug reporting. + /// Returns the pre-encryption request payload for SDK debug reporting. /// /// This does not include bridge encryption keys, IVs, invite codes, or /// encrypted request/response bodies. diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index 189db1c7..e52dbe3a 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -1227,7 +1227,7 @@ impl IDKitRequest { .map(|s| s.request_id().to_string()) } - /// Returns the pre-encryption payload for safe debug report generation. + /// Returns the pre-encryption payload for debug report generation. /// /// # Errors /// @@ -1339,7 +1339,7 @@ impl IDKitInviteCodeRequest { .map(|s| s.request_id().to_string()) } - /// Returns the pre-encryption payload for safe debug report generation. + /// Returns the pre-encryption payload for debug report generation. /// /// # Errors /// From 86cea46d4fdd90e3bdbdeca7fe52665baa687a7b Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 10:34:32 -0700 Subject: [PATCH 03/12] Remove debug report test additions --- js/packages/core/src/lib/debug.test.ts | 130 ------------------------- rust/core/src/bridge.rs | 50 ---------- 2 files changed, 180 deletions(-) delete mode 100644 js/packages/core/src/lib/debug.test.ts diff --git a/js/packages/core/src/lib/debug.test.ts b/js/packages/core/src/lib/debug.test.ts deleted file mode 100644 index d6662320..00000000 --- a/js/packages/core/src/lib/debug.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - attachDebugReportToError, - createIDKitDebugReport, - setDebug, - setDebugReportHandler, - updateDebugReport, - type IDKitDebugReport, - requestModeFromConfig, -} from "./debug"; - -describe("IDKit debug reports", () => { - afterEach(() => { - setDebug(false); - setDebugReportHandler(null); - vi.restoreAllMocks(); - }); - - it("only creates raw reports in debug mode", () => { - const options = { - mode: "request" as const, - transportKind: "bridge" as const, - config: { - app_id: "app_staging_test", - action: "claim", - bridge_url: - "https://bridge.example/request?access_token=bridge-token&proof=0xproof", - return_to: "https://rp.example/callback?token=return-token", - }, - connectorURI: - "https://world.org/verify?t=wld&i=req-123&k=bridge-key&c=ABC123", - payload: { - app_id: "app_staging_test", - proof_request: { - signature: "0xsig", - nonce: "0xnonce", - requests: [ - { - proof: "0xproof", - nullifier_hash: "0xnullifier", - safe_field: "safe", - }, - ], - }, - }, - }; - - expect(createIDKitDebugReport(options)).toBeUndefined(); - - setDebug(true); - const report = createIDKitDebugReport(options)!; - const serialized = JSON.stringify(report); - const payload = report.request.payload_before_transport as any; - - expect(report.transport.bridge_url).toContain("access_token=bridge-token"); - expect(report.transport.bridge_url).toContain("proof=0xproof"); - expect(report.request.return_to).toContain("token=return-token"); - expect(report.transport.connector_uri).toContain("k=bridge-key"); - expect(report.transport.connector_uri).toContain("c=ABC123"); - expect(payload.proof_request.signature).toBe("0xsig"); - expect(payload.proof_request.nonce).toBe("0xnonce"); - expect(payload.proof_request.requests[0].proof).toBe("0xproof"); - expect(payload.proof_request.requests[0].nullifier_hash).toBe( - "0xnullifier", - ); - expect(payload.proof_request.requests[0].safe_field).toBe("safe"); - expect(report.request.payload_before_transport_sha256).toMatch(/^sha256:/); - - for (const secret of [ - "bridge-token", - "bridge-key", - "ABC123", - "0xsig", - "0xnonce", - "0xproof", - "0xnullifier", - "return-token", - ]) { - expect(serialized).toContain(secret); - } - }); - - it("emits cloned updates and attaches reports without enumerating them on errors", () => { - setDebug(true); - const handler = vi.fn(); - setDebugReportHandler(handler); - - const report = createIDKitDebugReport({ - mode: "request", - transportKind: "bridge", - config: { - app_id: "app_staging_test", - }, - })!; - - updateDebugReport(report, { - status: "error", - connectorURI: "https://world.org/verify?t=wld&i=req-123&k=secret", - errorCode: "connection_failed", - }); - - const emitted = handler.mock.calls[0][0] as IDKitDebugReport; - expect(emitted).not.toBe(report); - expect(emitted.lifecycle.error_code).toBe("connection_failed"); - expect(emitted.transport.connector_uri).toContain("secret"); - - const error = new Error("boom") as Error & { - debugPayload?: unknown; - debugReport?: IDKitDebugReport; - }; - error.debugPayload = { proof_request: { signature: "0xsig" } }; - - attachDebugReportToError(error, report); - - expect(error.debugPayload).toBeUndefined(); - expect(error.debugReport).toEqual(report); - expect(Object.keys(error)).not.toContain("debugReport"); - expect(JSON.stringify(error)).not.toContain("0xsig"); - }); - - it("maps builder config request types into debug report modes", () => { - expect(requestModeFromConfig({ type: "request" })).toBe("request"); - expect(requestModeFromConfig({ type: "createSession" })).toBe( - "create_session", - ); - expect(requestModeFromConfig({ type: "proveSession" })).toBe( - "prove_session", - ); - }); -}); diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index dea66ded..d9eb5e11 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -2440,42 +2440,6 @@ mod tests { assert_eq!(status, Status::Failed(AppError::InvalidRpSignature)); } - #[test] - fn test_bridge_request_failed_carries_debug_payload_snapshot() { - let mut payload = serde_json::json!({ - "app_id": "app_test", - "action": "test-action", - "proof_request": { - "signature": "0xsig", - "nonce": "0xnonce" - } - }); - - let error = bridge_request_failed("network failure", &payload); - payload["app_id"] = serde_json::json!("app_mutated"); - - match error { - Error::BridgeRequestFailed { - message, - debug_payload, - } => { - assert_eq!(message, "network failure"); - assert_eq!(debug_payload["app_id"], "app_test"); - assert_eq!(debug_payload["proof_request"]["signature"], "0xsig"); - assert_eq!(debug_payload["proof_request"]["nonce"], "0xnonce"); - } - other => panic!("expected BridgeRequestFailed, got {other:?}"), - } - } - - #[cfg(feature = "ffi")] - #[test] - fn test_bridge_request_failed_maps_to_connection_failed_app_error() { - let error = bridge_request_failed("network failure", &serde_json::json!({})); - - assert_eq!(to_app_error(&error), AppError::ConnectionFailed); - } - #[test] fn test_bridge_response_error_deserialization() { let json = r#"{"error_code": "user_rejected"}"#; @@ -3369,18 +3333,4 @@ mod tests { assert!(!url.contains("return_to=")); } - - #[test] - fn test_debug_payload_is_separate_from_connect_url_secrets() { - let connection = sample_connection(None); - let connect_url = connection.connect_url(); - let debug_payload = connection.debug_payload(); - - assert!(connect_url.contains("k=")); - assert!(debug_payload.get("k").is_none()); - assert!(debug_payload.get("key").is_none()); - assert!(debug_payload.get("iv").is_none()); - assert!(debug_payload.get("payload").is_none()); - assert!(debug_payload.get("invite_code").is_none()); - } } From 6a0a18ec946cccf93ae5f8d2b3e1b753d1dae351 Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 10:52:54 -0700 Subject: [PATCH 04/12] Fix bridge debug doc markdown --- rust/core/src/bridge.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index d9eb5e11..a82e5216 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -508,7 +508,7 @@ pub struct BridgeConnection { /// Cached signal hashes of the request /// Used to add the `signal_hash` back to the idkit response for convenience cached_signal_hashes: CachedSignalHashes, - /// Request payload built by IDKit before bridge encryption/wrapping. + /// Request payload built by `IDKit` before bridge encryption/wrapping. debug_payload: serde_json::Value, /// Action identifier (only for uniqueness proofs) action: Option, From b7177d3e4b7338199862cff068021d7db5b4f87a Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 11:48:22 -0700 Subject: [PATCH 05/12] Tidy debug report payload handling --- js/packages/core/src/lib/debug.ts | 25 +++++++++++-------------- rust/core/src/bridge.rs | 1 + rust/core/src/error.rs | 7 ++++--- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/js/packages/core/src/lib/debug.ts b/js/packages/core/src/lib/debug.ts index 6c6feab3..8dbe2e62 100644 --- a/js/packages/core/src/lib/debug.ts +++ b/js/packages/core/src/lib/debug.ts @@ -148,14 +148,14 @@ export function createIDKitDebugReport(options: { } const now = new Date().toISOString(); - const payloadJson = - options.payload === undefined - ? undefined - : stableStringify(options.payload); - const payload = + const normalizedPayload = options.payload === undefined ? undefined : normalizeForJson(options.payload); + const payloadJson = + normalizedPayload === undefined + ? undefined + : (JSON.stringify(normalizedPayload) ?? "undefined"); return { schema_version: 1, @@ -173,7 +173,7 @@ export function createIDKitDebugReport(options: { return_to: options.config.return_to, allow_legacy_proofs: options.config.allow_legacy_proofs, require_user_presence: options.config.require_user_presence, - payload_before_transport: payload, + payload_before_transport: normalizedPayload, payload_before_transport_sha256: payloadJson === undefined ? undefined : fingerprintString(payloadJson), payload_before_transport_size_bytes: @@ -344,10 +344,6 @@ function fingerprintBytes(value: Uint8Array): string { return bytesToHex(sha256(value)); } -function stableStringify(value: unknown): string { - return JSON.stringify(normalizeForJson(value)) ?? "undefined"; -} - function normalizeForJson(value: unknown): unknown { if (typeof value === "bigint") { return value.toString(); @@ -367,9 +363,10 @@ function normalizeForJson(value: unknown): unknown { if (value && typeof value === "object") { const object = value as Record; return Object.fromEntries( - Object.keys(object) - .sort() - .map((childKey) => [childKey, normalizeForJson(object[childKey])]), + Object.entries(object).map(([key, child]) => [ + key, + normalizeForJson(child), + ]), ); } @@ -381,5 +378,5 @@ function cloneValue(value: unknown): unknown { return structuredClone(value); } - return JSON.parse(stableStringify(value)); + return JSON.parse(JSON.stringify(normalizeForJson(value)) ?? "undefined"); } diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index a82e5216..49f6de44 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -509,6 +509,7 @@ pub struct BridgeConnection { /// Used to add the `signal_hash` back to the idkit response for convenience cached_signal_hashes: CachedSignalHashes, /// Request payload built by `IDKit` before bridge encryption/wrapping. + /// Stored unconditionally because Rust does not know whether JS debug mode is enabled. debug_payload: serde_json::Value, /// Action identifier (only for uniqueness proofs) action: Option, diff --git a/rust/core/src/error.rs b/rust/core/src/error.rs index 83d0c72d..b185c5a5 100644 --- a/rust/core/src/error.rs +++ b/rust/core/src/error.rs @@ -279,9 +279,10 @@ impl From for IdkitError { details: err.to_string(), }, Error::InvalidProof(message) => Self::InvalidProof { details: message }, - Error::BridgeError(message) | Error::BridgeRequestFailed { message, .. } => { - Self::BridgeError { details: message } - } + Error::BridgeError(message) => Self::BridgeError { details: message }, + // FFI/mobile does not use JS debug reports, so the raw debug payload is intentionally + // dropped when crossing this error boundary. + Error::BridgeRequestFailed { message, .. } => Self::BridgeError { details: message }, Error::AppError(app_err) => Self::AppError { details: app_err.to_string(), }, From 253d109d51571f96f13a6d6940971c6c32f19978 Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 12:13:07 -0700 Subject: [PATCH 06/12] Update debug reports on bridge poll exits --- js/packages/core/src/request.ts | 42 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index 9f6b1ed5..e4ac56ff 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -92,6 +92,7 @@ export interface IDKitRequest { async function pollUntilCompletionLoop( pollOnce: () => Promise, options?: WaitOptions, + onLoopExit?: (result: IDKitCompletionResult) => void, ): Promise { const pollInterval = options?.pollInterval ?? 1000; const timeout = options?.timeout ?? 900_000; // 15 minutes default @@ -99,11 +100,21 @@ async function pollUntilCompletionLoop( while (true) { if (options?.signal?.aborted) { - return { success: false, error: IDKitErrorCodes.Cancelled }; + const result: IDKitCompletionResult = { + success: false, + error: IDKitErrorCodes.Cancelled, + }; + onLoopExit?.(result); + return result; } if (Date.now() - startTime > timeout) { - return { success: false, error: IDKitErrorCodes.Timeout }; + const result: IDKitCompletionResult = { + success: false, + error: IDKitErrorCodes.Timeout, + }; + onLoopExit?.(result); + return result; } const status = await pollOnce(); @@ -181,6 +192,21 @@ function updateDebugReportFromStatus( }); } +function updateDebugReportFromLoopExit( + report: IDKitDebugReport | undefined, + result: IDKitCompletionResult, +): void { + if (result.success === true) { + return; + } + + updateDebugReport(report, { + status: + result.error === IDKitErrorCodes.Cancelled ? "cancelled" : "timeout", + errorCode: result.error, + }); +} + function attachBridgeCreationDebugReport( error: unknown, config: BuilderConfig, @@ -255,7 +281,11 @@ class IDKitRequestImpl implements IDKitRequest { } pollUntilCompletion(options?: WaitOptions): Promise { - return pollUntilCompletionLoop(() => this.pollOnce(), options); + return pollUntilCompletionLoop( + () => this.pollOnce(), + options, + (result) => updateDebugReportFromLoopExit(this.debugReport, result), + ); } getDebugReport(): IDKitDebugReport | undefined { @@ -348,7 +378,11 @@ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { } pollUntilCompletion(options?: WaitOptions): Promise { - return pollUntilCompletionLoop(() => this.pollOnce(), options); + return pollUntilCompletionLoop( + () => this.pollOnce(), + options, + (result) => updateDebugReportFromLoopExit(this.debugReport, result), + ); } getDebugReport(): IDKitDebugReport | undefined { From 6d5439a383bc9a6b95a1bfd8ce460f41c4300ce4 Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 17:54:03 -0700 Subject: [PATCH 07/12] Address bridge debug report review feedback Simplify the debug report and tighten its handling per review: - Capture the bridge response (create + poll) alongside the request via a new debug_response_payload on BridgeConnection, exposed through WASM as debugResponsePayload and surfaced as report.response_payload. - Stop threading the request payload into every bridge error: remove the Error::BridgeRequestFailed variant and the unconditional debugPayload attachment at the WASM boundary. The payload is now obtained at a single isDebug()-gated point (getBridgeDebugPayload), closing the off-state leak. - Flatten IDKitDebugReport to the loose v1 shape (version, package_version, timestamps, transport: bridge|mini_app, request/response payloads, mini_app from window.WorldApp); drop sha256 fingerprinting and the curated native log helpers. - Update the report on timeout/cancelled poll-loop exits for both bridge and invite-code paths. Follow-up hardening: - getDebugReport() short-circuits on !isDebug() so disabling debug stops stored-proof egress. - "failed" poll status only stamps response_received_at when a poll response actually arrived (matches the exception path). - Omit the rebuilt, non-correlatable proof_request.id from creation-error reports while keeping the real id on the live path. - Fix cloneValue JSON fallback to use "null" instead of invalid "undefined". Co-Authored-By: Claude Opus 4.8 --- js/packages/core/src/lib/debug.ts | 335 +++++++--------------- js/packages/core/src/request.ts | 222 +++++++++----- js/packages/core/src/transports/native.ts | 58 +--- rust/core/src/bridge.rs | 137 ++++----- rust/core/src/error.rs | 10 - rust/core/src/wasm_bindings.rs | 117 +++++--- 6 files changed, 415 insertions(+), 464 deletions(-) diff --git a/js/packages/core/src/lib/debug.ts b/js/packages/core/src/lib/debug.ts index 8dbe2e62..a20a12ab 100644 --- a/js/packages/core/src/lib/debug.ts +++ b/js/packages/core/src/lib/debug.ts @@ -1,9 +1,14 @@ -import { sha256 } from "@noble/hashes/sha2"; -import { bytesToHex } from "@noble/hashes/utils"; import packageJson from "../../package.json"; let _debug = false; +let _debugReportHandler: IDKitDebugReportHandler | null = null; +export type IDKitDebugTransport = "bridge" | "mini_app"; +export type IDKitDebugRequestMode = + | "request" + | "invite_code_request" + | "create_session" + | "prove_session"; export type IDKitDebugReportStatus = | "created" | "sent" @@ -14,36 +19,13 @@ export type IDKitDebugReportStatus = | "cancelled" | "timeout"; -export type IDKitDebugTransportKind = - | "bridge" - | "invite_code_bridge" - | "native"; - -export type IDKitDebugRequestMode = - | "request" - | "invite_code_request" - | "create_session" - | "prove_session"; - -export type IDKitDebugRuntimePlatform = - | "web" - | "world_app_ios" - | "world_app_android" - | "unknown"; - export type IDKitDebugReport = { - schema_version: 1; - created_at: string; - sdk: { - package_name: string; - package_version: string; - }; - runtime: { - platform: IDKitDebugRuntimePlatform; - in_world_app: boolean; - user_agent?: string; - }; - request: { + version: 1; + package_version: string; + transport: IDKitDebugTransport; + status: string; + timestamps: Record; + request?: { mode: IDKitDebugRequestMode; app_id?: string; action?: string; @@ -51,37 +33,17 @@ export type IDKitDebugReport = { return_to?: string; allow_legacy_proofs?: boolean; require_user_presence?: boolean; - payload_before_transport?: unknown; - payload_before_transport_sha256?: string; - payload_before_transport_size_bytes?: number; - signal_hashes?: Record; - legacy_signal_hash?: string; - }; - transport: { - kind: IDKitDebugTransportKind; - bridge_url?: string; - bridge_host?: string; - request_id?: string; - connector_uri?: string; - connector_uri_sha256?: string; - native_command_version?: 1 | 2; - native_platform?: "ios" | "android" | "unknown"; - }; - lifecycle: { - created_request_at?: string; - sent_to_transport_at?: string; - response_received_at?: string; - updated_at: string; - status: IDKitDebugReportStatus; - error_code?: string; - error_message?: string; }; + request_id?: string; + connector_uri?: string; + request_payload?: object; + response_payload?: object; + mini_app?: Record; + error?: Record; }; export type IDKitDebugReportHandler = (report: IDKitDebugReport) => void; -let _debugReportHandler: IDKitDebugReportHandler | null = null; - export function isDebug(): boolean { if (_debug) return true; return typeof window !== "undefined" && Boolean((window as any).IDKIT_DEBUG); @@ -98,23 +60,40 @@ export function setDebugReportHandler( } export function emitDebugReport(report: IDKitDebugReport | undefined): void { - if (!report || !isDebug() || !_debugReportHandler) { - return; + if (report && isDebug() && _debugReportHandler) { + _debugReportHandler(cloneDebugReport(report)!); } - - _debugReportHandler(cloneDebugReport(report)!); } export function cloneDebugReport( report: IDKitDebugReport | undefined, ): IDKitDebugReport | undefined { - if (!report) { - return undefined; - } + if (!report) return undefined; return cloneValue(report) as IDKitDebugReport; } +export function attachDebugReportToError( + error: T, + report: IDKitDebugReport | undefined, +): T { + if ( + !report || + (typeof error !== "object" && typeof error !== "function") || + error === null + ) { + return error; + } + + Object.defineProperty(error, "debugReport", { + configurable: true, + enumerable: false, + value: cloneDebugReport(report), + }); + + return error; +} + export function requestModeFromConfig(config: { type: "request" | "createSession" | "proveSession"; }): IDKitDebugRequestMode { @@ -125,7 +104,7 @@ export function requestModeFromConfig(config: { export function createIDKitDebugReport(options: { mode: IDKitDebugRequestMode; - transportKind: IDKitDebugTransportKind; + transport: IDKitDebugTransport; config: { app_id?: string; action?: string; @@ -133,39 +112,21 @@ export function createIDKitDebugReport(options: { return_to?: string; allow_legacy_proofs?: boolean; require_user_presence?: boolean; - bridge_url?: string; }; payload?: unknown; - signalHashes?: Record; - legacySignalHash?: string; requestId?: string; connectorURI?: string; - nativeCommandVersion?: 1 | 2; - nativePlatform?: "ios" | "android" | "unknown"; }): IDKitDebugReport | undefined { - if (!isDebug()) { - return undefined; - } + if (!isDebug()) return undefined; const now = new Date().toISOString(); - const normalizedPayload = - options.payload === undefined - ? undefined - : normalizeForJson(options.payload); - const payloadJson = - normalizedPayload === undefined - ? undefined - : (JSON.stringify(normalizedPayload) ?? "undefined"); - - return { - schema_version: 1, - created_at: now, - sdk: { - package_name: packageJson.name, - package_version: packageJson.version, - }, - runtime: getRuntimeDebugInfo(), - request: { + const report: IDKitDebugReport = { + version: 1, + package_version: packageJson.version, + transport: options.transport, + status: "created", + timestamps: { created_at: now, updated_at: now }, + request: compact({ mode: options.mode, app_id: options.config.app_id, action: options.config.action, @@ -173,34 +134,18 @@ export function createIDKitDebugReport(options: { return_to: options.config.return_to, allow_legacy_proofs: options.config.allow_legacy_proofs, require_user_presence: options.config.require_user_presence, - payload_before_transport: normalizedPayload, - payload_before_transport_sha256: - payloadJson === undefined ? undefined : fingerprintString(payloadJson), - payload_before_transport_size_bytes: - payloadJson === undefined - ? undefined - : new TextEncoder().encode(payloadJson).byteLength, - signal_hashes: options.signalHashes, - legacy_signal_hash: options.legacySignalHash, - }, - transport: { - kind: options.transportKind, - bridge_url: options.config.bridge_url, - bridge_host: getUrlHost(options.config.bridge_url), - request_id: options.requestId, - connector_uri: options.connectorURI, - connector_uri_sha256: options.connectorURI - ? fingerprintString(options.connectorURI) - : undefined, - native_command_version: options.nativeCommandVersion, - native_platform: options.nativePlatform, - }, - lifecycle: { - created_request_at: now, - updated_at: now, - status: "created", - }, + }), }; + + const miniApp = getMiniAppDebugInfo(); + if (miniApp) report.mini_app = miniApp; + + if (options.payload && typeof options.payload === "object") { + report.request_payload = options.payload; + } + if (options.requestId) report.request_id = options.requestId; + if (options.connectorURI) report.connector_uri = options.connectorURI; + return report; } export function updateDebugReport( @@ -211,143 +156,58 @@ export function updateDebugReport( connectorURI?: string; sentToTransportAt?: string; responseReceivedAt?: string; + responsePayload?: unknown; errorCode?: string; errorMessage?: string; }, ): IDKitDebugReport | undefined { - if (!report) { - return undefined; - } - - if (update.requestId) { - report.transport.request_id = update.requestId; - } - if (update.connectorURI) { - report.transport.connector_uri = update.connectorURI; - report.transport.connector_uri_sha256 = fingerprintString( - update.connectorURI, - ); - } - - report.lifecycle.updated_at = new Date().toISOString(); - report.lifecycle.status = update.status ?? report.lifecycle.status; + if (!report) return undefined; + if (update.requestId) report.request_id = update.requestId; + if (update.connectorURI) report.connector_uri = update.connectorURI; + if (update.status) report.status = update.status; if (update.sentToTransportAt) { - report.lifecycle.sent_to_transport_at = update.sentToTransportAt; + report.timestamps.sent_to_transport_at = update.sentToTransportAt; } if (update.responseReceivedAt) { - report.lifecycle.response_received_at = update.responseReceivedAt; + report.timestamps.response_received_at = update.responseReceivedAt; } - if (update.errorCode) { - report.lifecycle.error_code = update.errorCode; + if (update.responsePayload !== undefined) { + if (update.responsePayload && typeof update.responsePayload === "object") { + report.response_payload = update.responsePayload; + } } - if (update.errorMessage) { - report.lifecycle.error_message = update.errorMessage; + if (update.errorCode !== undefined || update.errorMessage !== undefined) { + report.error = { + code: update.errorCode ?? report.error?.code, + message: update.errorMessage ?? report.error?.message, + }; } + report.timestamps.updated_at = new Date().toISOString(); emitDebugReport(report); return report; } -export function attachDebugReportToError( - error: T, - report: IDKitDebugReport | undefined, -): T { - if ( - !report || - (typeof error !== "object" && typeof error !== "function") || - error === null - ) { - return error; - } - - deleteRawDebugPayload(error); - - Object.defineProperty(error, "debugReport", { - configurable: true, - enumerable: false, - value: cloneDebugReport(report), - }); - - return error; -} - -function deleteRawDebugPayload(error: object): void { - if (!("debugPayload" in error)) { - return; - } - - try { - delete (error as { debugPayload?: unknown }).debugPayload; - return; - } catch { - // Fall through to the non-enumerable overwrite below. - } - - try { - Object.defineProperty(error, "debugPayload", { - configurable: true, - enumerable: false, - value: undefined, - }); - } catch { - // Best effort: never let debug-report attachment fail because cleanup did. - } +function compact>(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, child]) => child !== undefined), + ) as T; } -function getRuntimeDebugInfo(): IDKitDebugReport["runtime"] { - const userAgent = - typeof navigator !== "undefined" ? navigator.userAgent : undefined; +function getMiniAppDebugInfo(): Record | undefined { const worldApp = typeof window !== "undefined" ? (window as any).WorldApp : undefined; - const isWorldApp = Boolean(worldApp); - - return { - platform: getRuntimePlatform(isWorldApp), - in_world_app: isWorldApp, - user_agent: userAgent, - }; -} - -function getRuntimePlatform(inWorldApp: boolean): IDKitDebugRuntimePlatform { - if (!inWorldApp) { - return typeof window === "undefined" ? "unknown" : "web"; - } - - const w = window as any; - if (w.webkit?.messageHandlers?.minikit) { - return "world_app_ios"; - } - if (w.Android) { - return "world_app_android"; - } - return "unknown"; -} + if (!worldApp || typeof worldApp !== "object") return undefined; -function getUrlHost(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - - try { - return new URL(value).host; - } catch { - return undefined; - } -} - -function fingerprintString(value: string): string { - return `sha256:${fingerprintBytes(new TextEncoder().encode(value))}`; -} - -function fingerprintBytes(value: Uint8Array): string { - return bytesToHex(sha256(value)); + return compact({ + world_app_version: worldApp.world_app_version, + platform: worldApp.device_os, + }); } function normalizeForJson(value: unknown): unknown { - if (typeof value === "bigint") { - return value.toString(); - } + if (typeof value === "bigint") return value.toString(); if (value instanceof Uint8Array) { return { @@ -361,9 +221,8 @@ function normalizeForJson(value: unknown): unknown { } if (value && typeof value === "object") { - const object = value as Record; return Object.fromEntries( - Object.entries(object).map(([key, child]) => [ + Object.entries(value as Record).map(([key, child]) => [ key, normalizeForJson(child), ]), @@ -374,9 +233,13 @@ function normalizeForJson(value: unknown): unknown { } function cloneValue(value: unknown): unknown { - if (typeof structuredClone === "function") { - return structuredClone(value); + try { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + } catch { + // Fall through to JSON clone below. } - return JSON.parse(JSON.stringify(normalizeForJson(value)) ?? "undefined"); + return JSON.parse(JSON.stringify(normalizeForJson(value)) ?? "null"); } diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index e4ac56ff..fe24b75b 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -27,11 +27,11 @@ import { attachDebugReportToError, cloneDebugReport, createIDKitDebugReport, + isDebug, requestModeFromConfig, updateDebugReport, type IDKitDebugReport, type IDKitDebugRequestMode, - type IDKitDebugTransportKind, } from "./lib/debug"; /** Options for pollUntilCompletion() */ @@ -135,28 +135,50 @@ async function pollUntilCompletionLoop( } } -function getWasmDebugPayload(wasmRequest: unknown): unknown { - const candidate = wasmRequest as { debugPayload?: () => unknown }; - if (typeof candidate.debugPayload !== "function") { +function callDebugMethod( + target: unknown, + method: string, + input?: unknown, +): unknown { + const fn = (target as Record)[method]; + if (typeof fn !== "function") { return undefined; } try { - return candidate.debugPayload(); + return input === undefined ? fn.call(target) : fn.call(target, input); } catch { return undefined; } } -function getErrorDebugPayload(error: unknown): unknown { - if ( - (typeof error !== "object" && typeof error !== "function") || - error === null - ) { - return undefined; - } +function getWasmDebugPayload(wasmRequest: unknown): unknown { + return callDebugMethod(wasmRequest, "debugPayload"); +} - return (error as { debugPayload?: unknown }).debugPayload; +function getWasmDebugResponsePayload(wasmRequest: unknown): unknown { + return callDebugMethod(wasmRequest, "debugResponsePayload"); +} + +function getBridgeDebugPayload( + wasmBuilder: WasmModule.IDKitBuilder, + method: "bridgeDebugPayload" | "bridgeDebugPayloadFromPreset", + input: unknown, +): unknown { + if (!isDebug()) return undefined; + const payload = callDebugMethod(wasmBuilder, method, input); + // This payload is rebuilt for the bridge-creation failure path, where no + // request object exists yet. Its proof_request.id is a freshly generated + // UUID that does not match the id of any request actually sent to the bridge + // (and is end-to-end encrypted, so the bridge never sees it anyway). Drop it + // here so the creation-error report doesn't surface a fabricated, non- + // correlatable id. The live path (getDebugReport) keeps the real id. + const proofRequest = (payload as { proof_request?: { id?: unknown } }) + ?.proof_request; + if (proofRequest && typeof proofRequest === "object") { + delete proofRequest.id; + } + return payload; } function getErrorMessage(error: unknown): string { @@ -169,19 +191,31 @@ function getErrorMessage(error: unknown): string { function updateDebugReportFromStatus( report: IDKitDebugReport | undefined, status: Status, + responsePayload?: unknown, ): void { if (status.type === "confirmed") { updateDebugReport(report, { status: "success", responseReceivedAt: new Date().toISOString(), + ...(responsePayload !== undefined ? { responsePayload } : {}), }); return; } if (status.type === "failed") { + // A "failed" status can originate from a non-2xx bridge response before any + // authenticator response was decrypted, so only record response_received_at + // when a poll response actually arrived (matches the exception path below). + const hasResponsePayload = + typeof responsePayload === "object" && + responsePayload !== null && + Object.prototype.hasOwnProperty.call(responsePayload, "poll"); updateDebugReport(report, { status: "error", - responseReceivedAt: new Date().toISOString(), + ...(hasResponsePayload + ? { responseReceivedAt: new Date().toISOString() } + : {}), + ...(responsePayload !== undefined ? { responsePayload } : {}), errorCode: status.error, }); return; @@ -207,17 +241,17 @@ function updateDebugReportFromLoopExit( }); } -function attachBridgeCreationDebugReport( +function emitBridgeCreationDebugReport( error: unknown, config: BuilderConfig, mode: IDKitDebugRequestMode, - transportKind: IDKitDebugTransportKind, + payload?: unknown, ): never { const report = createIDKitDebugReport({ mode, - transportKind, + transport: "bridge", config, - payload: getErrorDebugPayload(error), + payload, }); updateDebugReport(report, { status: "error", @@ -226,6 +260,59 @@ function attachBridgeCreationDebugReport( throw attachDebugReportToError(error, report); } +function createSentBridgeDebugReport( + wasmRequest: unknown, + config: BuilderConfig, + mode: IDKitDebugRequestMode, + requestId: string, + connectorURI: string, +): IDKitDebugReport | undefined { + const report = createIDKitDebugReport({ + mode, + transport: "bridge", + config, + payload: getWasmDebugPayload(wasmRequest), + requestId, + connectorURI, + }); + updateDebugReport(report, { + status: "sent", + sentToTransportAt: new Date().toISOString(), + responsePayload: getWasmDebugResponsePayload(wasmRequest), + }); + return report; +} + +async function pollBridgeStatus( + wasmRequest: { pollForStatus: () => Promise }, + report: IDKitDebugReport | undefined, +): Promise { + try { + const status = (await wasmRequest.pollForStatus()) as Status; + updateDebugReportFromStatus( + report, + status, + getWasmDebugResponsePayload(wasmRequest), + ); + return status; + } catch (error) { + const responsePayload = getWasmDebugResponsePayload(wasmRequest); + const hasResponsePayload = + typeof responsePayload === "object" && + responsePayload !== null && + Object.prototype.hasOwnProperty.call(responsePayload, "poll"); + updateDebugReport(report, { + status: "error", + ...(responsePayload !== undefined ? { responsePayload } : {}), + ...(hasResponsePayload + ? { responseReceivedAt: new Date().toISOString() } + : {}), + errorMessage: getErrorMessage(error), + }); + throw attachDebugReportToError(error, report); + } +} + /** * Internal request implementation (bridge/WASM path) */ @@ -239,23 +326,17 @@ class IDKitRequestImpl implements IDKitRequest { wasmRequest: WasmModule.IDKitRequest, config: BuilderConfig, mode: IDKitDebugRequestMode, - transportKind: IDKitDebugTransportKind, ) { this.wasmRequest = wasmRequest; this._connectorURI = wasmRequest.connectUrl(); this._requestId = wasmRequest.requestId(); - this.debugReport = createIDKitDebugReport({ - mode, - transportKind, + this.debugReport = createSentBridgeDebugReport( + wasmRequest, config, - payload: getWasmDebugPayload(wasmRequest), - requestId: this._requestId, - connectorURI: this._connectorURI, - }); - updateDebugReport(this.debugReport, { - status: "sent", - sentToTransportAt: new Date().toISOString(), - }); + mode, + this._requestId, + this._connectorURI, + ); } get connectorURI(): string { @@ -267,17 +348,7 @@ class IDKitRequestImpl implements IDKitRequest { } async pollOnce(): Promise { - try { - const status = (await this.wasmRequest.pollForStatus()) as Status; - updateDebugReportFromStatus(this.debugReport, status); - return status; - } catch (error) { - updateDebugReport(this.debugReport, { - status: "error", - errorMessage: getErrorMessage(error), - }); - throw attachDebugReportToError(error, this.debugReport); - } + return pollBridgeStatus(this.wasmRequest, this.debugReport); } pollUntilCompletion(options?: WaitOptions): Promise { @@ -289,6 +360,7 @@ class IDKitRequestImpl implements IDKitRequest { } getDebugReport(): IDKitDebugReport | undefined { + if (!isDebug()) return undefined; return cloneDebugReport(this.debugReport); } } @@ -337,18 +409,13 @@ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { this._connectorURI = wasmRequest.connectUrl(); this._expiresAt = wasmRequest.expiresAt(); this._requestId = wasmRequest.requestId(); - this.debugReport = createIDKitDebugReport({ - mode: "invite_code_request", - transportKind: "invite_code_bridge", + this.debugReport = createSentBridgeDebugReport( + wasmRequest, config, - payload: getWasmDebugPayload(wasmRequest), - requestId: this._requestId, - connectorURI: this._connectorURI, - }); - updateDebugReport(this.debugReport, { - status: "sent", - sentToTransportAt: new Date().toISOString(), - }); + "invite_code_request", + this._requestId, + this._connectorURI, + ); } get connectorURI(): string { @@ -364,17 +431,7 @@ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { } async pollOnce(): Promise { - try { - const status = (await this.wasmRequest.pollForStatus()) as Status; - updateDebugReportFromStatus(this.debugReport, status); - return status; - } catch (error) { - updateDebugReport(this.debugReport, { - status: "error", - errorMessage: getErrorMessage(error), - }); - throw attachDebugReportToError(error, this.debugReport); - } + return pollBridgeStatus(this.wasmRequest, this.debugReport); } pollUntilCompletion(options?: WaitOptions): Promise { @@ -386,6 +443,7 @@ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { } getDebugReport(): IDKitDebugReport | undefined { + if (!isDebug()) return undefined; return cloneDebugReport(this.debugReport); } } @@ -803,6 +861,11 @@ class IDKitBuilder { // Bridge path — WASM const wasmBuilder = createWasmBuilderFromConfig(this.config); + const debugPayload = getBridgeDebugPayload( + wasmBuilder, + "bridgeDebugPayload", + constraints, + ); try { const wasmRequest = (await wasmBuilder.constraints( constraints, @@ -811,14 +874,13 @@ class IDKitBuilder { wasmRequest, this.config, requestModeFromConfig(this.config), - "bridge", ); } catch (error) { - attachBridgeCreationDebugReport( + emitBridgeCreationDebugReport( error, this.config, requestModeFromConfig(this.config), - "bridge", + debugPayload, ); } } @@ -896,6 +958,11 @@ class IDKitBuilder { // Bridge path — WASM const wasmBuilder = createWasmBuilderFromConfig(this.config); + const debugPayload = getBridgeDebugPayload( + wasmBuilder, + "bridgeDebugPayloadFromPreset", + preset, + ); try { const wasmRequest = (await wasmBuilder.preset( preset, @@ -904,14 +971,13 @@ class IDKitBuilder { wasmRequest, this.config, requestModeFromConfig(this.config), - "bridge", ); } catch (error) { - attachBridgeCreationDebugReport( + emitBridgeCreationDebugReport( error, this.config, requestModeFromConfig(this.config), - "bridge", + debugPayload, ); } } @@ -951,17 +1017,22 @@ class IDKitInviteCodeBuilder { await initIDKit(); const wasmBuilder = createWasmBuilderFromConfig(this.config); + const debugPayload = getBridgeDebugPayload( + wasmBuilder, + "bridgeDebugPayload", + constraints, + ); try { const wasmRequest = (await wasmBuilder.constraintsWithInviteCode( constraints, )) as unknown as WasmModule.IDKitInviteCodeRequest; return new IDKitInviteCodeRequestImpl(wasmRequest, this.config); } catch (error) { - attachBridgeCreationDebugReport( + emitBridgeCreationDebugReport( error, this.config, "invite_code_request", - "invite_code_bridge", + debugPayload, ); } } @@ -985,17 +1056,22 @@ class IDKitInviteCodeBuilder { await initIDKit(); const wasmBuilder = createWasmBuilderFromConfig(this.config); + const debugPayload = getBridgeDebugPayload( + wasmBuilder, + "bridgeDebugPayloadFromPreset", + preset, + ); try { const wasmRequest = (await wasmBuilder.presetWithInviteCode( preset, )) as unknown as WasmModule.IDKitInviteCodeRequest; return new IDKitInviteCodeRequestImpl(wasmRequest, this.config); } catch (error) { - attachBridgeCreationDebugReport( + emitBridgeCreationDebugReport( error, this.config, "invite_code_request", - "invite_code_bridge", + debugPayload, ); } } diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index 27ff4605..c11daf88 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -93,43 +93,6 @@ export interface BuilderConfig { environment?: string; } -function getNativePlatform(): "ios" | "android" | "unknown" { - const w = window as any; - if (w.webkit?.messageHandlers?.minikit) return "ios"; - if (w.Android) return "android"; - return "unknown"; -} - -function nativeSendLogDetails( - report: IDKitDebugReport | undefined, - requestId: string, - version: 1 | 2, -) { - return { - command: "verify", - version, - requestId, - payload_sha256: report?.request.payload_before_transport_sha256, - payload_size_bytes: report?.request.payload_before_transport_size_bytes, - }; -} - -function nativeResponseLogDetails(payload: unknown) { - const p = payload as Record; - return { - status: p?.status, - error_code: p?.error_code, - protocol_version: p?.protocol_version, - has_proof_response: p?.proof_response != null, - proof_response_count: Array.isArray(p?.proof_response?.responses) - ? p.proof_response.responses.length - : undefined, - verification_count: Array.isArray(p?.verifications) - ? p.verifications.length - : undefined, - }; -} - // ───────────────────────────────────────────────────────────────────────────── // Native IDKit request // ───────────────────────────────────────────────────────────────────────────── @@ -169,13 +132,9 @@ export function createNativeRequest( } const debugReport = createIDKitDebugReport({ mode: requestModeFromConfig(config), - transportKind: "native", + transport: "mini_app", config, payload: wasmPayload, - signalHashes, - legacySignalHash, - nativeCommandVersion: version, - nativePlatform: getNativePlatform(), }); const request = new NativeIDKitRequest( wasmPayload, @@ -223,13 +182,15 @@ class NativeIDKitRequest implements IDKitRequest { updateDebugReport(this.debugReport, { responseReceivedAt: new Date().toISOString(), + responsePayload, }); if (isDebug()) - console.debug( - "[IDKit] Native: received response", - nativeResponseLogDetails(responsePayload), - ); + console.debug("[IDKit] Native: received response", { + status: responsePayload?.status, + error_code: responsePayload?.error_code, + protocol_version: responsePayload?.protocol_version, + }); if (responsePayload?.status === "error") { if (isDebug()) @@ -320,7 +281,7 @@ class NativeIDKitRequest implements IDKitRequest { if (isDebug()) console.debug( `[IDKit] Native: sending verify command (version=${version}, platform=ios)`, - nativeSendLogDetails(this.debugReport, this.requestId, version), + { command: "verify", version, requestId: this.requestId }, ); w.webkit.messageHandlers.minikit.postMessage(sendPayload); updateDebugReport(this.debugReport, { @@ -331,7 +292,7 @@ class NativeIDKitRequest implements IDKitRequest { if (isDebug()) console.debug( `[IDKit] Native: sending verify command (version=${version}, platform=android)`, - nativeSendLogDetails(this.debugReport, this.requestId, version), + { command: "verify", version, requestId: this.requestId }, ); w.Android.postMessage(JSON.stringify(sendPayload)); updateDebugReport(this.debugReport, { @@ -387,6 +348,7 @@ class NativeIDKitRequest implements IDKitRequest { } getDebugReport(): IDKitDebugReport | undefined { + if (!isDebug()) return undefined; return cloneDebugReport(this.debugReport); } diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 49f6de44..a45a79e4 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -23,6 +23,7 @@ use crate::crypto::CryptoKey; use std::str::FromStr; #[cfg(feature = "ffi")] use std::sync::Arc; +use std::sync::Mutex; // ───────────────────────────────────────────────────────────────────────────── // Environment @@ -511,6 +512,7 @@ pub struct BridgeConnection { /// Request payload built by `IDKit` before bridge encryption/wrapping. /// Stored unconditionally because Rust does not know whether JS debug mode is enabled. debug_payload: serde_json::Value, + debug_response_payload: Mutex>, /// Action identifier (only for uniqueness proofs) action: Option, /// Action description (only if provided in input) @@ -735,42 +737,37 @@ impl BridgeConnection { let client = reqwest::Client::builder() .user_agent(format!("idkit-core/{}", env!("CARGO_PKG_VERSION"))) .build() - .map_err(|e| { - bridge_request_failed(format!("Bridge client build failed: {e}"), &payload_value) - })?; + .map_err(|e| bridge_request_failed(format!("Bridge client build failed: {e}")))?; let response = client .post(bridge_url.join("/request")?) .json(&body) .send() .await - .map_err(|e| { - bridge_request_failed(format!("Bridge request failed: {e}"), &payload_value) - })?; + .map_err(|e| bridge_request_failed(format!("Bridge request failed: {e}")))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(bridge_request_failed( - format!( - "Bridge request failed with status {}: {}", - status, - if body.is_empty() { - "no error details" - } else { - &body - } - ), - &payload_value, - )); + return Err(bridge_request_failed(format!( + "Bridge request failed with status {}: {}", + status, + if body.is_empty() { + "no error details" + } else { + &body + } + ))); } - let create_response: BridgeCreateResponse = response.json().await.map_err(|e| { - bridge_request_failed( - format!("Failed to parse bridge response: {e}"), - &payload_value, - ) - })?; + let create_response_payload: serde_json::Value = response + .json() + .await + .map_err(|e| bridge_request_failed(format!("Failed to parse bridge response: {e}")))?; + let create_response: BridgeCreateResponse = + serde_json::from_value(create_response_payload.clone()).map_err(|e| { + bridge_request_failed(format!("Failed to parse bridge response: {e}")) + })?; // Extract action from kind for result let action = match ¶ms.kind { @@ -790,6 +787,9 @@ impl BridgeConnection { client, cached_signal_hashes, debug_payload: payload_value, + debug_response_payload: Mutex::new(Some(serde_json::json!({ + "create": create_response_payload + }))), action, action_description: params.action_description, nonce: params.rp_context.nonce.clone(), @@ -936,7 +936,9 @@ impl BridgeConnection { #[cfg(not(feature = "native-crypto"))] let plaintext = decrypt(&self.key_bytes, &iv, &ciphertext)?; - let bridge_response: BridgeResponse = serde_json::from_slice(&plaintext)?; + let response_payload: serde_json::Value = serde_json::from_slice(&plaintext)?; + self.set_debug_poll_response_payload(response_payload.clone()); + let bridge_response: BridgeResponse = serde_json::from_value(response_payload)?; match bridge_response { BridgeResponse::Error { error_code } => Ok(Status::Failed(error_code)), @@ -1089,6 +1091,25 @@ impl BridgeConnection { &self.debug_payload } + #[must_use] + pub fn debug_response_payload(&self) -> Option { + self.debug_response_payload + .lock() + .ok() + .and_then(|payload| payload.clone()) + } + + fn set_debug_poll_response_payload(&self, payload: serde_json::Value) { + if let Ok(mut debug_response_payload) = self.debug_response_payload.lock() { + let mut response_object = debug_response_payload + .take() + .and_then(|payload| payload.as_object().cloned()) + .unwrap_or_default(); + response_object.insert("poll".to_string(), payload); + *debug_response_payload = Some(serde_json::Value::Object(response_object)); + } + } + /// Unix-seconds expiry of the unredeemed code, if this connection was /// created in invite-code mode. #[must_use] @@ -1104,11 +1125,8 @@ enum CreateCodeError { Other(Error), } -fn bridge_request_failed(message: impl Into, debug_payload: &serde_json::Value) -> Error { - Error::BridgeRequestFailed { - message: message.into(), - debug_payload: Box::new(debug_payload.clone()), - } +fn bridge_request_failed(message: impl Into) -> Error { + Error::BridgeError(message.into()) } impl From for CreateCodeError { @@ -1176,18 +1194,14 @@ async fn try_create_invite_code_request( let client = reqwest::Client::builder() .user_agent(format!("idkit-core/{}", env!("CARGO_PKG_VERSION"))) .build() - .map_err(|e| { - bridge_request_failed(format!("Bridge client build failed: {e}"), &payload_value) - })?; + .map_err(|e| bridge_request_failed(format!("Bridge client build failed: {e}")))?; let response = client .post(bridge_url.join("/request")?) .json(&body) .send() .await - .map_err(|e| { - bridge_request_failed(format!("Bridge request failed: {e}"), &payload_value) - })?; + .map_err(|e| bridge_request_failed(format!("Bridge request failed: {e}")))?; if response.status() == reqwest::StatusCode::CONFLICT { return Err(CreateCodeError::Conflict); @@ -1195,17 +1209,14 @@ async fn try_create_invite_code_request( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(bridge_request_failed( - format!( - "Bridge /request (code) failed with status {status}: {}", - if body.is_empty() { - "no error details" - } else { - &body - } - ), - &payload_value, - ) + return Err(bridge_request_failed(format!( + "Bridge /request (code) failed with status {status}: {}", + if body.is_empty() { + "no error details" + } else { + &body + } + )) .into()); } @@ -1216,20 +1227,17 @@ async fn try_create_invite_code_request( // the World App side and we'd fail in a confusing way much later in the // poll loop. Catching the mismatch here surfaces the contract violation // at creation time. - let echoed: BridgeCreateResponse = response.json().await.map_err(|e| { - bridge_request_failed( - format!("Failed to parse bridge response: {e}"), - &payload_value, - ) - })?; + let echoed_payload: serde_json::Value = response + .json() + .await + .map_err(|e| bridge_request_failed(format!("Failed to parse bridge response: {e}")))?; + let echoed: BridgeCreateResponse = serde_json::from_value(echoed_payload.clone()) + .map_err(|e| bridge_request_failed(format!("Failed to parse bridge response: {e}")))?; if echoed.request_id != request_id { - return Err(bridge_request_failed( - format!( - "Bridge echoed mismatched request_id (sent {request_id}, got {})", - echoed.request_id - ), - &payload_value, - ) + return Err(bridge_request_failed(format!( + "Bridge echoed mismatched request_id (sent {request_id}, got {})", + echoed.request_id + )) .into()); } @@ -1257,6 +1265,7 @@ async fn try_create_invite_code_request( client, cached_signal_hashes, debug_payload: payload_value, + debug_response_payload: Mutex::new(Some(serde_json::json!({ "create": echoed_payload }))), action, action_description: params.action_description.clone(), nonce: params.rp_context.nonce.clone(), @@ -1774,7 +1783,7 @@ impl From for StatusWrapper { fn to_app_error(error: &Error) -> AppError { match error { Error::InvalidConfiguration(_) => AppError::MalformedRequest, - Error::BridgeError(_) | Error::BridgeRequestFailed { .. } => AppError::ConnectionFailed, + Error::BridgeError(_) => AppError::ConnectionFailed, Error::Json(_) => AppError::UnexpectedResponse, Error::Crypto(_) => AppError::UnexpectedResponse, Error::Base64(_) => AppError::UnexpectedResponse, @@ -1795,10 +1804,7 @@ fn to_app_error(error: &Error) -> AppError { #[cfg(feature = "ffi")] fn is_networking_error(error: &Error) -> bool { match error { - Error::Timeout - | Error::ConnectionFailed - | Error::BridgeError(_) - | Error::BridgeRequestFailed { .. } => true, + Error::Timeout | Error::ConnectionFailed | Error::BridgeError(_) => true, #[cfg(any(feature = "bridge", feature = "bridge-wasm"))] Error::Http(err) => err.is_timeout() || err.is_request(), _ => false, @@ -3307,6 +3313,7 @@ mod tests { legacy_signal_hash: String::new(), }, debug_payload: serde_json::json!({"app_id": "app_test"}), + debug_response_payload: Mutex::new(None), action: Some("test-action".to_string()), action_description: None, nonce: "0x01".to_string(), diff --git a/rust/core/src/error.rs b/rust/core/src/error.rs index b185c5a5..76f82dcf 100644 --- a/rust/core/src/error.rs +++ b/rust/core/src/error.rs @@ -16,13 +16,6 @@ pub enum Error { #[error("Bridge error: {0}")] BridgeError(String), - /// Bridge request creation failed after the pre-encryption payload was built. - #[error("Bridge error: {message}")] - BridgeRequestFailed { - message: String, - debug_payload: Box, - }, - /// JSON serialization/deserialization error #[error("JSON error: {0}")] Json(#[from] serde_json::Error), @@ -280,9 +273,6 @@ impl From for IdkitError { }, Error::InvalidProof(message) => Self::InvalidProof { details: message }, Error::BridgeError(message) => Self::BridgeError { details: message }, - // FFI/mobile does not use JS debug reports, so the raw debug payload is intentionally - // dropped when crossing this error boundary. - Error::BridgeRequestFailed { message, .. } => Self::BridgeError { details: message }, Error::AppError(app_err) => Self::AppError { details: app_err.to_string(), }, diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index e52dbe3a..5b641c5e 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -31,18 +31,6 @@ pub fn init_wasm() { fn bridge_create_error_to_js(error: crate::Error) -> JsValue { let js_error = js_sys::Error::new(&format!("Failed: {error}")); - - if let crate::Error::BridgeRequestFailed { debug_payload, .. } = &error { - let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); - if let Ok(value) = debug_payload.serialize(&serializer) { - let _ = js_sys::Reflect::set( - js_error.as_ref(), - &JsValue::from_str("debugPayload"), - &value, - ); - } - } - js_error.into() } @@ -766,6 +754,42 @@ fn validate_v1_preset_support(preset: &Preset) -> Result<(), &'static str> { Ok(()) } +fn json_to_js(payload: T) -> Result { + let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + payload + .serialize(&serializer) + .map_err(|e| JsValue::from_str(&format!("Serialization failed: {e}"))) +} + +fn bridge_debug_payload_to_js( + params: &crate::bridge::BridgeConnectionParams, +) -> Result { + crate::bridge::build_request_payload(params, false) + .map_err(|e| JsValue::from_str(&format!("Failed to build bridge debug payload: {e}"))) + .and_then(json_to_js) +} + +fn debug_payload_to_js( + inner: &Rc>>, +) -> Result { + inner + .borrow() + .as_ref() + .ok_or_else(|| JsValue::from_str("Request closed")) + .and_then(|s| json_to_js(s.debug_payload())) +} + +fn debug_response_payload_to_js( + inner: &Rc>>, +) -> Result { + inner + .borrow() + .as_ref() + .ok_or_else(|| JsValue::from_str("Request closed"))? + .debug_response_payload() + .map_or(Ok(JsValue::UNDEFINED), json_to_js) +} + /// Unified builder for creating `IDKit` requests and sessions (WASM) #[wasm_bindgen(js_name = IDKitBuilder)] pub struct IDKitBuilderWasm { @@ -862,6 +886,35 @@ impl IDKitBuilderWasm { } } + /// Returns the bridge debug request payload for constraints. + /// + /// # Errors + /// Returns an error if input parsing or payload construction fails. + #[wasm_bindgen(js_name = bridgeDebugPayload)] + pub fn bridge_debug_payload(&self, constraints_json: JsValue) -> Result { + let constraints: ConstraintNode = serde_wasm_bindgen::from_value(constraints_json) + .map_err(|e| JsValue::from_str(&format!("Invalid constraints: {e}")))?; + + let params = self.config.to_params(Some(constraints))?; + bridge_debug_payload_to_js(¶ms) + } + + /// Returns the bridge debug request payload for a preset. + /// + /// # Errors + /// Returns an error if input parsing or payload construction fails. + #[wasm_bindgen(js_name = bridgeDebugPayloadFromPreset)] + pub fn bridge_debug_payload_from_preset( + &self, + preset_json: JsValue, + ) -> Result { + let preset: Preset = serde_wasm_bindgen::from_value(preset_json) + .map_err(|e| JsValue::from_str(&format!("Invalid preset: {e}")))?; + + let params = self.config.to_params_from_preset(preset)?; + bridge_debug_payload_to_js(¶ms) + } + /// Builds the native payload for constraints (synchronous, no bridge connection). /// /// Used by the native transport to get the same payload format as the bridge @@ -1234,16 +1287,16 @@ impl IDKitRequest { /// Returns an error if the request has been closed or serialization fails. #[wasm_bindgen(js_name = debugPayload)] pub fn debug_payload(&self) -> Result { - let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); - self.inner - .borrow() - .as_ref() - .ok_or_else(|| JsValue::from_str("Request closed")) - .and_then(|s| { - s.debug_payload() - .serialize(&serializer) - .map_err(|e| JsValue::from_str(&format!("Serialization failed: {e}"))) - }) + debug_payload_to_js(&self.inner) + } + + /// Returns captured bridge response payloads. + /// + /// # Errors + /// Returns an error if the request has been closed. + #[wasm_bindgen(js_name = debugResponsePayload)] + pub fn debug_response_payload(&self) -> Result { + debug_response_payload_to_js(&self.inner) } /// Polls the bridge for the current status (non-blocking) @@ -1346,16 +1399,16 @@ impl IDKitInviteCodeRequest { /// Returns an error if the request has been closed or serialization fails. #[wasm_bindgen(js_name = debugPayload)] pub fn debug_payload(&self) -> Result { - let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); - self.inner - .borrow() - .as_ref() - .ok_or_else(|| JsValue::from_str("Request closed")) - .and_then(|s| { - s.debug_payload() - .serialize(&serializer) - .map_err(|e| JsValue::from_str(&format!("Serialization failed: {e}"))) - }) + debug_payload_to_js(&self.inner) + } + + /// Returns captured bridge response payloads. + /// + /// # Errors + /// Returns an error if the request has been closed. + #[wasm_bindgen(js_name = debugResponsePayload)] + pub fn debug_response_payload(&self) -> Result { + debug_response_payload_to_js(&self.inner) } /// Polls the bridge for the current status (non-blocking). From 94fad3bd00db9576d43d68cc14bcc9a9fdd7da3b Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 18:38:52 -0700 Subject: [PATCH 08/12] Trim debug report request metadata and harden bridge debug paths - Drop the curated `request` summary object (mode/app_id/action/environment/ return_to/flags), the IDKitDebugRequestMode type, and requestModeFromConfig; every field is already carried by request_payload. - Guard getWasmDebugPayload/getWasmDebugResponsePayload behind isDebug() so debugPayload()/debugResponsePayload() no longer run on the default-off path (previously fired once per poll for all users). - Capture the HTTP status on a failed bridge poll instead of collapsing every non-2xx into connection_failed; surfaces at response_payload.poll.http_status. Co-Authored-By: Claude Opus 4.8 --- js/packages/core/src/lib/debug.ts | 40 ------------- js/packages/core/src/request.ts | 71 ++++------------------- js/packages/core/src/transports/native.ts | 3 - rust/core/src/bridge.rs | 3 + 4 files changed, 15 insertions(+), 102 deletions(-) diff --git a/js/packages/core/src/lib/debug.ts b/js/packages/core/src/lib/debug.ts index a20a12ab..a5c6301a 100644 --- a/js/packages/core/src/lib/debug.ts +++ b/js/packages/core/src/lib/debug.ts @@ -4,11 +4,6 @@ let _debug = false; let _debugReportHandler: IDKitDebugReportHandler | null = null; export type IDKitDebugTransport = "bridge" | "mini_app"; -export type IDKitDebugRequestMode = - | "request" - | "invite_code_request" - | "create_session" - | "prove_session"; export type IDKitDebugReportStatus = | "created" | "sent" @@ -25,15 +20,6 @@ export type IDKitDebugReport = { transport: IDKitDebugTransport; status: string; timestamps: Record; - request?: { - mode: IDKitDebugRequestMode; - app_id?: string; - action?: string; - environment?: string; - return_to?: string; - allow_legacy_proofs?: boolean; - require_user_presence?: boolean; - }; request_id?: string; connector_uri?: string; request_payload?: object; @@ -94,25 +80,8 @@ export function attachDebugReportToError( return error; } -export function requestModeFromConfig(config: { - type: "request" | "createSession" | "proveSession"; -}): IDKitDebugRequestMode { - if (config.type === "createSession") return "create_session"; - if (config.type === "proveSession") return "prove_session"; - return "request"; -} - export function createIDKitDebugReport(options: { - mode: IDKitDebugRequestMode; transport: IDKitDebugTransport; - config: { - app_id?: string; - action?: string; - environment?: string; - return_to?: string; - allow_legacy_proofs?: boolean; - require_user_presence?: boolean; - }; payload?: unknown; requestId?: string; connectorURI?: string; @@ -126,15 +95,6 @@ export function createIDKitDebugReport(options: { transport: options.transport, status: "created", timestamps: { created_at: now, updated_at: now }, - request: compact({ - mode: options.mode, - app_id: options.config.app_id, - action: options.config.action, - environment: options.config.environment ?? "production", - return_to: options.config.return_to, - allow_legacy_proofs: options.config.allow_legacy_proofs, - require_user_presence: options.config.require_user_presence, - }), }; const miniApp = getMiniAppDebugInfo(); diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index fe24b75b..d3223918 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -28,10 +28,8 @@ import { cloneDebugReport, createIDKitDebugReport, isDebug, - requestModeFromConfig, updateDebugReport, type IDKitDebugReport, - type IDKitDebugRequestMode, } from "./lib/debug"; /** Options for pollUntilCompletion() */ @@ -153,10 +151,12 @@ function callDebugMethod( } function getWasmDebugPayload(wasmRequest: unknown): unknown { + if (!isDebug()) return undefined; return callDebugMethod(wasmRequest, "debugPayload"); } function getWasmDebugResponsePayload(wasmRequest: unknown): unknown { + if (!isDebug()) return undefined; return callDebugMethod(wasmRequest, "debugResponsePayload"); } @@ -243,14 +243,10 @@ function updateDebugReportFromLoopExit( function emitBridgeCreationDebugReport( error: unknown, - config: BuilderConfig, - mode: IDKitDebugRequestMode, payload?: unknown, ): never { const report = createIDKitDebugReport({ - mode, transport: "bridge", - config, payload, }); updateDebugReport(report, { @@ -262,15 +258,11 @@ function emitBridgeCreationDebugReport( function createSentBridgeDebugReport( wasmRequest: unknown, - config: BuilderConfig, - mode: IDKitDebugRequestMode, requestId: string, connectorURI: string, ): IDKitDebugReport | undefined { const report = createIDKitDebugReport({ - mode, transport: "bridge", - config, payload: getWasmDebugPayload(wasmRequest), requestId, connectorURI, @@ -322,18 +314,12 @@ class IDKitRequestImpl implements IDKitRequest { private _requestId: string; private debugReport?: IDKitDebugReport; - constructor( - wasmRequest: WasmModule.IDKitRequest, - config: BuilderConfig, - mode: IDKitDebugRequestMode, - ) { + constructor(wasmRequest: WasmModule.IDKitRequest) { this.wasmRequest = wasmRequest; this._connectorURI = wasmRequest.connectUrl(); this._requestId = wasmRequest.requestId(); this.debugReport = createSentBridgeDebugReport( wasmRequest, - config, - mode, this._requestId, this._connectorURI, ); @@ -401,18 +387,13 @@ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { private _requestId: string; private debugReport?: IDKitDebugReport; - constructor( - wasmRequest: WasmModule.IDKitInviteCodeRequest, - config: BuilderConfig, - ) { + constructor(wasmRequest: WasmModule.IDKitInviteCodeRequest) { this.wasmRequest = wasmRequest; this._connectorURI = wasmRequest.connectUrl(); this._expiresAt = wasmRequest.expiresAt(); this._requestId = wasmRequest.requestId(); this.debugReport = createSentBridgeDebugReport( wasmRequest, - config, - "invite_code_request", this._requestId, this._connectorURI, ); @@ -870,18 +851,9 @@ class IDKitBuilder { const wasmRequest = (await wasmBuilder.constraints( constraints, )) as unknown as WasmModule.IDKitRequest; - return new IDKitRequestImpl( - wasmRequest, - this.config, - requestModeFromConfig(this.config), - ); + return new IDKitRequestImpl(wasmRequest); } catch (error) { - emitBridgeCreationDebugReport( - error, - this.config, - requestModeFromConfig(this.config), - debugPayload, - ); + emitBridgeCreationDebugReport(error, debugPayload); } } @@ -967,18 +939,9 @@ class IDKitBuilder { const wasmRequest = (await wasmBuilder.preset( preset, )) as unknown as WasmModule.IDKitRequest; - return new IDKitRequestImpl( - wasmRequest, - this.config, - requestModeFromConfig(this.config), - ); + return new IDKitRequestImpl(wasmRequest); } catch (error) { - emitBridgeCreationDebugReport( - error, - this.config, - requestModeFromConfig(this.config), - debugPayload, - ); + emitBridgeCreationDebugReport(error, debugPayload); } } } @@ -1026,14 +989,9 @@ class IDKitInviteCodeBuilder { const wasmRequest = (await wasmBuilder.constraintsWithInviteCode( constraints, )) as unknown as WasmModule.IDKitInviteCodeRequest; - return new IDKitInviteCodeRequestImpl(wasmRequest, this.config); + return new IDKitInviteCodeRequestImpl(wasmRequest); } catch (error) { - emitBridgeCreationDebugReport( - error, - this.config, - "invite_code_request", - debugPayload, - ); + emitBridgeCreationDebugReport(error, debugPayload); } } @@ -1065,14 +1023,9 @@ class IDKitInviteCodeBuilder { const wasmRequest = (await wasmBuilder.presetWithInviteCode( preset, )) as unknown as WasmModule.IDKitInviteCodeRequest; - return new IDKitInviteCodeRequestImpl(wasmRequest, this.config); + return new IDKitInviteCodeRequestImpl(wasmRequest); } catch (error) { - emitBridgeCreationDebugReport( - error, - this.config, - "invite_code_request", - debugPayload, - ); + emitBridgeCreationDebugReport(error, debugPayload); } } } diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index c11daf88..7f1dea30 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -30,7 +30,6 @@ import { createIDKitDebugReport, emitDebugReport, isDebug, - requestModeFromConfig, updateDebugReport, type IDKitDebugReport, } from "../lib/debug"; @@ -131,9 +130,7 @@ export function createNativeRequest( return _activeNativeRequest; } const debugReport = createIDKitDebugReport({ - mode: requestModeFromConfig(config), transport: "mini_app", - config, payload: wasmPayload, }); const request = new NativeIDKitRequest( diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index a45a79e4..e9a8d846 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -914,6 +914,9 @@ impl BridgeConnection { .await?; if !response.status().is_success() { + self.set_debug_poll_response_payload(serde_json::json!({ + "http_status": response.status().as_u16(), + })); return Ok(Status::Failed(AppError::ConnectionFailed)); } From 99f432b2168028d08da5de1e62443240ce9e2bc1 Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 19:01:58 -0700 Subject: [PATCH 09/12] Surface bridge create failure status/body as structured response_payload A failed bridge POST /request previously folded the HTTP status and body into an opaque error message string, leaving the report's response side unstructured on the creation path (unlike the poll path, which now carries http_status). Add an Error::BridgeRequestFailed { status, body } variant; the WASM boundary attaches the namespaced { create: { http_status, body } } object as a single debugResponsePayload property on the JS error, and request.ts lifts it as-is into response_payload.create. This mirrors the existing create/poll namespacing (bridge.rs success path + poll fix) and keeps the bridge-specific shape defined in Rust rather than reconstructed in TS. Raw body is stored (no "no error details" placeholder) so the structured field is truthful: an empty body is reported as empty. Co-Authored-By: Claude Opus 4.8 --- js/packages/core/src/request.ts | 10 ++++++++++ rust/core/src/bridge.rs | 13 +++---------- rust/core/src/error.rs | 7 +++++++ rust/core/src/wasm_bindings.rs | 6 ++++++ 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index d3223918..0e80fdba 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -188,6 +188,15 @@ function getErrorMessage(error: unknown): string { return String(error); } +// Bridge creation failures carry their structured response (HTTP status + body) +// as a `debugResponsePayload` property set at the WASM boundary. Lift it as-is so +// the bridge-specific shape stays defined in Rust rather than reconstructed here. +function getDebugResponsePayload(error: unknown): object | undefined { + const payload = (error as { debugResponsePayload?: unknown }) + ?.debugResponsePayload; + return payload && typeof payload === "object" ? payload : undefined; +} + function updateDebugReportFromStatus( report: IDKitDebugReport | undefined, status: Status, @@ -252,6 +261,7 @@ function emitBridgeCreationDebugReport( updateDebugReport(report, { status: "error", errorMessage: getErrorMessage(error), + responsePayload: getDebugResponsePayload(error), }); throw attachDebugReportToError(error, report); } diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index e9a8d846..6c74bf4f 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -747,17 +747,9 @@ impl BridgeConnection { .map_err(|e| bridge_request_failed(format!("Bridge request failed: {e}")))?; if !response.status().is_success() { - let status = response.status(); + let status = response.status().as_u16(); let body = response.text().await.unwrap_or_default(); - return Err(bridge_request_failed(format!( - "Bridge request failed with status {}: {}", - status, - if body.is_empty() { - "no error details" - } else { - &body - } - ))); + return Err(Error::BridgeRequestFailed { status, body }); } let create_response_payload: serde_json::Value = response @@ -1787,6 +1779,7 @@ fn to_app_error(error: &Error) -> AppError { match error { Error::InvalidConfiguration(_) => AppError::MalformedRequest, Error::BridgeError(_) => AppError::ConnectionFailed, + Error::BridgeRequestFailed { .. } => AppError::ConnectionFailed, Error::Json(_) => AppError::UnexpectedResponse, Error::Crypto(_) => AppError::UnexpectedResponse, Error::Base64(_) => AppError::UnexpectedResponse, diff --git a/rust/core/src/error.rs b/rust/core/src/error.rs index 76f82dcf..7d47505e 100644 --- a/rust/core/src/error.rs +++ b/rust/core/src/error.rs @@ -16,6 +16,10 @@ pub enum Error { #[error("Bridge error: {0}")] BridgeError(String), + /// Bridge rejected the create request with an HTTP status + #[error("Bridge request failed with status {status}: {body}")] + BridgeRequestFailed { status: u16, body: String }, + /// JSON serialization/deserialization error #[error("JSON error: {0}")] Json(#[from] serde_json::Error), @@ -273,6 +277,9 @@ impl From for IdkitError { }, Error::InvalidProof(message) => Self::InvalidProof { details: message }, Error::BridgeError(message) => Self::BridgeError { details: message }, + Error::BridgeRequestFailed { status, body } => Self::BridgeError { + details: format!("Bridge request failed with status {status}: {body}"), + }, Error::AppError(app_err) => Self::AppError { details: app_err.to_string(), }, diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index 5b641c5e..6c4efcd2 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -31,6 +31,12 @@ pub fn init_wasm() { fn bridge_create_error_to_js(error: crate::Error) -> JsValue { let js_error = js_sys::Error::new(&format!("Failed: {error}")); + if let crate::Error::BridgeRequestFailed { status, body } = &error { + let payload = serde_json::json!({ "create": { "http_status": status, "body": body } }); + if let Ok(value) = json_to_js(&payload) { + let _ = js_sys::Reflect::set(&js_error, &"debugResponsePayload".into(), &value); + } + } js_error.into() } From 553736ebe0c26f6ac4fd7d2ba0274f2dba9480b6 Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Mon, 15 Jun 2026 19:20:50 -0700 Subject: [PATCH 10/12] Fix invite-code bridge debug response payload --- rust/core/src/bridge.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 6c74bf4f..774437d8 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -1202,17 +1202,9 @@ async fn try_create_invite_code_request( return Err(CreateCodeError::Conflict); } if !response.status().is_success() { - let status = response.status(); + let status = response.status().as_u16(); let body = response.text().await.unwrap_or_default(); - return Err(bridge_request_failed(format!( - "Bridge /request (code) failed with status {status}: {}", - if body.is_empty() { - "no error details" - } else { - &body - } - )) - .into()); + return Err(Error::BridgeRequestFailed { status, body }.into()); } // Validate that the bridge stored the request under the id we sent. From 5df3b4d271d68f71c3266ef6d76468fc021f3748 Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Tue, 16 Jun 2026 01:41:43 -0700 Subject: [PATCH 11/12] Revert native transport debug log trimming Restore the three console.debug calls in the native transport to their original full-payload form, per review feedback to avoid changing the existing logging. The full request/response payloads remain available through the structured debug report (getDebugReport), so no debug data is lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- js/packages/core/src/transports/native.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index 7f1dea30..d6b5a3fe 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -183,11 +183,7 @@ class NativeIDKitRequest implements IDKitRequest { }); if (isDebug()) - console.debug("[IDKit] Native: received response", { - status: responsePayload?.status, - error_code: responsePayload?.error_code, - protocol_version: responsePayload?.protocol_version, - }); + console.debug("[IDKit] Native: received response", responsePayload); if (responsePayload?.status === "error") { if (isDebug()) @@ -278,7 +274,7 @@ class NativeIDKitRequest implements IDKitRequest { if (isDebug()) console.debug( `[IDKit] Native: sending verify command (version=${version}, platform=ios)`, - { command: "verify", version, requestId: this.requestId }, + sendPayload, ); w.webkit.messageHandlers.minikit.postMessage(sendPayload); updateDebugReport(this.debugReport, { @@ -289,7 +285,7 @@ class NativeIDKitRequest implements IDKitRequest { if (isDebug()) console.debug( `[IDKit] Native: sending verify command (version=${version}, platform=android)`, - { command: "verify", version, requestId: this.requestId }, + sendPayload, ); w.Android.postMessage(JSON.stringify(sendPayload)); updateDebugReport(this.debugReport, { From 41beb9c89b68f28d30f353ac7fffd51f2bb9eda1 Mon Sep 17 00:00:00 2001 From: soamdesai-tfh Date: Tue, 16 Jun 2026 15:05:38 -0700 Subject: [PATCH 12/12] Simplify debug report snapshots --- js/packages/core/src/__tests__/smoke.test.ts | 81 ++++- js/packages/core/src/index.ts | 3 +- js/packages/core/src/lib/debug.ts | 170 +-------- js/packages/core/src/request.ts | 337 +++--------------- .../core/src/transports/native.test.ts | 57 +++ js/packages/core/src/transports/native.ts | 50 +-- rust/core/src/bridge.rs | 118 +++--- rust/core/src/error.rs | 7 - rust/core/src/wasm_bindings.rs | 91 +---- 9 files changed, 270 insertions(+), 644 deletions(-) diff --git a/js/packages/core/src/__tests__/smoke.test.ts b/js/packages/core/src/__tests__/smoke.test.ts index 6cfe046a..04375978 100644 --- a/js/packages/core/src/__tests__/smoke.test.ts +++ b/js/packages/core/src/__tests__/smoke.test.ts @@ -3,7 +3,7 @@ * These tests verify that the WASM integration and core APIs are functional */ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect } from "vitest"; import { IDKit, CredentialRequest, @@ -19,7 +19,10 @@ import { IDKitErrorCodes, signRequest, hashSignal, + setDebug, + type IDKitDebugTransport, } from "../index"; +import { createIDKitDebugReport } from "../lib/debug"; import { initIDKit, WasmModule } from "../lib/wasm"; const TEST_SESSION_ID = `session_${"11".repeat(64)}` as const; @@ -292,6 +295,82 @@ describe("IDKitRequest API", () => { }); }); +describe("Debug reports", () => { + afterEach(() => { + setDebug(false); + delete (globalThis as any).window; + }); + + it("does not build reports when debug mode is disabled", () => { + setDebug(false); + + expect( + createIDKitDebugReport({ + transport: "bridge", + requestPayload: { proof_request: { id: "req_1" } }, + }), + ).toBeUndefined(); + }); + + it("builds a bridge snapshot from the provided request data", () => { + setDebug(true); + + const payload = { proof_request: { id: "req_1" } }; + + const report = createIDKitDebugReport({ + transport: "bridge", + requestPayload: payload, + requestId: "req_1", + connectorURI: "https://world.org/verify?t=wld&i=req_1", + }); + + expect(report).toMatchObject({ + package_version: expect.any(String), + transport: "bridge", + timestamps: { generated_at: expect.any(String) }, + request_id: "req_1", + connector_uri: "https://world.org/verify?t=wld&i=req_1", + request_payload: payload, + }); + expect(report).not.toHaveProperty("response_payload"); + }); + + it("only includes Mini App metadata on Mini App reports", () => { + setDebug(true); + (globalThis as any).window = { + WorldApp: { + world_app_version: "2026.6.16", + device_os: "ios", + }, + }; + + expect( + createIDKitDebugReport({ + transport: "bridge", + requestPayload: { proof_request: { id: "req_1" } }, + }), + ).not.toHaveProperty("mini_app"); + + expect( + createIDKitDebugReport({ + transport: "mini_app", + requestPayload: { command: "verify" }, + }), + ).toMatchObject({ + mini_app: { + world_app_version: "2026.6.16", + platform: "ios", + }, + }); + }); + + it("exports debug transport type from the package entrypoint", () => { + const transport: IDKitDebugTransport = "bridge"; + + expect(transport).toBe("bridge"); + }); +}); + describe("Enums", () => { it("should export IDKitErrorCodes enum", () => { expect(IDKitErrorCodes.ConnectionFailed).toBe("connection_failed"); diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 74c67f84..cee67c48 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -78,9 +78,8 @@ export { isInWorldApp } from "./transports/native"; export { isDebug, setDebug, - setDebugReportHandler, type IDKitDebugReport, - type IDKitDebugReportHandler, + type IDKitDebugTransport, } from "./lib/debug"; // Session utilities diff --git a/js/packages/core/src/lib/debug.ts b/js/packages/core/src/lib/debug.ts index a5c6301a..9a93365e 100644 --- a/js/packages/core/src/lib/debug.ts +++ b/js/packages/core/src/lib/debug.ts @@ -1,35 +1,20 @@ import packageJson from "../../package.json"; let _debug = false; -let _debugReportHandler: IDKitDebugReportHandler | null = null; export type IDKitDebugTransport = "bridge" | "mini_app"; -export type IDKitDebugReportStatus = - | "created" - | "sent" - | "waiting_for_connection" - | "awaiting_confirmation" - | "success" - | "error" - | "cancelled" - | "timeout"; export type IDKitDebugReport = { - version: 1; package_version: string; transport: IDKitDebugTransport; - status: string; - timestamps: Record; + timestamps: { generated_at: string }; request_id?: string; connector_uri?: string; request_payload?: object; response_payload?: object; mini_app?: Record; - error?: Record; }; -export type IDKitDebugReportHandler = (report: IDKitDebugReport) => void; - export function isDebug(): boolean { if (_debug) return true; return typeof window !== "undefined" && Boolean((window as any).IDKIT_DEBUG); @@ -39,50 +24,10 @@ export function setDebug(enabled: boolean): void { _debug = enabled; } -export function setDebugReportHandler( - handler: IDKitDebugReportHandler | null, -): void { - _debugReportHandler = handler; -} - -export function emitDebugReport(report: IDKitDebugReport | undefined): void { - if (report && isDebug() && _debugReportHandler) { - _debugReportHandler(cloneDebugReport(report)!); - } -} - -export function cloneDebugReport( - report: IDKitDebugReport | undefined, -): IDKitDebugReport | undefined { - if (!report) return undefined; - - return cloneValue(report) as IDKitDebugReport; -} - -export function attachDebugReportToError( - error: T, - report: IDKitDebugReport | undefined, -): T { - if ( - !report || - (typeof error !== "object" && typeof error !== "function") || - error === null - ) { - return error; - } - - Object.defineProperty(error, "debugReport", { - configurable: true, - enumerable: false, - value: cloneDebugReport(report), - }); - - return error; -} - export function createIDKitDebugReport(options: { transport: IDKitDebugTransport; - payload?: unknown; + requestPayload?: unknown; + responsePayload?: unknown; requestId?: string; connectorURI?: string; }): IDKitDebugReport | undefined { @@ -90,116 +35,39 @@ export function createIDKitDebugReport(options: { const now = new Date().toISOString(); const report: IDKitDebugReport = { - version: 1, package_version: packageJson.version, transport: options.transport, - status: "created", - timestamps: { created_at: now, updated_at: now }, + timestamps: { generated_at: now }, }; - const miniApp = getMiniAppDebugInfo(); - if (miniApp) report.mini_app = miniApp; - - if (options.payload && typeof options.payload === "object") { - report.request_payload = options.payload; + if (options.transport === "mini_app") { + const miniApp = getMiniAppDebugInfo(); + if (miniApp) report.mini_app = miniApp; } - if (options.requestId) report.request_id = options.requestId; - if (options.connectorURI) report.connector_uri = options.connectorURI; - return report; -} -export function updateDebugReport( - report: IDKitDebugReport | undefined, - update: { - status?: IDKitDebugReportStatus; - requestId?: string; - connectorURI?: string; - sentToTransportAt?: string; - responseReceivedAt?: string; - responsePayload?: unknown; - errorCode?: string; - errorMessage?: string; - }, -): IDKitDebugReport | undefined { - if (!report) return undefined; - - if (update.requestId) report.request_id = update.requestId; - if (update.connectorURI) report.connector_uri = update.connectorURI; - if (update.status) report.status = update.status; - if (update.sentToTransportAt) { - report.timestamps.sent_to_transport_at = update.sentToTransportAt; - } - if (update.responseReceivedAt) { - report.timestamps.response_received_at = update.responseReceivedAt; - } - if (update.responsePayload !== undefined) { - if (update.responsePayload && typeof update.responsePayload === "object") { - report.response_payload = update.responsePayload; - } + if (options.requestPayload && typeof options.requestPayload === "object") { + report.request_payload = options.requestPayload; } - if (update.errorCode !== undefined || update.errorMessage !== undefined) { - report.error = { - code: update.errorCode ?? report.error?.code, - message: update.errorMessage ?? report.error?.message, - }; + if (options.responsePayload && typeof options.responsePayload === "object") { + report.response_payload = options.responsePayload; } - - report.timestamps.updated_at = new Date().toISOString(); - emitDebugReport(report); + if (options.requestId) report.request_id = options.requestId; + if (options.connectorURI) report.connector_uri = options.connectorURI; return report; } -function compact>(value: T): T { - return Object.fromEntries( - Object.entries(value).filter(([, child]) => child !== undefined), - ) as T; -} - function getMiniAppDebugInfo(): Record | undefined { const worldApp = typeof window !== "undefined" ? (window as any).WorldApp : undefined; if (!worldApp || typeof worldApp !== "object") return undefined; - return compact({ - world_app_version: worldApp.world_app_version, - platform: worldApp.device_os, - }); -} - -function normalizeForJson(value: unknown): unknown { - if (typeof value === "bigint") return value.toString(); - - if (value instanceof Uint8Array) { - return { - type: "Uint8Array", - data: Array.from(value), - }; + const miniApp: Record = {}; + if (worldApp.world_app_version !== undefined) { + miniApp.world_app_version = worldApp.world_app_version; } - - if (Array.isArray(value)) { - return value.map((item) => normalizeForJson(item)); - } - - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value as Record).map(([key, child]) => [ - key, - normalizeForJson(child), - ]), - ); - } - - return value; -} - -function cloneValue(value: unknown): unknown { - try { - if (typeof structuredClone === "function") { - return structuredClone(value); - } - } catch { - // Fall through to JSON clone below. + if (worldApp.device_os !== undefined) { + miniApp.platform = worldApp.device_os; } - return JSON.parse(JSON.stringify(normalizeForJson(value)) ?? "null"); + return Object.keys(miniApp).length > 0 ? miniApp : undefined; } diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index 0e80fdba..790b2269 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -24,11 +24,8 @@ import { type BuilderConfig, } from "./transports/native"; import { - attachDebugReportToError, - cloneDebugReport, createIDKitDebugReport, isDebug, - updateDebugReport, type IDKitDebugReport, } from "./lib/debug"; @@ -90,7 +87,6 @@ export interface IDKitRequest { async function pollUntilCompletionLoop( pollOnce: () => Promise, options?: WaitOptions, - onLoopExit?: (result: IDKitCompletionResult) => void, ): Promise { const pollInterval = options?.pollInterval ?? 1000; const timeout = options?.timeout ?? 900_000; // 15 minutes default @@ -98,21 +94,11 @@ async function pollUntilCompletionLoop( while (true) { if (options?.signal?.aborted) { - const result: IDKitCompletionResult = { - success: false, - error: IDKitErrorCodes.Cancelled, - }; - onLoopExit?.(result); - return result; + return { success: false, error: IDKitErrorCodes.Cancelled }; } if (Date.now() - startTime > timeout) { - const result: IDKitCompletionResult = { - success: false, - error: IDKitErrorCodes.Timeout, - }; - onLoopExit?.(result); - return result; + return { success: false, error: IDKitErrorCodes.Timeout }; } const status = await pollOnce(); @@ -133,186 +119,37 @@ async function pollUntilCompletionLoop( } } -function callDebugMethod( - target: unknown, - method: string, - input?: unknown, +type BridgeDebugPayloadSource = { + debugPayload?: () => unknown; +}; + +function readBridgeDebugPayload( + wasmRequest: BridgeDebugPayloadSource, ): unknown { - const fn = (target as Record)[method]; - if (typeof fn !== "function") { + if (typeof wasmRequest.debugPayload !== "function") { return undefined; } try { - return input === undefined ? fn.call(target) : fn.call(target, input); + return wasmRequest.debugPayload(); } catch { return undefined; } } -function getWasmDebugPayload(wasmRequest: unknown): unknown { - if (!isDebug()) return undefined; - return callDebugMethod(wasmRequest, "debugPayload"); -} - -function getWasmDebugResponsePayload(wasmRequest: unknown): unknown { - if (!isDebug()) return undefined; - return callDebugMethod(wasmRequest, "debugResponsePayload"); -} - -function getBridgeDebugPayload( - wasmBuilder: WasmModule.IDKitBuilder, - method: "bridgeDebugPayload" | "bridgeDebugPayloadFromPreset", - input: unknown, -): unknown { - if (!isDebug()) return undefined; - const payload = callDebugMethod(wasmBuilder, method, input); - // This payload is rebuilt for the bridge-creation failure path, where no - // request object exists yet. Its proof_request.id is a freshly generated - // UUID that does not match the id of any request actually sent to the bridge - // (and is end-to-end encrypted, so the bridge never sees it anyway). Drop it - // here so the creation-error report doesn't surface a fabricated, non- - // correlatable id. The live path (getDebugReport) keeps the real id. - const proofRequest = (payload as { proof_request?: { id?: unknown } }) - ?.proof_request; - if (proofRequest && typeof proofRequest === "object") { - delete proofRequest.id; - } - return payload; -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -// Bridge creation failures carry their structured response (HTTP status + body) -// as a `debugResponsePayload` property set at the WASM boundary. Lift it as-is so -// the bridge-specific shape stays defined in Rust rather than reconstructed here. -function getDebugResponsePayload(error: unknown): object | undefined { - const payload = (error as { debugResponsePayload?: unknown }) - ?.debugResponsePayload; - return payload && typeof payload === "object" ? payload : undefined; -} - -function updateDebugReportFromStatus( - report: IDKitDebugReport | undefined, - status: Status, - responsePayload?: unknown, -): void { - if (status.type === "confirmed") { - updateDebugReport(report, { - status: "success", - responseReceivedAt: new Date().toISOString(), - ...(responsePayload !== undefined ? { responsePayload } : {}), - }); - return; - } - - if (status.type === "failed") { - // A "failed" status can originate from a non-2xx bridge response before any - // authenticator response was decrypted, so only record response_received_at - // when a poll response actually arrived (matches the exception path below). - const hasResponsePayload = - typeof responsePayload === "object" && - responsePayload !== null && - Object.prototype.hasOwnProperty.call(responsePayload, "poll"); - updateDebugReport(report, { - status: "error", - ...(hasResponsePayload - ? { responseReceivedAt: new Date().toISOString() } - : {}), - ...(responsePayload !== undefined ? { responsePayload } : {}), - errorCode: status.error, - }); - return; - } - - updateDebugReport(report, { - status: status.type, - }); -} - -function updateDebugReportFromLoopExit( - report: IDKitDebugReport | undefined, - result: IDKitCompletionResult, -): void { - if (result.success === true) { - return; - } - - updateDebugReport(report, { - status: - result.error === IDKitErrorCodes.Cancelled ? "cancelled" : "timeout", - errorCode: result.error, - }); -} - -function emitBridgeCreationDebugReport( - error: unknown, - payload?: unknown, -): never { - const report = createIDKitDebugReport({ - transport: "bridge", - payload, - }); - updateDebugReport(report, { - status: "error", - errorMessage: getErrorMessage(error), - responsePayload: getDebugResponsePayload(error), - }); - throw attachDebugReportToError(error, report); -} - -function createSentBridgeDebugReport( - wasmRequest: unknown, +function createBridgeDebugReport( + wasmRequest: BridgeDebugPayloadSource, requestId: string, connectorURI: string, ): IDKitDebugReport | undefined { - const report = createIDKitDebugReport({ + if (!isDebug()) return undefined; + + return createIDKitDebugReport({ transport: "bridge", - payload: getWasmDebugPayload(wasmRequest), + requestPayload: readBridgeDebugPayload(wasmRequest), requestId, connectorURI, }); - updateDebugReport(report, { - status: "sent", - sentToTransportAt: new Date().toISOString(), - responsePayload: getWasmDebugResponsePayload(wasmRequest), - }); - return report; -} - -async function pollBridgeStatus( - wasmRequest: { pollForStatus: () => Promise }, - report: IDKitDebugReport | undefined, -): Promise { - try { - const status = (await wasmRequest.pollForStatus()) as Status; - updateDebugReportFromStatus( - report, - status, - getWasmDebugResponsePayload(wasmRequest), - ); - return status; - } catch (error) { - const responsePayload = getWasmDebugResponsePayload(wasmRequest); - const hasResponsePayload = - typeof responsePayload === "object" && - responsePayload !== null && - Object.prototype.hasOwnProperty.call(responsePayload, "poll"); - updateDebugReport(report, { - status: "error", - ...(responsePayload !== undefined ? { responsePayload } : {}), - ...(hasResponsePayload - ? { responseReceivedAt: new Date().toISOString() } - : {}), - errorMessage: getErrorMessage(error), - }); - throw attachDebugReportToError(error, report); - } } /** @@ -320,44 +157,29 @@ async function pollBridgeStatus( */ class IDKitRequestImpl implements IDKitRequest { private wasmRequest: WasmModule.IDKitRequest; - private _connectorURI: string; - private _requestId: string; - private debugReport?: IDKitDebugReport; + readonly connectorURI: string; + readonly requestId: string; constructor(wasmRequest: WasmModule.IDKitRequest) { this.wasmRequest = wasmRequest; - this._connectorURI = wasmRequest.connectUrl(); - this._requestId = wasmRequest.requestId(); - this.debugReport = createSentBridgeDebugReport( - wasmRequest, - this._requestId, - this._connectorURI, - ); - } - - get connectorURI(): string { - return this._connectorURI; - } - - get requestId(): string { - return this._requestId; + this.connectorURI = wasmRequest.connectUrl(); + this.requestId = wasmRequest.requestId(); } async pollOnce(): Promise { - return pollBridgeStatus(this.wasmRequest, this.debugReport); + return (await this.wasmRequest.pollForStatus()) as Status; } pollUntilCompletion(options?: WaitOptions): Promise { - return pollUntilCompletionLoop( - () => this.pollOnce(), - options, - (result) => updateDebugReportFromLoopExit(this.debugReport, result), - ); + return pollUntilCompletionLoop(() => this.pollOnce(), options); } getDebugReport(): IDKitDebugReport | undefined { - if (!isDebug()) return undefined; - return cloneDebugReport(this.debugReport); + return createBridgeDebugReport( + this.wasmRequest, + this.requestId, + this.connectorURI, + ); } } @@ -392,50 +214,31 @@ export interface IDKitInviteCodeRequest { */ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { private wasmRequest: WasmModule.IDKitInviteCodeRequest; - private _connectorURI: string; - private _expiresAt: number; - private _requestId: string; - private debugReport?: IDKitDebugReport; + readonly connectorURI: string; + readonly expiresAt: number; + readonly requestId: string; constructor(wasmRequest: WasmModule.IDKitInviteCodeRequest) { this.wasmRequest = wasmRequest; - this._connectorURI = wasmRequest.connectUrl(); - this._expiresAt = wasmRequest.expiresAt(); - this._requestId = wasmRequest.requestId(); - this.debugReport = createSentBridgeDebugReport( - wasmRequest, - this._requestId, - this._connectorURI, - ); - } - - get connectorURI(): string { - return this._connectorURI; - } - - get expiresAt(): number { - return this._expiresAt; - } - - get requestId(): string { - return this._requestId; + this.connectorURI = wasmRequest.connectUrl(); + this.expiresAt = wasmRequest.expiresAt(); + this.requestId = wasmRequest.requestId(); } async pollOnce(): Promise { - return pollBridgeStatus(this.wasmRequest, this.debugReport); + return (await this.wasmRequest.pollForStatus()) as Status; } pollUntilCompletion(options?: WaitOptions): Promise { - return pollUntilCompletionLoop( - () => this.pollOnce(), - options, - (result) => updateDebugReportFromLoopExit(this.debugReport, result), - ); + return pollUntilCompletionLoop(() => this.pollOnce(), options); } getDebugReport(): IDKitDebugReport | undefined { - if (!isDebug()) return undefined; - return cloneDebugReport(this.debugReport); + return createBridgeDebugReport( + this.wasmRequest, + this.requestId, + this.connectorURI, + ); } } @@ -852,19 +655,10 @@ class IDKitBuilder { // Bridge path — WASM const wasmBuilder = createWasmBuilderFromConfig(this.config); - const debugPayload = getBridgeDebugPayload( - wasmBuilder, - "bridgeDebugPayload", + const wasmRequest = (await wasmBuilder.constraints( constraints, - ); - try { - const wasmRequest = (await wasmBuilder.constraints( - constraints, - )) as unknown as WasmModule.IDKitRequest; - return new IDKitRequestImpl(wasmRequest); - } catch (error) { - emitBridgeCreationDebugReport(error, debugPayload); - } + )) as unknown as WasmModule.IDKitRequest; + return new IDKitRequestImpl(wasmRequest); } /** @@ -940,19 +734,10 @@ class IDKitBuilder { // Bridge path — WASM const wasmBuilder = createWasmBuilderFromConfig(this.config); - const debugPayload = getBridgeDebugPayload( - wasmBuilder, - "bridgeDebugPayloadFromPreset", + const wasmRequest = (await wasmBuilder.preset( preset, - ); - try { - const wasmRequest = (await wasmBuilder.preset( - preset, - )) as unknown as WasmModule.IDKitRequest; - return new IDKitRequestImpl(wasmRequest); - } catch (error) { - emitBridgeCreationDebugReport(error, debugPayload); - } + )) as unknown as WasmModule.IDKitRequest; + return new IDKitRequestImpl(wasmRequest); } } @@ -990,19 +775,10 @@ class IDKitInviteCodeBuilder { await initIDKit(); const wasmBuilder = createWasmBuilderFromConfig(this.config); - const debugPayload = getBridgeDebugPayload( - wasmBuilder, - "bridgeDebugPayload", + const wasmRequest = (await wasmBuilder.constraintsWithInviteCode( constraints, - ); - try { - const wasmRequest = (await wasmBuilder.constraintsWithInviteCode( - constraints, - )) as unknown as WasmModule.IDKitInviteCodeRequest; - return new IDKitInviteCodeRequestImpl(wasmRequest); - } catch (error) { - emitBridgeCreationDebugReport(error, debugPayload); - } + )) as unknown as WasmModule.IDKitInviteCodeRequest; + return new IDKitInviteCodeRequestImpl(wasmRequest); } /** @@ -1024,19 +800,10 @@ class IDKitInviteCodeBuilder { await initIDKit(); const wasmBuilder = createWasmBuilderFromConfig(this.config); - const debugPayload = getBridgeDebugPayload( - wasmBuilder, - "bridgeDebugPayloadFromPreset", + const wasmRequest = (await wasmBuilder.presetWithInviteCode( preset, - ); - try { - const wasmRequest = (await wasmBuilder.presetWithInviteCode( - preset, - )) as unknown as WasmModule.IDKitInviteCodeRequest; - return new IDKitInviteCodeRequestImpl(wasmRequest); - } catch (error) { - emitBridgeCreationDebugReport(error, debugPayload); - } + )) as unknown as WasmModule.IDKitInviteCodeRequest; + return new IDKitInviteCodeRequestImpl(wasmRequest); } } diff --git a/js/packages/core/src/transports/native.test.ts b/js/packages/core/src/transports/native.test.ts index 66338eb8..75c2ce40 100644 --- a/js/packages/core/src/transports/native.test.ts +++ b/js/packages/core/src/transports/native.test.ts @@ -3,6 +3,7 @@ import { IDKitErrorCodes } from "../types/result"; import type { BuilderConfig } from "./native"; import { createNativeRequest, getWorldAppVerifyVersion } from "./native"; import { hashSignal } from "../lib/hashing"; +import { setDebug } from "../lib/debug"; const baseConfig: BuilderConfig = { type: "request", @@ -27,6 +28,7 @@ describe("native transport request lifecycle", () => { listeners = []; miniKitHandlers = {}; activeRequest = null; + setDebug(false); (globalThis as any).window = { addEventListener: vi.fn( @@ -56,11 +58,66 @@ describe("native transport request lifecycle", () => { afterEach(() => { activeRequest?.cancel?.(); + setDebug(false); vi.useRealTimers(); vi.restoreAllMocks(); delete (globalThis as any).window; }); + it("returns undefined debug reports when debug mode is disabled", () => { + const req = createNativeRequest({ payload: 1 }, baseConfig, {}, ""); + activeRequest = req; + + expect(req.getDebugReport()).toBeUndefined(); + }); + + it("builds native debug snapshots from current request data", async () => { + setDebug(true); + vi.spyOn(console, "debug").mockImplementation(() => {}); + (globalThis as any).window.WorldApp = { + world_app_version: "2026.6.16", + device_os: "ios", + }; + + const requestPayload = { payload: 1 }; + const req = createNativeRequest(requestPayload, baseConfig, {}, ""); + activeRequest = req; + + const initialReport = req.getDebugReport(); + expect(initialReport).toMatchObject({ + package_version: expect.any(String), + transport: "mini_app", + timestamps: { generated_at: expect.any(String) }, + request_id: expect.any(String), + request_payload: requestPayload, + mini_app: { + world_app_version: "2026.6.16", + platform: "ios", + }, + }); + expect(initialReport?.response_payload).toBeUndefined(); + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + const responsePayload = { + status: "success", + protocol_version: "3.0", + verification_level: "orb", + signal_hash: "0xabc", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }; + miniKitHandlers["miniapp-verify-action"]?.(responsePayload); + + await expect(completionPromise).resolves.toMatchObject({ + success: true, + }); + expect(req.getDebugReport()).toMatchObject({ + request_payload: requestPayload, + response_payload: responsePayload, + }); + }); + it("reuses the in-flight native request instead of cancelling it", async () => { const req1 = createNativeRequest({ payload: 1 }, baseConfig, {}, ""); activeRequest = req1; diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index d6b5a3fe..63844156 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -26,11 +26,8 @@ import { IDKitErrorCodes } from "../types/result"; import type { IDKitResultV3, IntegrityBundle } from "../lib/wasm"; import { WasmModule } from "../lib/wasm"; import { - cloneDebugReport, createIDKitDebugReport, - emitDebugReport, isDebug, - updateDebugReport, type IDKitDebugReport, } from "../lib/debug"; @@ -126,20 +123,14 @@ export function createNativeRequest( console.warn( "[IDKit] Native: request already in flight, reusing active request", ); - emitDebugReport(_activeNativeRequest.getDebugReport()); return _activeNativeRequest; } - const debugReport = createIDKitDebugReport({ - transport: "mini_app", - payload: wasmPayload, - }); const request = new NativeIDKitRequest( wasmPayload, config, signalHashes, legacySignalHash, version, - debugReport, ); _activeNativeRequest = request; return request; @@ -154,21 +145,17 @@ class NativeIDKitRequest implements IDKitRequest { private resolveFn: ((result: IDKitCompletionResult) => void) | null = null; private messageHandler: ((event: MessageEvent) => void) | null = null; private miniKitHandler: ((payload: any) => void) | null = null; - private debugReport?: IDKitDebugReport; + private responsePayload?: unknown; constructor( - wasmPayload: unknown, + private readonly wasmPayload: unknown, config: BuilderConfig, signalHashes: Record = {}, legacySignalHash: string, version: 1 | 2 = 2, - debugReport?: IDKitDebugReport, ) { this.requestId = crypto.randomUUID?.() ?? `native-${Date.now()}-${++_requestCounter}`; - this.debugReport = updateDebugReport(debugReport, { - requestId: this.requestId, - }); // Never rejects — all outcomes (success, error, cancel, timeout) resolve. this.resultPromise = new Promise((resolve) => { @@ -177,10 +164,7 @@ class NativeIDKitRequest implements IDKitRequest { const handleIncomingPayload = (responsePayload: any) => { if (this.completionResult) return; - updateDebugReport(this.debugReport, { - responseReceivedAt: new Date().toISOString(), - responsePayload, - }); + this.responsePayload = responsePayload; if (isDebug()) console.debug("[IDKit] Native: received response", responsePayload); @@ -277,10 +261,6 @@ class NativeIDKitRequest implements IDKitRequest { sendPayload, ); w.webkit.messageHandlers.minikit.postMessage(sendPayload); - updateDebugReport(this.debugReport, { - status: "sent", - sentToTransportAt: new Date().toISOString(), - }); } else if (w.Android) { if (isDebug()) console.debug( @@ -288,10 +268,6 @@ class NativeIDKitRequest implements IDKitRequest { sendPayload, ); w.Android.postMessage(JSON.stringify(sendPayload)); - updateDebugReport(this.debugReport, { - status: "sent", - sentToTransportAt: new Date().toISOString(), - }); } else { if (isDebug()) console.warn( @@ -321,18 +297,6 @@ class NativeIDKitRequest implements IDKitRequest { result.success === true ? "success" : `error=${result.error}`, ); this.completionResult = result; - const status = - result.success === true - ? "success" - : result.error === IDKitErrorCodes.Cancelled - ? "cancelled" - : result.error === IDKitErrorCodes.Timeout - ? "timeout" - : "error"; - updateDebugReport(this.debugReport, { - status, - errorCode: result.success === true ? undefined : result.error, - }); this.cleanup(); this.resolveFn?.(result); if (_activeNativeRequest === this) { @@ -341,8 +305,12 @@ class NativeIDKitRequest implements IDKitRequest { } getDebugReport(): IDKitDebugReport | undefined { - if (!isDebug()) return undefined; - return cloneDebugReport(this.debugReport); + return createIDKitDebugReport({ + transport: "mini_app", + requestPayload: this.wasmPayload, + responsePayload: this.responsePayload, + requestId: this.requestId, + }); } cancel(): void { diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 774437d8..9680dd37 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -23,7 +23,6 @@ use crate::crypto::CryptoKey; use std::str::FromStr; #[cfg(feature = "ffi")] use std::sync::Arc; -use std::sync::Mutex; // ───────────────────────────────────────────────────────────────────────────── // Environment @@ -510,9 +509,8 @@ pub struct BridgeConnection { /// Used to add the `signal_hash` back to the idkit response for convenience cached_signal_hashes: CachedSignalHashes, /// Request payload built by `IDKit` before bridge encryption/wrapping. - /// Stored unconditionally because Rust does not know whether JS debug mode is enabled. - debug_payload: serde_json::Value, - debug_response_payload: Mutex>, + #[allow(dead_code)] + request_payload: serde_json::Value, /// Action identifier (only for uniqueness proofs) action: Option, /// Action description (only if provided in input) @@ -710,8 +708,8 @@ impl BridgeConnection { // Build the payload using the shared function (borrows params). // Bridge path does not need the timestamp field. - let payload_value = build_request_payload(¶ms, false)?; - let payload_json = serde_json::to_vec(&payload_value)?; + let request_payload = build_request_payload(¶ms, false)?; + let payload_json = serde_json::to_vec(&request_payload)?; // Compute signal hashes before partial moves let cached_signal_hashes = CachedSignalHashes::compute(¶ms); @@ -736,30 +734,29 @@ impl BridgeConnection { // Send to bridge let client = reqwest::Client::builder() .user_agent(format!("idkit-core/{}", env!("CARGO_PKG_VERSION"))) - .build() - .map_err(|e| bridge_request_failed(format!("Bridge client build failed: {e}")))?; + .build()?; let response = client .post(bridge_url.join("/request")?) .json(&body) .send() - .await - .map_err(|e| bridge_request_failed(format!("Bridge request failed: {e}")))?; + .await?; if !response.status().is_success() { - let status = response.status().as_u16(); + let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(Error::BridgeRequestFailed { status, body }); + return Err(Error::BridgeError(format!( + "Bridge request failed with status {}: {}", + status, + if body.is_empty() { + "no error details" + } else { + &body + }, + ))); } - let create_response_payload: serde_json::Value = response - .json() - .await - .map_err(|e| bridge_request_failed(format!("Failed to parse bridge response: {e}")))?; - let create_response: BridgeCreateResponse = - serde_json::from_value(create_response_payload.clone()).map_err(|e| { - bridge_request_failed(format!("Failed to parse bridge response: {e}")) - })?; + let create_response: BridgeCreateResponse = response.json().await?; // Extract action from kind for result let action = match ¶ms.kind { @@ -778,10 +775,7 @@ impl BridgeConnection { app_id, client, cached_signal_hashes, - debug_payload: payload_value, - debug_response_payload: Mutex::new(Some(serde_json::json!({ - "create": create_response_payload - }))), + request_payload, action, action_description: params.action_description, nonce: params.rp_context.nonce.clone(), @@ -906,9 +900,6 @@ impl BridgeConnection { .await?; if !response.status().is_success() { - self.set_debug_poll_response_payload(serde_json::json!({ - "http_status": response.status().as_u16(), - })); return Ok(Status::Failed(AppError::ConnectionFailed)); } @@ -931,9 +922,7 @@ impl BridgeConnection { #[cfg(not(feature = "native-crypto"))] let plaintext = decrypt(&self.key_bytes, &iv, &ciphertext)?; - let response_payload: serde_json::Value = serde_json::from_slice(&plaintext)?; - self.set_debug_poll_response_payload(response_payload.clone()); - let bridge_response: BridgeResponse = serde_json::from_value(response_payload)?; + let bridge_response: BridgeResponse = serde_json::from_slice(&plaintext)?; match bridge_response { BridgeResponse::Error { error_code } => Ok(Status::Failed(error_code)), @@ -1079,30 +1068,14 @@ impl BridgeConnection { /// Returns the pre-encryption request payload for SDK debug reporting. /// - /// This does not include bridge encryption keys, IVs, invite codes, or - /// encrypted request/response bodies. - #[must_use] - pub fn debug_payload(&self) -> &serde_json::Value { - &self.debug_payload - } - + /// This includes the request fields sent to World App before bridge + /// encryption/wrapping, including RP context fields such as nonce and + /// signature. It does not include bridge encryption keys, IVs, invite codes, + /// or encrypted request/response bodies. + #[allow(dead_code)] #[must_use] - pub fn debug_response_payload(&self) -> Option { - self.debug_response_payload - .lock() - .ok() - .and_then(|payload| payload.clone()) - } - - fn set_debug_poll_response_payload(&self, payload: serde_json::Value) { - if let Ok(mut debug_response_payload) = self.debug_response_payload.lock() { - let mut response_object = debug_response_payload - .take() - .and_then(|payload| payload.as_object().cloned()) - .unwrap_or_default(); - response_object.insert("poll".to_string(), payload); - *debug_response_payload = Some(serde_json::Value::Object(response_object)); - } + pub(crate) fn request_payload(&self) -> &serde_json::Value { + &self.request_payload } /// Unix-seconds expiry of the unredeemed code, if this connection was @@ -1120,10 +1093,6 @@ enum CreateCodeError { Other(Error), } -fn bridge_request_failed(message: impl Into) -> Error { - Error::BridgeError(message.into()) -} - impl From for CreateCodeError { fn from(e: Error) -> Self { Self::Other(e) @@ -1174,8 +1143,8 @@ async fn try_create_invite_code_request( // there's nothing to gain from determinism. let nonce_bytes = generate_nonce()?; - let payload_value = build_request_payload(params, false)?; - let payload_json = serde_json::to_vec(&payload_value).map_err(Error::from)?; + let request_payload = build_request_payload(params, false)?; + let payload_json = serde_json::to_vec(&request_payload).map_err(Error::from)?; let encrypted = encrypt(&key_bytes, &nonce_bytes, &payload_json)?; let body = CreateRequestBody { @@ -1189,22 +1158,30 @@ async fn try_create_invite_code_request( let client = reqwest::Client::builder() .user_agent(format!("idkit-core/{}", env!("CARGO_PKG_VERSION"))) .build() - .map_err(|e| bridge_request_failed(format!("Bridge client build failed: {e}")))?; + .map_err(Error::from)?; let response = client .post(bridge_url.join("/request")?) .json(&body) .send() .await - .map_err(|e| bridge_request_failed(format!("Bridge request failed: {e}")))?; + .map_err(|e| Error::BridgeError(format!("Bridge request failed: {e}")))?; if response.status() == reqwest::StatusCode::CONFLICT { return Err(CreateCodeError::Conflict); } if !response.status().is_success() { - let status = response.status().as_u16(); + let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(Error::BridgeRequestFailed { status, body }.into()); + return Err(Error::BridgeError(format!( + "Bridge /request (code) failed with status {status}: {}", + if body.is_empty() { + "no error details" + } else { + &body + }, + )) + .into()); } // Validate that the bridge stored the request under the id we sent. @@ -1214,14 +1191,12 @@ async fn try_create_invite_code_request( // the World App side and we'd fail in a confusing way much later in the // poll loop. Catching the mismatch here surfaces the contract violation // at creation time. - let echoed_payload: serde_json::Value = response + let echoed: BridgeCreateResponse = response .json() .await - .map_err(|e| bridge_request_failed(format!("Failed to parse bridge response: {e}")))?; - let echoed: BridgeCreateResponse = serde_json::from_value(echoed_payload.clone()) - .map_err(|e| bridge_request_failed(format!("Failed to parse bridge response: {e}")))?; + .map_err(|e| Error::BridgeError(format!("Failed to parse bridge response: {e}")))?; if echoed.request_id != request_id { - return Err(bridge_request_failed(format!( + return Err(Error::BridgeError(format!( "Bridge echoed mismatched request_id (sent {request_id}, got {})", echoed.request_id )) @@ -1251,8 +1226,7 @@ async fn try_create_invite_code_request( app_id: params.app_id.as_str().to_string(), client, cached_signal_hashes, - debug_payload: payload_value, - debug_response_payload: Mutex::new(Some(serde_json::json!({ "create": echoed_payload }))), + request_payload, action, action_description: params.action_description.clone(), nonce: params.rp_context.nonce.clone(), @@ -1771,7 +1745,6 @@ fn to_app_error(error: &Error) -> AppError { match error { Error::InvalidConfiguration(_) => AppError::MalformedRequest, Error::BridgeError(_) => AppError::ConnectionFailed, - Error::BridgeRequestFailed { .. } => AppError::ConnectionFailed, Error::Json(_) => AppError::UnexpectedResponse, Error::Crypto(_) => AppError::UnexpectedResponse, Error::Base64(_) => AppError::UnexpectedResponse, @@ -3300,8 +3273,7 @@ mod tests { signal_hashes: std::collections::HashMap::new(), legacy_signal_hash: String::new(), }, - debug_payload: serde_json::json!({"app_id": "app_test"}), - debug_response_payload: Mutex::new(None), + request_payload: serde_json::json!({"app_id": "app_test"}), action: Some("test-action".to_string()), action_description: None, nonce: "0x01".to_string(), diff --git a/rust/core/src/error.rs b/rust/core/src/error.rs index 7d47505e..76f82dcf 100644 --- a/rust/core/src/error.rs +++ b/rust/core/src/error.rs @@ -16,10 +16,6 @@ pub enum Error { #[error("Bridge error: {0}")] BridgeError(String), - /// Bridge rejected the create request with an HTTP status - #[error("Bridge request failed with status {status}: {body}")] - BridgeRequestFailed { status: u16, body: String }, - /// JSON serialization/deserialization error #[error("JSON error: {0}")] Json(#[from] serde_json::Error), @@ -277,9 +273,6 @@ impl From for IdkitError { }, Error::InvalidProof(message) => Self::InvalidProof { details: message }, Error::BridgeError(message) => Self::BridgeError { details: message }, - Error::BridgeRequestFailed { status, body } => Self::BridgeError { - details: format!("Bridge request failed with status {status}: {body}"), - }, Error::AppError(app_err) => Self::AppError { details: app_err.to_string(), }, diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index 6c4efcd2..55c75abc 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -29,17 +29,6 @@ pub fn init_wasm() { }); } -fn bridge_create_error_to_js(error: crate::Error) -> JsValue { - let js_error = js_sys::Error::new(&format!("Failed: {error}")); - if let crate::Error::BridgeRequestFailed { status, body } = &error { - let payload = serde_json::json!({ "create": { "http_status": status, "body": body } }); - if let Ok(value) = json_to_js(&payload) { - let _ = js_sys::Reflect::set(&js_error, &"debugResponsePayload".into(), &value); - } - } - js_error.into() -} - /// WASM wrapper for `CredentialRequest` #[wasm_bindgen(js_name = CredentialRequestWasm)] pub struct CredentialRequestWasm(CredentialRequest); @@ -767,14 +756,6 @@ fn json_to_js(payload: T) -> Result { .map_err(|e| JsValue::from_str(&format!("Serialization failed: {e}"))) } -fn bridge_debug_payload_to_js( - params: &crate::bridge::BridgeConnectionParams, -) -> Result { - crate::bridge::build_request_payload(params, false) - .map_err(|e| JsValue::from_str(&format!("Failed to build bridge debug payload: {e}"))) - .and_then(json_to_js) -} - fn debug_payload_to_js( inner: &Rc>>, ) -> Result { @@ -782,18 +763,7 @@ fn debug_payload_to_js( .borrow() .as_ref() .ok_or_else(|| JsValue::from_str("Request closed")) - .and_then(|s| json_to_js(s.debug_payload())) -} - -fn debug_response_payload_to_js( - inner: &Rc>>, -) -> Result { - inner - .borrow() - .as_ref() - .ok_or_else(|| JsValue::from_str("Request closed"))? - .debug_response_payload() - .map_or(Ok(JsValue::UNDEFINED), json_to_js) + .and_then(|s| json_to_js(s.request_payload())) } /// Unified builder for creating `IDKit` requests and sessions (WASM) @@ -892,35 +862,6 @@ impl IDKitBuilderWasm { } } - /// Returns the bridge debug request payload for constraints. - /// - /// # Errors - /// Returns an error if input parsing or payload construction fails. - #[wasm_bindgen(js_name = bridgeDebugPayload)] - pub fn bridge_debug_payload(&self, constraints_json: JsValue) -> Result { - let constraints: ConstraintNode = serde_wasm_bindgen::from_value(constraints_json) - .map_err(|e| JsValue::from_str(&format!("Invalid constraints: {e}")))?; - - let params = self.config.to_params(Some(constraints))?; - bridge_debug_payload_to_js(¶ms) - } - - /// Returns the bridge debug request payload for a preset. - /// - /// # Errors - /// Returns an error if input parsing or payload construction fails. - #[wasm_bindgen(js_name = bridgeDebugPayloadFromPreset)] - pub fn bridge_debug_payload_from_preset( - &self, - preset_json: JsValue, - ) -> Result { - let preset: Preset = serde_wasm_bindgen::from_value(preset_json) - .map_err(|e| JsValue::from_str(&format!("Invalid preset: {e}")))?; - - let params = self.config.to_params_from_preset(preset)?; - bridge_debug_payload_to_js(¶ms) - } - /// Builds the native payload for constraints (synchronous, no bridge connection). /// /// Used by the native transport to get the same payload format as the bridge @@ -1090,7 +1031,7 @@ impl IDKitBuilderWasm { let params = config.to_params(Some(constraints))?; let connection = crate::bridge::BridgeConnection::create(params) .await - .map_err(bridge_create_error_to_js)?; + .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; Ok(JsValue::from(IDKitRequest { inner: Rc::new(RefCell::new(Some(connection))), @@ -1108,7 +1049,7 @@ impl IDKitBuilderWasm { let params = config.to_params_from_preset(preset)?; let connection = crate::bridge::BridgeConnection::create(params) .await - .map_err(bridge_create_error_to_js)?; + .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; Ok(JsValue::from(IDKitRequest { inner: Rc::new(RefCell::new(Some(connection))), @@ -1127,7 +1068,7 @@ impl IDKitBuilderWasm { let params = config.to_params(Some(constraints))?; let connection = crate::bridge::BridgeConnection::create_for_invite_code(params) .await - .map_err(bridge_create_error_to_js)?; + .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; Ok(JsValue::from(IDKitInviteCodeRequest { inner: Rc::new(RefCell::new(Some(connection))), @@ -1146,7 +1087,7 @@ impl IDKitBuilderWasm { let params = config.to_params_from_preset(preset)?; let connection = crate::bridge::BridgeConnection::create_for_invite_code(params) .await - .map_err(bridge_create_error_to_js)?; + .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; Ok(JsValue::from(IDKitInviteCodeRequest { inner: Rc::new(RefCell::new(Some(connection))), @@ -1286,7 +1227,7 @@ impl IDKitRequest { .map(|s| s.request_id().to_string()) } - /// Returns the pre-encryption payload for debug report generation. + /// Returns the pre-encryption request payload for debug report generation. /// /// # Errors /// @@ -1296,15 +1237,6 @@ impl IDKitRequest { debug_payload_to_js(&self.inner) } - /// Returns captured bridge response payloads. - /// - /// # Errors - /// Returns an error if the request has been closed. - #[wasm_bindgen(js_name = debugResponsePayload)] - pub fn debug_response_payload(&self) -> Result { - debug_response_payload_to_js(&self.inner) - } - /// Polls the bridge for the current status (non-blocking) /// /// Returns a status object with type: @@ -1398,7 +1330,7 @@ impl IDKitInviteCodeRequest { .map(|s| s.request_id().to_string()) } - /// Returns the pre-encryption payload for debug report generation. + /// Returns the pre-encryption request payload for debug report generation. /// /// # Errors /// @@ -1408,15 +1340,6 @@ impl IDKitInviteCodeRequest { debug_payload_to_js(&self.inner) } - /// Returns captured bridge response payloads. - /// - /// # Errors - /// Returns an error if the request has been closed. - #[wasm_bindgen(js_name = debugResponsePayload)] - pub fn debug_response_payload(&self) -> Result { - debug_response_payload_to_js(&self.inner) - } - /// Polls the bridge for the current status (non-blocking). /// /// Mirrors `IDKitRequest::pollForStatus` exactly — same status shape,