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 101ddc61..cee67c48 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -75,7 +75,12 @@ 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, + type IDKitDebugReport, + type IDKitDebugTransport, +} from "./lib/debug"; // Session utilities export { getSessionCommitment } from "./session"; diff --git a/js/packages/core/src/lib/debug.ts b/js/packages/core/src/lib/debug.ts index 13c04f35..9a93365e 100644 --- a/js/packages/core/src/lib/debug.ts +++ b/js/packages/core/src/lib/debug.ts @@ -1,5 +1,20 @@ +import packageJson from "../../package.json"; + let _debug = false; +export type IDKitDebugTransport = "bridge" | "mini_app"; + +export type IDKitDebugReport = { + package_version: string; + transport: IDKitDebugTransport; + timestamps: { generated_at: string }; + request_id?: string; + connector_uri?: string; + request_payload?: object; + response_payload?: object; + mini_app?: Record; +}; + export function isDebug(): boolean { if (_debug) return true; return typeof window !== "undefined" && Boolean((window as any).IDKIT_DEBUG); @@ -8,3 +23,51 @@ export function isDebug(): boolean { export function setDebug(enabled: boolean): void { _debug = enabled; } + +export function createIDKitDebugReport(options: { + transport: IDKitDebugTransport; + requestPayload?: unknown; + responsePayload?: unknown; + requestId?: string; + connectorURI?: string; +}): IDKitDebugReport | undefined { + if (!isDebug()) return undefined; + + const now = new Date().toISOString(); + const report: IDKitDebugReport = { + package_version: packageJson.version, + transport: options.transport, + timestamps: { generated_at: now }, + }; + + if (options.transport === "mini_app") { + const miniApp = getMiniAppDebugInfo(); + if (miniApp) report.mini_app = miniApp; + } + + if (options.requestPayload && typeof options.requestPayload === "object") { + report.request_payload = options.requestPayload; + } + if (options.responsePayload && typeof options.responsePayload === "object") { + report.response_payload = options.responsePayload; + } + if (options.requestId) report.request_id = options.requestId; + if (options.connectorURI) report.connector_uri = options.connectorURI; + return report; +} + +function getMiniAppDebugInfo(): Record | undefined { + const worldApp = + typeof window !== "undefined" ? (window as any).WorldApp : undefined; + if (!worldApp || typeof worldApp !== "object") return undefined; + + const miniApp: Record = {}; + if (worldApp.world_app_version !== undefined) { + miniApp.world_app_version = worldApp.world_app_version; + } + if (worldApp.device_os !== undefined) { + miniApp.platform = worldApp.device_os; + } + + 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 d5c3a71a..790b2269 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -23,6 +23,11 @@ import { createNativeRequest, type BuilderConfig, } from "./transports/native"; +import { + createIDKitDebugReport, + isDebug, + type IDKitDebugReport, +} from "./lib/debug"; /** Options for pollUntilCompletion() */ export interface WaitOptions { @@ -70,6 +75,8 @@ export interface IDKitRequest { pollOnce(): Promise; /** Poll continuously until completion or timeout */ pollUntilCompletion(options?: WaitOptions): Promise; + /** Debug report for this request when debug mode is enabled. May include sensitive data. */ + getDebugReport(): IDKitDebugReport | undefined; } /** @@ -112,26 +119,51 @@ async function pollUntilCompletionLoop( } } +type BridgeDebugPayloadSource = { + debugPayload?: () => unknown; +}; + +function readBridgeDebugPayload( + wasmRequest: BridgeDebugPayloadSource, +): unknown { + if (typeof wasmRequest.debugPayload !== "function") { + return undefined; + } + + try { + return wasmRequest.debugPayload(); + } catch { + return undefined; + } +} + +function createBridgeDebugReport( + wasmRequest: BridgeDebugPayloadSource, + requestId: string, + connectorURI: string, +): IDKitDebugReport | undefined { + if (!isDebug()) return undefined; + + return createIDKitDebugReport({ + transport: "bridge", + requestPayload: readBridgeDebugPayload(wasmRequest), + requestId, + connectorURI, + }); +} + /** * Internal request implementation (bridge/WASM path) */ class IDKitRequestImpl implements IDKitRequest { private wasmRequest: WasmModule.IDKitRequest; - private _connectorURI: string; - private _requestId: string; + readonly connectorURI: string; + readonly requestId: string; constructor(wasmRequest: WasmModule.IDKitRequest) { this.wasmRequest = wasmRequest; - this._connectorURI = wasmRequest.connectUrl(); - this._requestId = wasmRequest.requestId(); - } - - get connectorURI(): string { - return this._connectorURI; - } - - get requestId(): string { - return this._requestId; + this.connectorURI = wasmRequest.connectUrl(); + this.requestId = wasmRequest.requestId(); } async pollOnce(): Promise { @@ -141,6 +173,14 @@ class IDKitRequestImpl implements IDKitRequest { pollUntilCompletion(options?: WaitOptions): Promise { return pollUntilCompletionLoop(() => this.pollOnce(), options); } + + getDebugReport(): IDKitDebugReport | undefined { + return createBridgeDebugReport( + this.wasmRequest, + this.requestId, + this.connectorURI, + ); + } } /** @@ -163,6 +203,8 @@ export interface IDKitInviteCodeRequest { pollOnce(): Promise; /** Poll continuously until completion or timeout */ pollUntilCompletion(options?: WaitOptions): Promise; + /** Debug report for this request when debug mode is enabled. May include sensitive data. */ + getDebugReport(): IDKitDebugReport | undefined; } /** @@ -172,27 +214,15 @@ export interface IDKitInviteCodeRequest { */ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { private wasmRequest: WasmModule.IDKitInviteCodeRequest; - private _connectorURI: string; - private _expiresAt: number; - private _requestId: string; + 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(); - } - - 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 { @@ -202,6 +232,14 @@ class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { pollUntilCompletion(options?: WaitOptions): Promise { return pollUntilCompletionLoop(() => this.pollOnce(), options); } + + getDebugReport(): IDKitDebugReport | undefined { + return createBridgeDebugReport( + this.wasmRequest, + this.requestId, + this.connectorURI, + ); + } } // ───────────────────────────────────────────────────────────────────────────── 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 ab66a80a..63844156 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -25,7 +25,11 @@ 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 { + createIDKitDebugReport, + isDebug, + type IDKitDebugReport, +} from "../lib/debug"; const MINIAPP_VERIFY_ACTION = "miniapp-verify-action"; @@ -141,9 +145,10 @@ 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 responsePayload?: unknown; constructor( - wasmPayload: unknown, + private readonly wasmPayload: unknown, config: BuilderConfig, signalHashes: Record = {}, legacySignalHash: string, @@ -159,6 +164,8 @@ class NativeIDKitRequest implements IDKitRequest { const handleIncomingPayload = (responsePayload: any) => { if (this.completionResult) return; + this.responsePayload = responsePayload; + if (isDebug()) console.debug("[IDKit] Native: received response", responsePayload); @@ -297,6 +304,15 @@ class NativeIDKitRequest implements IDKitRequest { } } + getDebugReport(): IDKitDebugReport | undefined { + return createIDKitDebugReport({ + transport: "mini_app", + requestPayload: this.wasmPayload, + responsePayload: this.responsePayload, + requestId: this.requestId, + }); + } + 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..9680dd37 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -508,6 +508,9 @@ 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. + #[allow(dead_code)] + request_payload: serde_json::Value, /// Action identifier (only for uniqueness proofs) action: Option, /// Action description (only if provided in input) @@ -705,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); @@ -749,7 +752,7 @@ impl BridgeConnection { "no error details" } else { &body - } + }, ))); } @@ -772,6 +775,7 @@ impl BridgeConnection { app_id, client, cached_signal_hashes, + request_payload, action, action_description: params.action_description, nonce: params.rp_context.nonce.clone(), @@ -1062,6 +1066,18 @@ impl BridgeConnection { &self.request_id } + /// Returns the pre-encryption request payload for SDK debug reporting. + /// + /// 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(crate) fn request_payload(&self) -> &serde_json::Value { + &self.request_payload + } + /// Unix-seconds expiry of the unredeemed code, if this connection was /// created in invite-code mode. #[must_use] @@ -1127,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 { @@ -1163,7 +1179,7 @@ async fn try_create_invite_code_request( "no error details" } else { &body - } + }, )) .into()); } @@ -1210,6 +1226,7 @@ async fn try_create_invite_code_request( app_id: params.app_id.as_str().to_string(), client, cached_signal_hashes, + request_payload, action, action_description: params.action_description.clone(), nonce: params.rp_context.nonce.clone(), @@ -3256,6 +3273,7 @@ mod tests { signal_hashes: std::collections::HashMap::new(), legacy_signal_hash: String::new(), }, + 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/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index 76ce37cd..55c75abc 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -749,6 +749,23 @@ 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 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.request_payload())) +} + /// Unified builder for creating `IDKit` requests and sessions (WASM) #[wasm_bindgen(js_name = IDKitBuilder)] pub struct IDKitBuilderWasm { @@ -1210,6 +1227,16 @@ impl IDKitRequest { .map(|s| s.request_id().to_string()) } + /// Returns the pre-encryption request payload for 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 { + debug_payload_to_js(&self.inner) + } + /// Polls the bridge for the current status (non-blocking) /// /// Returns a status object with type: @@ -1303,6 +1330,16 @@ impl IDKitInviteCodeRequest { .map(|s| s.request_id().to_string()) } + /// Returns the pre-encryption request payload for 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 { + debug_payload_to_js(&self.inner) + } + /// Polls the bridge for the current status (non-blocking). /// /// Mirrors `IDKitRequest::pollForStatus` exactly — same status shape,