From 521e3acaed9ea3570c7810184a4e9e6c0580d327 Mon Sep 17 00:00:00 2001 From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:35:49 -0700 Subject: [PATCH 1/5] feat(core): enforce user presence bridge contract --- js/packages/core/src/__tests__/smoke.test.ts | 1 + .../core/src/transports/native.test.ts | 47 ++++ js/packages/core/src/transports/native.ts | 17 ++ js/packages/core/src/types/index.ts | 1 - js/packages/core/src/types/result.ts | 1 + .../react/src/__tests__/hooks.test.tsx | 12 +- .../react/src/__tests__/widgets.test.tsx | 4 +- .../main/kotlin/com/worldcoin/idkit/IdKit.kt | 2 + .../kotlin/com/worldcoin/idkit/IDKitTests.kt | 10 +- rust/core/src/bridge.rs | 232 +++++++++++++++++- rust/core/src/error.rs | 4 + rust/core/src/types.rs | 8 + rust/core/src/wasm_bindings.rs | 11 + swift/Sources/IDKit/IDKit.swift | 3 + swift/Tests/IDKitTests/IDKitTests.swift | 4 +- 15 files changed, 335 insertions(+), 22 deletions(-) diff --git a/js/packages/core/src/__tests__/smoke.test.ts b/js/packages/core/src/__tests__/smoke.test.ts index 26aef3dc..b8614baa 100644 --- a/js/packages/core/src/__tests__/smoke.test.ts +++ b/js/packages/core/src/__tests__/smoke.test.ts @@ -196,6 +196,7 @@ describe("Enums", () => { expect(IDKitErrorCodes.CredentialUnavailable).toBe( "credential_unavailable", ); + expect(IDKitErrorCodes.UserPresenceFailed).toBe("user_presence_failed"); expect(IDKitErrorCodes.Timeout).toBe("timeout"); expect(IDKitErrorCodes.Cancelled).toBe("cancelled"); }); diff --git a/js/packages/core/src/transports/native.test.ts b/js/packages/core/src/transports/native.test.ts index 0fa1e8b6..a390cc24 100644 --- a/js/packages/core/src/transports/native.test.ts +++ b/js/packages/core/src/transports/native.test.ts @@ -106,6 +106,53 @@ describe("native transport request lifecycle", () => { expect(completion.success).toBe(true); }); + it("defaults missing user_presence_completed to false on success", async () => { + const req = createNativeRequest({ payload: 1 }, baseConfig, {}, ""); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + protocol_version: "3.0", + verification_level: "orb", + signal_hash: "0xabc", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }); + + const completion = await completionPromise; + expect(completion.success).toBe(true); + if (completion.success) { + expect(completion.result.user_presence_completed).toBe(false); + } + }); + + it("preserves completed user presence on success", async () => { + const req = createNativeRequest({ payload: 1 }, baseConfig, {}, ""); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + user_presence_completed: true, + protocol_version: "3.0", + verification_level: "orb", + signal_hash: "0xabc", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }); + + const completion = await completionPromise; + expect(completion.success).toBe(true); + if (completion.success) { + expect(completion.result.user_presence_completed).toBe(true); + } + }); + it("uses per-identifier signal hashes when response omits signal_hash", async () => { const signalHashes = { proof_of_human: hashSignal("poh-signal"), diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index 4d56b003..fcee9fb8 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -167,6 +167,8 @@ class NativeIDKitRequest implements IDKitRequest { return; } + const userPresenceCompleted = getUserPresenceCompleted(responsePayload); + this.complete({ success: true, result: nativeResultToIDKitResult( @@ -174,6 +176,7 @@ class NativeIDKitRequest implements IDKitRequest { config, signalHashes, legacySignalHash, + userPresenceCompleted, ), }); }; @@ -349,6 +352,7 @@ function nativeResultToIDKitResult( config: BuilderConfig, signalHashes: Record, legacySignalHash: string, + userPresenceCompleted: boolean, ): IDKitResult { const p = payload as Record; const rpNonce = config.rp_context?.nonce ?? ""; @@ -372,6 +376,7 @@ function nativeResultToIDKitResult( issuer_schema_id: item.issuer_schema_id, expires_at_min: item.expires_at_min, })), + user_presence_completed: userPresenceCompleted, environment: config.environment ?? "production", } satisfies IDKitResultSession; } @@ -389,6 +394,7 @@ function nativeResultToIDKitResult( issuer_schema_id: item.issuer_schema_id, expires_at_min: item.expires_at_min, })), + user_presence_completed: userPresenceCompleted, environment: config.environment ?? "production", } satisfies IDKitResultV4; } @@ -414,6 +420,7 @@ function nativeResultToIDKitResult( merkle_root: v.merkle_root, nullifier: v.nullifier_hash, })), + user_presence_completed: userPresenceCompleted, environment: config.environment ?? "production", } satisfies IDKitResultV3; } @@ -435,6 +442,16 @@ function nativeResultToIDKitResult( nullifier: p.nullifier_hash, }, ], + user_presence_completed: userPresenceCompleted, environment: config.environment ?? "production", } satisfies IDKitResultV3; } + +function getUserPresenceCompleted(payload: unknown): boolean { + const p = payload as Record; + return ( + p?.user_presence_completed === true || + (p?.proof_response as Record | undefined) + ?.user_presence_completed === true + ); +} diff --git a/js/packages/core/src/types/index.ts b/js/packages/core/src/types/index.ts index 5a543a1e..287f5d08 100644 --- a/js/packages/core/src/types/index.ts +++ b/js/packages/core/src/types/index.ts @@ -1,3 +1,2 @@ -export * from "./bridge"; export * from "./config"; export * from "./result"; diff --git a/js/packages/core/src/types/result.ts b/js/packages/core/src/types/result.ts index cb6aec21..e6b35a09 100644 --- a/js/packages/core/src/types/result.ts +++ b/js/packages/core/src/types/result.ts @@ -40,6 +40,7 @@ export enum IDKitErrorCodes { ConnectionFailed = "connection_failed", MaxVerificationsReached = "max_verifications_reached", FailedByHostApp = "failed_by_host_app", + UserPresenceFailed = "user_presence_failed", GenericError = "generic_error", // Client-side errors Timeout = "timeout", diff --git a/js/packages/react/src/__tests__/hooks.test.tsx b/js/packages/react/src/__tests__/hooks.test.tsx index 7c002a37..381cd24e 100644 --- a/js/packages/react/src/__tests__/hooks.test.tsx +++ b/js/packages/react/src/__tests__/hooks.test.tsx @@ -152,7 +152,7 @@ describe("request/session hooks", () => { useIDKitSession({ app_id: "app_test", rp_context: baseRpContext, - constraints: { type: "All", children: [] }, + constraints: { all: [] }, }), ); @@ -194,7 +194,7 @@ describe("request/session hooks", () => { app_id: "app_test", rp_context: baseRpContext, existing_session_id: SESSION_ID_2, - preset: { type: "OrbLegacy" }, + constraints: { all: [] }, }), ); @@ -276,7 +276,7 @@ describe("request/session hooks", () => { app_id: "app_test", rp_context: baseRpContext, return_to: "idkit://callback?step=create", - constraints: { type: "All", children: [] }, + constraints: { all: [] }, }), ); @@ -317,7 +317,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, existing_session_id: validSessionId, return_to: "idkit://callback?step=prove", - constraints: { type: "All", children: [] }, + constraints: { all: [] }, }), ); @@ -346,7 +346,7 @@ describe("request/session hooks", () => { app_id: "app_test", rp_context: baseRpContext, existing_session_id: " " as unknown as `session_${string}`, - constraints: { type: "All", children: [] }, + constraints: { all: [] }, }), ); @@ -367,7 +367,7 @@ describe("request/session hooks", () => { app_id: "app_test", rp_context: baseRpContext, existing_session_id: "session_2" as `session_${string}`, - constraints: { type: "All", children: [] }, + constraints: { all: [] }, }), ); diff --git a/js/packages/react/src/__tests__/widgets.test.tsx b/js/packages/react/src/__tests__/widgets.test.tsx index 522b573c..8652e3ab 100644 --- a/js/packages/react/src/__tests__/widgets.test.tsx +++ b/js/packages/react/src/__tests__/widgets.test.tsx @@ -76,7 +76,7 @@ function createRequestProps( allow_legacy_proofs: false, preset: { type: "OrbLegacy" }, ...overrides, - }; + } as IDKitRequestWidgetProps; } function createSessionProps( @@ -88,7 +88,7 @@ function createSessionProps( onSuccess: vi.fn(), app_id: "app_test", rp_context: baseRpContext, - preset: { type: "OrbLegacy" }, + constraints: { all: [] }, ...overrides, }; } diff --git a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt b/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt index 1c94943e..24e68a3b 100644 --- a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt +++ b/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt @@ -52,6 +52,7 @@ enum class IDKitErrorCode(val rawValue: String) { CONNECTION_FAILED("connection_failed"), MAX_VERIFICATIONS_REACHED("max_verifications_reached"), FAILED_BY_HOST_APP("failed_by_host_app"), + USER_PRESENCE_FAILED("user_presence_failed"), GENERIC_ERROR("generic_error"), TIMEOUT("timeout"), CANCELLED("cancelled"); @@ -69,6 +70,7 @@ enum class IDKitErrorCode(val rawValue: String) { AppError.CONNECTION_FAILED -> CONNECTION_FAILED AppError.MAX_VERIFICATIONS_REACHED -> MAX_VERIFICATIONS_REACHED AppError.FAILED_BY_HOST_APP -> FAILED_BY_HOST_APP + AppError.USER_PRESENCE_FAILED -> USER_PRESENCE_FAILED AppError.GENERIC_ERROR -> GENERIC_ERROR } } diff --git a/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt b/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt index 17ee9974..661f6610 100644 --- a/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt +++ b/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt @@ -16,7 +16,10 @@ import uniffi.idkit_core.RpContext import uniffi.idkit_core.StatusWrapper class IDKitTests { - private fun sampleResult(sessionId: String? = null): IDKitResult = + private fun sampleResult( + sessionId: String? = null, + userPresenceCompleted: Boolean = false, + ): IDKitResult = IDKitResult( protocolVersion = "4.0", nonce = "0x1234", @@ -24,6 +27,7 @@ class IDKitTests { actionDescription = "Sample action", sessionId = sessionId, responses = emptyList(), + userPresenceCompleted = userPresenceCompleted, environment = "production", ) @@ -89,6 +93,10 @@ class IDKitTests { IDKitStatus.Failed(IDKitErrorCode.INVALID_NETWORK), IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.INVALID_NETWORK)), ) + assertEquals( + IDKitStatus.Failed(IDKitErrorCode.USER_PRESENCE_FAILED), + IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.USER_PRESENCE_FAILED)), + ) assertEquals( IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED), IDKitRequest.mapStatus(StatusWrapper.NetworkingError(AppError.CONNECTION_FAILED)), diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 460306d5..7bb65249 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -12,7 +12,10 @@ use crate::{ }; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use world_id_primitives::{FieldElement, ProofRequest, SessionId}; +use world_id_primitives::{ + FieldElement, ProofRequest, ProofResponse, RequestVersion, + ResponseItem as ProtocolResponseItem, SessionId, +}; #[cfg(feature = "native-crypto")] use crate::crypto::CryptoKey; @@ -116,6 +119,9 @@ struct BridgeRequestPayload { /// - `false`: Only accept v4 proofs. Use after migration cutoff or for new apps. allow_legacy_proofs: bool, + /// Whether World App should require a user-presence check before verification. + require_user_presence: bool, + /// Environment for the bridge request environment: Environment, } @@ -219,15 +225,68 @@ enum BridgeResponse { Error { error_code: AppError }, /// World ID 4.0 protocol response - ResponseV2(world_id_primitives::ProofResponse), + ResponseV2(BridgeResponseV2), /// Multi-credential legacy: bridge sends v3 proofs via `legacy_responses` MultiLegacyResponse { legacy_responses: Vec, + #[serde(default)] + user_presence_completed: bool, }, /// V1 legacy (old World App 3.0, single credential) - ResponseV1(BridgeResponseV1), + ResponseV1(BridgeResponseV1WithMetadata), +} + +#[derive(Debug, Deserialize)] +struct BridgeResponseV2 { + /// The response id references request id + id: String, + /// Version corresponding to request version + version: RequestVersion, + /// Optional session identifier for session proofs + #[serde(default)] + session_id: Option, + /// Protocol-level proof response error + #[serde(default)] + error: Option, + /// Per-credential proof responses + responses: Vec, + /// Whether World App completed the requested user-presence check. + #[serde(default)] + user_presence_completed: bool, +} + +impl BridgeResponseV2 { + fn into_proof_response(self) -> ProofResponse { + ProofResponse { + id: self.id, + version: self.version, + session_id: self.session_id, + error: self.error, + responses: self.responses, + } + } +} + +#[derive(Debug, Deserialize)] +struct BridgeResponseV1WithMetadata { + #[serde(flatten)] + response: BridgeResponseV1, + /// Whether World App completed the requested user-presence check. + #[serde(default)] + user_presence_completed: bool, +} + +fn user_presence_failure_status( + require_user_presence: bool, + user_presence_completed: bool, +) -> Option { + if require_user_presence && !user_presence_completed { + Some(Status::Failed(AppError::UserPresenceFailed)) + } else { + None + } } /// Status of a verification request @@ -259,6 +318,7 @@ pub struct BridgeConnectionParams { pub legacy_signal: String, pub bridge_url: Option, pub allow_legacy_proofs: bool, + pub require_user_presence: bool, /// Optional override for the connect base URL (e.g., for staging environments) pub override_connect_base_url: Option, /// Optional deep-link callback URL appended as `return_to` on the connector URL @@ -346,6 +406,8 @@ pub struct BridgeConnection { return_to: Option, /// Resolved environment for this connection environment: Environment, + /// Whether a successful response must prove user presence was completed. + require_user_presence: bool, } /// Builds a `BridgeRequestPayload` from params without connecting to the bridge. @@ -444,6 +506,7 @@ pub fn build_request_payload( signal: legacy_signal_hash, timestamp, allow_legacy_proofs: params.allow_legacy_proofs, + require_user_presence: params.require_user_presence, environment: params.environment.unwrap_or_default(), }; @@ -588,6 +651,7 @@ impl BridgeConnection { override_connect_base_url: params.override_connect_base_url, return_to: params.return_to, environment: params.environment.unwrap_or_default(), + require_user_presence: params.require_user_presence, }) } @@ -666,7 +730,17 @@ impl BridgeConnection { match bridge_response { BridgeResponse::Error { error_code } => Ok(Status::Failed(error_code)), - BridgeResponse::ResponseV2(proof_response) => { + BridgeResponse::ResponseV2(response) => { + let user_presence_completed = response.user_presence_completed; + if let Some(status) = user_presence_failure_status( + self.require_user_presence, + user_presence_completed, + ) { + return Ok(status); + } + + let proof_response = response.into_proof_response(); + // Check for protocol-level error if proof_response.error.is_some() { return Ok(Status::Failed(AppError::GenericError)); @@ -696,6 +770,7 @@ impl BridgeConnection { .to_owned(), self.action_description.clone(), responses, + user_presence_completed, self.environment.as_ref(), ) } else { @@ -705,12 +780,23 @@ impl BridgeConnection { self.action.clone(), self.action_description.clone(), responses, + user_presence_completed, self.environment.as_ref(), ) }, )) } - BridgeResponse::MultiLegacyResponse { legacy_responses } => { + BridgeResponse::MultiLegacyResponse { + legacy_responses, + user_presence_completed, + } => { + if let Some(status) = user_presence_failure_status( + self.require_user_presence, + user_presence_completed, + ) { + return Ok(status); + } + let responses: Vec = legacy_responses .into_iter() .map(|item| { @@ -729,20 +815,30 @@ impl BridgeConnection { self.action.clone(), self.action_description.clone(), responses, + user_presence_completed, self.environment.as_ref(), ))) } BridgeResponse::ResponseV1(response) => { + if let Some(status) = user_presence_failure_status( + self.require_user_presence, + response.user_presence_completed, + ) { + return Ok(status); + } + // V1 responses are always protocol 3.0 // For V1 we don't have identifier, use verification_level as key let signal_hash = self.cached_signal_hashes.legacy(); - let item = response.into_response_item(signal_hash); + let user_presence_completed = response.user_presence_completed; + let item = response.response.into_response_item(signal_hash); Ok(Status::Confirmed(IDKitResult::new( "3.0", self.nonce.clone(), self.action.clone(), self.action_description.clone(), vec![item], + user_presence_completed, self.environment.as_ref(), ))) } @@ -866,6 +962,7 @@ impl IDKitConfig { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: config.allow_legacy_proofs, + require_user_presence: false, override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -890,6 +987,7 @@ impl IDKitConfig { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -916,6 +1014,7 @@ impl IDKitConfig { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -960,6 +1059,7 @@ impl IDKitConfig { legacy_signal: legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: config.allow_legacy_proofs, + require_user_presence: false, override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -983,6 +1083,7 @@ impl IDKitConfig { legacy_signal: legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1008,6 +1109,7 @@ impl IDKitConfig { legacy_signal: legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1319,6 +1421,7 @@ mod tests { timestamp: None, proof_request: Some(proof_request), allow_legacy_proofs: false, + require_user_presence: false, environment: Environment::Production, }; @@ -1329,6 +1432,7 @@ mod tests { assert!(json.contains("proof_request")); assert!(json.contains("rp_1234567890abcdef")); assert!(json.contains("allow_legacy_proofs")); + assert!(json.contains("require_user_presence")); } #[test] @@ -1442,10 +1546,10 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { BridgeResponse::ResponseV1(v1) => { - assert_eq!(v1.verification_level, VerificationLevel::Device); - assert_eq!(v1.proof, "0xproof"); - assert_eq!(v1.merkle_root, "0xroot"); - assert_eq!(v1.nullifier_hash, "0xnull"); + assert_eq!(v1.response.verification_level, VerificationLevel::Device); + assert_eq!(v1.response.proof, "0xproof"); + assert_eq!(v1.response.merkle_root, "0xroot"); + assert_eq!(v1.response.nullifier_hash, "0xnull"); } other => panic!("Expected ResponseV1, got: {other:?}"), } @@ -1465,12 +1569,57 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { BridgeResponse::ResponseV1(v1) => { - assert_eq!(v1.verification_level, VerificationLevel::Orb); + assert_eq!(v1.response.verification_level, VerificationLevel::Orb); } other => panic!("Expected ResponseV1, got: {other:?}"), } } + #[test] + fn test_bridge_response_v2_user_presence_defaults_false() { + let json = r#"{ + "id": "request-id", + "version": 1, + "responses": [] + }"#; + + let response: BridgeResponse = serde_json::from_str(json).unwrap(); + match response { + BridgeResponse::ResponseV2(v2) => { + assert!(!v2.user_presence_completed); + } + other => panic!("Expected ResponseV2, got: {other:?}"), + } + } + + #[test] + fn test_bridge_response_v2_user_presence_explicit_true() { + let json = r#"{ + "id": "request-id", + "version": 1, + "responses": [], + "user_presence_completed": true + }"#; + + let response: BridgeResponse = serde_json::from_str(json).unwrap(); + match response { + BridgeResponse::ResponseV2(v2) => { + assert!(v2.user_presence_completed); + } + other => panic!("Expected ResponseV2, got: {other:?}"), + } + } + + #[test] + fn test_required_user_presence_fails_when_not_completed() { + assert_eq!( + user_presence_failure_status(true, false), + Some(Status::Failed(AppError::UserPresenceFailed)) + ); + assert_eq!(user_presence_failure_status(true, true), None); + assert_eq!(user_presence_failure_status(false, false), None); + } + #[test] fn test_bridge_response_error_deserialization() { let json = r#"{"error_code": "user_rejected"}"#; @@ -1507,6 +1656,7 @@ mod tests { legacy_signal: legacy_signal.unwrap_or_default(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, @@ -1545,6 +1695,7 @@ mod tests { legacy_signal: legacy_signal.unwrap_or_default(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, @@ -1607,6 +1758,7 @@ mod tests { legacy_signal: "test-signal".to_string(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, @@ -1652,6 +1804,7 @@ mod tests { legacy_signal: "test-signal".to_string(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, environment: None, @@ -1660,10 +1813,65 @@ mod tests { // native=true includes timestamp let payload = build_request_payload(¶ms, true).unwrap(); assert_eq!(payload["timestamp"], "2023-11-14T22:13:20Z"); + assert_eq!(payload["require_user_presence"], serde_json::json!(false)); // native=false omits timestamp let payload_bridge = build_request_payload(¶ms, false).unwrap(); assert!(payload_bridge.get("timestamp").is_none()); + assert_eq!( + payload_bridge["require_user_presence"], + serde_json::json!(false) + ); + } + + #[test] + fn test_build_request_payload_serializes_user_presence_requirement() { + let app_id = AppId::new("app_test").unwrap(); + let sig = "0x".to_string() + &"00".repeat(64) + "1b"; + let rp_context = RpContext::new( + "rp_1234567890abcdef", + "0x0000000000000000000000000000000000000000000000000000000000000001", + 1_700_000_000, + 1_700_003_600, + &sig, + ) + .unwrap(); + + let item = CredentialRequest::new( + CredentialType::ProofOfHuman, + Some(Signal::from_string("test")), + ); + let constraints = ConstraintNode::item(item); + + let params = BridgeConnectionParams { + app_id, + kind: RequestKind::Uniqueness { + action: "my-action".to_string(), + }, + constraints: Some(constraints), + rp_context, + action_description: None, + legacy_verification_level: VerificationLevel::Orb, + legacy_signal: "test-signal".to_string(), + bridge_url: None, + allow_legacy_proofs: false, + require_user_presence: true, + override_connect_base_url: None, + return_to: None, + environment: None, + }; + + let bridge_payload = build_request_payload(¶ms, false).unwrap(); + assert_eq!( + bridge_payload["require_user_presence"], + serde_json::json!(true) + ); + + let native_payload = build_request_payload(¶ms, true).unwrap(); + assert_eq!( + native_payload["require_user_presence"], + serde_json::json!(true) + ); } #[test] @@ -1697,6 +1905,7 @@ mod tests { legacy_signal: address.to_string(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, @@ -1734,6 +1943,7 @@ mod tests { override_connect_base_url: None, return_to, environment: Environment::Production, + require_user_presence: false, } } diff --git a/rust/core/src/error.rs b/rust/core/src/error.rs index d974dcd8..02a63faf 100644 --- a/rust/core/src/error.rs +++ b/rust/core/src/error.rs @@ -108,6 +108,10 @@ pub enum AppError { #[error("Verification failed by host app")] FailedByHostApp, + /// User presence check failed or was not completed + #[error("User presence check failed")] + UserPresenceFailed, + /// Generic error #[error("An error occurred")] #[serde(other)] diff --git a/rust/core/src/types.rs b/rust/core/src/types.rs index 3809fd2e..33fa5693 100644 --- a/rust/core/src/types.rs +++ b/rust/core/src/types.rs @@ -592,6 +592,9 @@ pub struct IDKitResult { /// Array of credential responses (always successful - errors at `BridgeResponse` level) pub responses: Vec, + /// Whether World App completed the requested user-presence check. + pub user_presence_completed: bool, + /// The environment used for this request ("production" or "staging") pub environment: String, } @@ -605,6 +608,7 @@ impl IDKitResult { action: Option, action_description: Option, responses: Vec, + user_presence_completed: bool, environment: impl Into, ) -> Self { Self { @@ -614,6 +618,7 @@ impl IDKitResult { action_description, session_id: None, responses, + user_presence_completed, environment: environment.into(), } } @@ -626,6 +631,7 @@ impl IDKitResult { session_id: String, action_description: Option, responses: Vec, + user_presence_completed: bool, environment: impl Into, ) -> Self { Self { @@ -635,6 +641,7 @@ impl IDKitResult { action_description, session_id: Some(session_id), responses, + user_presence_completed, environment: environment.into(), } } @@ -1227,6 +1234,7 @@ mod tests { None, None, responses, + false, "production", ); assert_eq!(result.protocol_version, "3.0"); diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index d7998748..73058f67 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -542,6 +542,7 @@ impl IDKitConfigWasm { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: *allow_legacy_proofs, + require_user_presence: false, override_connect_base_url: override_connect_base_url.clone(), return_to: return_to.clone(), @@ -579,6 +580,7 @@ impl IDKitConfigWasm { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: override_connect_base_url.clone(), return_to: return_to.clone(), @@ -619,6 +621,7 @@ impl IDKitConfigWasm { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: override_connect_base_url.clone(), return_to: return_to.clone(), @@ -967,6 +970,7 @@ pub fn request( /// `session_`. #[must_use] #[wasm_bindgen(js_name = createSession)] +#[allow(clippy::too_many_arguments)] pub fn create_session( app_id: String, rp_context: RpContextWasm, @@ -1203,6 +1207,8 @@ export interface IDKitResultV3 { action_description?: string; /** Array of V3 credential responses */ responses: ResponseItemV3[]; + /** Whether World App completed the requested user-presence check. */ + user_presence_completed: boolean; /** The environment used for this request ("production" or "staging") */ environment: string; } @@ -1219,6 +1225,8 @@ export interface IDKitResultV4 { action_description?: string; /** Array of V4 credential responses */ responses: ResponseItemV4[]; + /** Whether World App completed the requested user-presence check. */ + user_presence_completed: boolean; /** The environment used for this request ("production" or "staging") */ environment: string; } @@ -1235,6 +1243,8 @@ export interface IDKitResultSession { session_id: `session_${string}`; /** Array of session credential responses */ responses: ResponseItemSession[]; + /** Whether World App completed the requested user-presence check. */ + user_presence_completed: boolean; /** The environment used for this request ("production" or "staging") */ environment: string; } @@ -1288,6 +1298,7 @@ export type IDKitErrorCode = | "connection_failed" | "max_verifications_reached" | "failed_by_host_app" + | "user_presence_failed" | "generic_error"; /** Status returned from pollForStatus() */ diff --git a/swift/Sources/IDKit/IDKit.swift b/swift/Sources/IDKit/IDKit.swift index 5bc68316..140d0a27 100644 --- a/swift/Sources/IDKit/IDKit.swift +++ b/swift/Sources/IDKit/IDKit.swift @@ -97,6 +97,7 @@ public enum IDKitErrorCode: String, Equatable { case connectionFailed = "connection_failed" case maxVerificationsReached = "max_verifications_reached" case failedByHostApp = "failed_by_host_app" + case userPresenceFailed = "user_presence_failed" case genericError = "generic_error" case timeout = "timeout" case cancelled = "cancelled" @@ -125,6 +126,8 @@ public enum IDKitErrorCode: String, Equatable { .maxVerificationsReached case .failedByHostApp: .failedByHostApp + case .userPresenceFailed: + .userPresenceFailed case .genericError: .genericError } diff --git a/swift/Tests/IDKitTests/IDKitTests.swift b/swift/Tests/IDKitTests/IDKitTests.swift index e094d48c..bf659e46 100644 --- a/swift/Tests/IDKitTests/IDKitTests.swift +++ b/swift/Tests/IDKitTests/IDKitTests.swift @@ -17,7 +17,7 @@ private actor StatusPoller { } } -private func sampleResult(sessionId: String? = nil) -> IDKitResult { +private func sampleResult(sessionId: String? = nil, userPresenceCompleted: Bool = false) -> IDKitResult { IDKitResult( protocolVersion: "4.0", nonce: "0x1234", @@ -25,6 +25,7 @@ private func sampleResult(sessionId: String? = nil) -> IDKitResult { actionDescription: "Sample action", sessionId: sessionId, responses: [], + userPresenceCompleted: userPresenceCompleted, environment: "production" ) } @@ -82,6 +83,7 @@ func statusMapping() { #expect(IDKitRequest.mapStatus(.awaitingConfirmation) == .awaitingConfirmation) #expect(IDKitRequest.mapStatus(.confirmed(result: result)) == .confirmed(result)) #expect(IDKitRequest.mapStatus(.failed(error: .invalidNetwork)) == .failed(.invalidNetwork)) + #expect(IDKitRequest.mapStatus(.failed(error: .userPresenceFailed)) == .failed(.userPresenceFailed)) #expect(IDKitRequest.mapStatus(.networkingError(error: .connectionFailed)) == .networkingError(.connectionFailed)) } From af468d6fba9486be8ac9b2aa3bfb4bc552a9a89b Mon Sep 17 00:00:00 2001 From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:36:58 -0700 Subject: [PATCH 2/5] feat(sdk): expose require user presence config --- js/packages/core/src/__tests__/smoke.test.ts | 30 +++++++++++ js/packages/core/src/request.ts | 6 +++ .../core/src/transports/native.test.ts | 34 +++++++++++- js/packages/core/src/transports/native.ts | 9 ++++ js/packages/core/src/types/config.ts | 5 ++ .../react/src/__tests__/hooks.test.tsx | 41 ++++++++++++++ .../react/src/hooks/useIDKitRequest.ts | 1 + .../react/src/hooks/useIDKitSession.ts | 2 + .../kotlin/com/worldcoin/idkit/IDKitTests.kt | 2 + rust/core/src/bridge.rs | 49 ++++++++++++++--- rust/core/src/wasm_bindings.rs | 53 +++++++++++++++++-- swift/Tests/IDKitTests/IDKitTests.swift | 2 + 12 files changed, 224 insertions(+), 10 deletions(-) diff --git a/js/packages/core/src/__tests__/smoke.test.ts b/js/packages/core/src/__tests__/smoke.test.ts index b8614baa..6c3a4ea5 100644 --- a/js/packages/core/src/__tests__/smoke.test.ts +++ b/js/packages/core/src/__tests__/smoke.test.ts @@ -105,6 +105,35 @@ describe("IDKitRequest API", () => { ).toThrow("rp_context is required"); }); + it("should default require_user_presence to false in request config", () => { + const builder = IDKit.request({ + app_id: "app_staging_test", + action: "test-action", + rp_context: TEST_SESSION_CONFIG.rp_context, + allow_legacy_proofs: false, + }); + + expect((builder as any).config.require_user_presence).toBe(false); + }); + + it("should preserve require_user_presence in request and session configs", () => { + const requestBuilder = IDKit.request({ + app_id: "app_staging_test", + action: "test-action", + rp_context: TEST_SESSION_CONFIG.rp_context, + allow_legacy_proofs: false, + require_user_presence: true, + }); + + const sessionBuilder = IDKit.createSession({ + ...TEST_SESSION_CONFIG, + require_user_presence: true, + }); + + expect((requestBuilder as any).config.require_user_presence).toBe(true); + expect((sessionBuilder as any).config.require_user_presence).toBe(true); + }); + it("should reject malformed session_id values in proveSession", () => { expect(() => IDKit.proveSession( @@ -173,6 +202,7 @@ describe("IDKitRequest API", () => { null, null, true, + false, null, null, "production", diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index 303eacc5..aeb878a8 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -372,6 +372,7 @@ function createWasmBuilderFromConfig( config.action_description ?? null, config.bridge_url ?? null, config.allow_legacy_proofs ?? false, + config.require_user_presence ?? false, config.override_connect_base_url ?? null, config.return_to ?? null, config.environment ?? null, @@ -385,6 +386,7 @@ function createWasmBuilderFromConfig( rpContext, config.action_description ?? null, config.bridge_url ?? null, + config.require_user_presence ?? false, config.override_connect_base_url ?? null, config.return_to ?? null, config.environment ?? null, @@ -397,6 +399,7 @@ function createWasmBuilderFromConfig( rpContext, config.action_description ?? null, config.bridge_url ?? null, + config.require_user_presence ?? false, config.override_connect_base_url ?? null, config.return_to ?? null, config.environment ?? null, @@ -618,6 +621,7 @@ function createRequest(config: IDKitRequestConfig): IDKitBuilder { bridge_url: config.bridge_url, return_to: config.return_to, allow_legacy_proofs: config.allow_legacy_proofs, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, environment: config.environment, }); @@ -667,6 +671,7 @@ function createSession(config: IDKitSessionConfig): IDKitBuilder { action_description: config.action_description, bridge_url: config.bridge_url, return_to: config.return_to, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, environment: config.environment, }); @@ -728,6 +733,7 @@ function proveSession( action_description: config.action_description, bridge_url: config.bridge_url, return_to: config.return_to, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, environment: config.environment, }); diff --git a/js/packages/core/src/transports/native.test.ts b/js/packages/core/src/transports/native.test.ts index a390cc24..7c88a9e4 100644 --- a/js/packages/core/src/transports/native.test.ts +++ b/js/packages/core/src/transports/native.test.ts @@ -129,8 +129,40 @@ describe("native transport request lifecycle", () => { } }); + it("fails when user presence is required but not completed", async () => { + const req = createNativeRequest( + { payload: 1 }, + { ...baseConfig, require_user_presence: true }, + {}, + "", + ); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + protocol_version: "3.0", + verification_level: "orb", + signal_hash: "0xabc", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }); + + await expect(completionPromise).resolves.toEqual({ + success: false, + error: IDKitErrorCodes.UserPresenceFailed, + }); + }); + it("preserves completed user presence on success", async () => { - const req = createNativeRequest({ payload: 1 }, baseConfig, {}, ""); + const req = createNativeRequest( + { payload: 1 }, + { ...baseConfig, require_user_presence: true }, + {}, + "", + ); activeRequest = req; const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index fcee9fb8..b0b031d3 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -76,6 +76,7 @@ export interface BuilderConfig { bridge_url?: string; return_to?: string; allow_legacy_proofs?: boolean; + require_user_presence?: boolean; override_connect_base_url?: string; environment?: string; } @@ -169,6 +170,14 @@ class NativeIDKitRequest implements IDKitRequest { const userPresenceCompleted = getUserPresenceCompleted(responsePayload); + if (config.require_user_presence === true && !userPresenceCompleted) { + this.complete({ + success: false, + error: IDKitErrorCodes.UserPresenceFailed, + }); + return; + } + this.complete({ success: true, result: nativeResultToIDKitResult( diff --git a/js/packages/core/src/types/config.ts b/js/packages/core/src/types/config.ts index 0fa77403..994ae987 100644 --- a/js/packages/core/src/types/config.ts +++ b/js/packages/core/src/types/config.ts @@ -58,6 +58,9 @@ export type IDKitRequestConfig = { */ allow_legacy_proofs: boolean; + /** Require World App to perform a user-presence check before verification. Defaults to false. */ + require_user_presence?: boolean; + /** Optional override for the connect base URL (e.g., for staging environments) */ override_connect_base_url?: string; @@ -84,6 +87,8 @@ export type IDKitSessionConfig = { bridge_url?: string; /** Optional deep-link callback URL appended as `return_to` on the connector URL. */ return_to?: string; + /** Require World App to perform a user-presence check before verification. Defaults to false. */ + require_user_presence?: boolean; /** Optional override for the connect base URL (e.g., for staging environments) */ override_connect_base_url?: string; diff --git a/js/packages/react/src/__tests__/hooks.test.tsx b/js/packages/react/src/__tests__/hooks.test.tsx index 381cd24e..0403b0b0 100644 --- a/js/packages/react/src/__tests__/hooks.test.tsx +++ b/js/packages/react/src/__tests__/hooks.test.tsx @@ -130,6 +130,7 @@ describe("request/session hooks", () => { bridge_url: undefined, return_to: undefined, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: undefined, environment: undefined, }); @@ -170,6 +171,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, action_description: undefined, bridge_url: undefined, + require_user_presence: false, override_connect_base_url: undefined, return_to: undefined, environment: undefined, @@ -211,6 +213,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, action_description: undefined, bridge_url: undefined, + require_user_presence: false, override_connect_base_url: undefined, return_to: undefined, environment: undefined, @@ -255,11 +258,46 @@ describe("request/session hooks", () => { bridge_url: undefined, return_to: "idkit://callback?step=proof", allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: undefined, environment: undefined, }); }); + it("request hook forwards require_user_presence to core", async () => { + requestMock.mockReturnValue({ + preset: vi.fn(async () => + makeRequest(async () => ({ + type: "confirmed", + result: { proof: "ok" }, + })), + ), + }); + + const { result } = renderHook(() => + useIDKitRequest({ + app_id: "app_test", + action: "test-action", + rp_context: baseRpContext, + allow_legacy_proofs: false, + require_user_presence: true, + preset: { type: "OrbLegacy" }, + }), + ); + + act(() => { + result.current.open(); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ require_user_presence: true }), + ); + }); + it("session hook forwards return_to to createSession", async () => { createSessionMock.mockReturnValue({ constraints: vi.fn(async () => ({ @@ -276,6 +314,7 @@ describe("request/session hooks", () => { app_id: "app_test", rp_context: baseRpContext, return_to: "idkit://callback?step=create", + require_user_presence: true, constraints: { all: [] }, }), ); @@ -293,6 +332,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, action_description: undefined, bridge_url: undefined, + require_user_presence: true, override_connect_base_url: undefined, return_to: "idkit://callback?step=create", environment: undefined, @@ -334,6 +374,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, action_description: undefined, bridge_url: undefined, + require_user_presence: false, override_connect_base_url: undefined, return_to: "idkit://callback?step=prove", environment: undefined, diff --git a/js/packages/react/src/hooks/useIDKitRequest.ts b/js/packages/react/src/hooks/useIDKitRequest.ts index 22ee05e8..916439ec 100644 --- a/js/packages/react/src/hooks/useIDKitRequest.ts +++ b/js/packages/react/src/hooks/useIDKitRequest.ts @@ -17,6 +17,7 @@ export function useIDKitRequest( bridge_url: config.bridge_url, return_to: config.return_to, allow_legacy_proofs: config.allow_legacy_proofs, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, environment: config.environment, }); diff --git a/js/packages/react/src/hooks/useIDKitSession.ts b/js/packages/react/src/hooks/useIDKitSession.ts index 9e826128..97bd300c 100644 --- a/js/packages/react/src/hooks/useIDKitSession.ts +++ b/js/packages/react/src/hooks/useIDKitSession.ts @@ -36,6 +36,7 @@ export function useIDKitSession( rp_context: config.rp_context, action_description: config.action_description, bridge_url: config.bridge_url, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, return_to: config.return_to, environment: config.environment, @@ -45,6 +46,7 @@ export function useIDKitSession( rp_context: config.rp_context, action_description: config.action_description, bridge_url: config.bridge_url, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, return_to: config.return_to, environment: config.environment, diff --git a/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt b/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt index 661f6610..8fec8d3c 100644 --- a/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt +++ b/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt @@ -51,6 +51,7 @@ class IDKitTests { actionDescription = null, bridgeUrl = null, allowLegacyProofs = false, + requireUserPresence = false, overrideConnectBaseUrl = null, returnTo = null, environment = Environment.STAGING, @@ -62,6 +63,7 @@ class IDKitTests { // rpContext = sampleRpContext(), // actionDescription = null, // bridgeUrl = null, + // requireUserPresence = false, // overrideConnectBaseUrl = null, // returnTo = null, // environment = Environment.STAGING, diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 7bb65249..301c468b 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -877,6 +877,8 @@ pub struct IDKitRequestConfig { /// - `true`: Accept both v3 and v4 proofs. Use during migration. /// - `false`: Only accept v4 proofs. Use after migration cutoff or for new apps. pub allow_legacy_proofs: bool, + /// Optional user-presence requirement. Defaults to false when omitted. + pub require_user_presence: Option, /// Optional override for the connect base URL (e.g., for staging environments) pub override_connect_base_url: Option, /// Optional deep-link callback URL appended as `return_to` on the connector URL @@ -901,6 +903,8 @@ pub struct IDKitSessionConfig { pub action_description: Option, /// Optional bridge URL (defaults to production) pub bridge_url: Option, + /// Optional user-presence requirement. Defaults to false when omitted. + pub require_user_presence: Option, /// Optional override for the connect base URL (e.g., for staging environments) pub override_connect_base_url: Option, /// Optional deep-link callback URL appended as `return_to` on the connector URL @@ -962,7 +966,7 @@ impl IDKitConfig { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: config.allow_legacy_proofs, - require_user_presence: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -987,7 +991,7 @@ impl IDKitConfig { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, - require_user_presence: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1014,7 +1018,7 @@ impl IDKitConfig { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, - require_user_presence: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1059,7 +1063,7 @@ impl IDKitConfig { legacy_signal: legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: config.allow_legacy_proofs, - require_user_presence: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1083,7 +1087,7 @@ impl IDKitConfig { legacy_signal: legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: false, - require_user_presence: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1109,7 +1113,7 @@ impl IDKitConfig { legacy_signal: legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: false, - require_user_presence: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1628,6 +1632,39 @@ mod tests { assert!(matches!(response, BridgeResponse::Error { .. })); } + #[cfg(feature = "ffi")] + #[test] + fn test_request_config_defaults_user_presence_requirement_to_false() { + let signature = "0x".to_string() + &"00".repeat(64) + "1b"; + let rp_context = RpContext::new( + "rp_1234567890abcdef", + "0x0000000000000000000000000000000000000000000000000000000000000001", + 1_700_000_000, + 1_700_003_600, + &signature, + ) + .unwrap(); + let config = IDKitConfig::Request(IDKitRequestConfig { + app_id: "app_test".to_string(), + action: "test-action".to_string(), + rp_context: std::sync::Arc::new(rp_context), + action_description: None, + bridge_url: None, + allow_legacy_proofs: false, + require_user_presence: None, + override_connect_base_url: None, + return_to: None, + environment: None, + connect_url_mode: None, + }); + + let params = config + .to_params(ConstraintNode::Any { any: Vec::new() }) + .unwrap(); + + assert!(!params.require_user_presence); + } + #[test] fn test_selfie_check_legacy_preset_serializes_face_verification_level() { let preset = crate::preset::Preset::selfie_check_legacy(Some("face-signal".to_string())); diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index 73058f67..0b0f53dd 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -478,6 +478,7 @@ enum IDKitConfigWasm { action_description: Option, bridge_url: Option, allow_legacy_proofs: bool, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -487,6 +488,7 @@ enum IDKitConfigWasm { rp_context: RpContext, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -497,6 +499,7 @@ enum IDKitConfigWasm { rp_context: RpContext, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -517,6 +520,7 @@ impl IDKitConfigWasm { action_description, bridge_url, allow_legacy_proofs, + require_user_presence, override_connect_base_url, return_to, environment, @@ -542,7 +546,7 @@ impl IDKitConfigWasm { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: *allow_legacy_proofs, - require_user_presence: false, + require_user_presence: *require_user_presence, override_connect_base_url: override_connect_base_url.clone(), return_to: return_to.clone(), @@ -557,6 +561,7 @@ impl IDKitConfigWasm { rp_context, action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -580,7 +585,7 @@ impl IDKitConfigWasm { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, - require_user_presence: false, + require_user_presence: *require_user_presence, override_connect_base_url: override_connect_base_url.clone(), return_to: return_to.clone(), @@ -596,6 +601,7 @@ impl IDKitConfigWasm { rp_context, action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -621,7 +627,7 @@ impl IDKitConfigWasm { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, - require_user_presence: false, + require_user_presence: *require_user_presence, override_connect_base_url: override_connect_base_url.clone(), return_to: return_to.clone(), @@ -671,6 +677,7 @@ impl IDKitBuilderWasm { action_description: Option, bridge_url: Option, allow_legacy_proofs: bool, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -683,6 +690,7 @@ impl IDKitBuilderWasm { action_description, bridge_url, allow_legacy_proofs, + require_user_presence, override_connect_base_url, return_to, environment, @@ -698,6 +706,7 @@ impl IDKitBuilderWasm { rp_context: RpContextWasm, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -708,6 +717,7 @@ impl IDKitBuilderWasm { rp_context: rp_context.into_inner(), action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -724,6 +734,7 @@ impl IDKitBuilderWasm { rp_context: RpContextWasm, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -735,6 +746,7 @@ impl IDKitBuilderWasm { rp_context: rp_context.into_inner(), action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -947,6 +959,7 @@ pub fn request( action_description: Option, bridge_url: Option, allow_legacy_proofs: bool, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -958,6 +971,7 @@ pub fn request( action_description, bridge_url, allow_legacy_proofs, + require_user_presence, override_connect_base_url, return_to, environment, @@ -976,6 +990,7 @@ pub fn create_session( rp_context: RpContextWasm, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -985,6 +1000,7 @@ pub fn create_session( rp_context, action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -1004,6 +1020,7 @@ pub fn prove_session( rp_context: RpContextWasm, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -1014,6 +1031,7 @@ pub fn prove_session( rp_context, action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -1269,6 +1287,8 @@ export interface IDKitSessionConfig { bridge_url?: string; /** Optional deep-link callback URL appended as `return_to` on the connector URL */ return_to?: string; + /** Require World App to perform a user-presence check before verification. Defaults to false. */ + require_user_presence?: boolean; } /** RpContext for proof requests */ @@ -1401,6 +1421,7 @@ export function createSession( rp_context: RpContextWasm, action_description?: string, bridge_url?: string, + require_user_presence?: boolean, override_connect_base_url?: string, return_to?: string, environment?: string @@ -1419,6 +1440,7 @@ export function proveSession( rp_context: RpContextWasm, action_description?: string, bridge_url?: string, + require_user_presence?: boolean, override_connect_base_url?: string, return_to?: string, environment?: string @@ -1443,6 +1465,7 @@ mod tests { action_description: None, bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: Some("idkit://callback?step=request".to_string()), environment: None, @@ -1458,6 +1481,28 @@ mod tests { ); } + #[test] + fn request_params_preserve_user_presence_requirement() { + let config = IDKitConfigWasm::Request { + app_id: "app_staging_test".to_string(), + action: "test-action".to_string(), + rp_context: sample_rp_context(), + action_description: None, + bridge_url: None, + allow_legacy_proofs: false, + require_user_presence: true, + override_connect_base_url: None, + return_to: None, + environment: None, + }; + + let params = config + .to_params(ConstraintNode::Any { any: Vec::new() }) + .expect("request params"); + + assert!(params.require_user_presence); + } + #[test] fn create_session_params_preserve_return_to() { let config = IDKitConfigWasm::CreateSession { @@ -1465,6 +1510,7 @@ mod tests { rp_context: sample_rp_context(), action_description: None, bridge_url: None, + require_user_presence: false, override_connect_base_url: None, return_to: Some("idkit://callback?step=create".to_string()), environment: None, @@ -1488,6 +1534,7 @@ mod tests { rp_context: sample_rp_context(), action_description: None, bridge_url: None, + require_user_presence: false, override_connect_base_url: None, return_to: Some("idkit://callback?step=prove".to_string()), environment: None, diff --git a/swift/Tests/IDKitTests/IDKitTests.swift b/swift/Tests/IDKitTests/IDKitTests.swift index bf659e46..5e74578e 100644 --- a/swift/Tests/IDKitTests/IDKitTests.swift +++ b/swift/Tests/IDKitTests/IDKitTests.swift @@ -50,6 +50,7 @@ func idkitEntrypoints() throws { actionDescription: nil, bridgeUrl: nil, allowLegacyProofs: false, + requireUserPresence: false, overrideConnectBaseUrl: nil, returnTo: nil, environment: nil, @@ -62,6 +63,7 @@ func idkitEntrypoints() throws { // rpContext: try sampleRpContext(), // actionDescription: nil, // bridgeUrl: nil, + // requireUserPresence: false, // overrideConnectBaseUrl: nil, // returnTo: nil, // environment: nil From db61d810052c85949577d78f2bb5faf57da7b233 Mon Sep 17 00:00:00 2001 From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:18:53 -0700 Subject: [PATCH 3/5] fix(sdk): default native user presence config --- .../main/kotlin/com/worldcoin/idkit/IdKit.kt | 67 +++++++++-- swift/Sources/IDKit/IDKit.swift | 110 +++++++++++++++++- 2 files changed, 163 insertions(+), 14 deletions(-) diff --git a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt b/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt index 24e68a3b..04a71b77 100644 --- a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt +++ b/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt @@ -13,11 +13,10 @@ import uniffi.idkit_core.AppError // import uniffi.idkit_core.CredentialRequest // import uniffi.idkit_core.CredentialType import uniffi.idkit_core.IdKitBuilder -import uniffi.idkit_core.IdKitRequestConfig +import uniffi.idkit_core.IdKitRequestConfig as NativeIDKitRequestConfig import uniffi.idkit_core.IdKitRequestWrapper import uniffi.idkit_core.IdKitResult -// TODO: Re-enable when World ID 4.0 is live -// import uniffi.idkit_core.IdKitSessionConfig +import uniffi.idkit_core.IdKitSessionConfig as NativeIDKitSessionConfig import uniffi.idkit_core.Preset import uniffi.idkit_core.Signal import uniffi.idkit_core.StatusWrapper @@ -31,12 +30,62 @@ import uniffi.idkit_core.idkitResultToJson as nativeIdkitResultToJson // import uniffi.idkit_core.proveSession as nativeProveSession import uniffi.idkit_core.request as nativeRequest -typealias IDKitRequestConfig = IdKitRequestConfig -// TODO: Re-enable when World ID 4.0 is live -// typealias IDKitSessionConfig = IdKitSessionConfig typealias IDKitResult = IdKitResult typealias RpContext = uniffi.idkit_core.RpContext typealias Environment = uniffi.idkit_core.Environment +typealias ConnectUrlMode = uniffi.idkit_core.ConnectUrlMode + +data class IDKitRequestConfig( + val appId: String, + val action: String, + val rpContext: RpContext, + val actionDescription: String? = null, + val bridgeUrl: String? = null, + val allowLegacyProofs: Boolean = false, + val requireUserPresence: Boolean = false, + val overrideConnectBaseUrl: String? = null, + val returnTo: String? = null, + val environment: Environment? = null, + val connectUrlMode: ConnectUrlMode? = null, +) { + internal fun toNative(): NativeIDKitRequestConfig = + NativeIDKitRequestConfig( + appId = appId, + action = action, + rpContext = rpContext, + actionDescription = actionDescription, + bridgeUrl = bridgeUrl, + allowLegacyProofs = allowLegacyProofs, + requireUserPresence = requireUserPresence, + overrideConnectBaseUrl = overrideConnectBaseUrl, + returnTo = returnTo, + environment = environment, + connectUrlMode = connectUrlMode, + ) +} + +data class IDKitSessionConfig( + val appId: String, + val rpContext: RpContext, + val actionDescription: String? = null, + val bridgeUrl: String? = null, + val requireUserPresence: Boolean = false, + val overrideConnectBaseUrl: String? = null, + val returnTo: String? = null, + val environment: Environment? = null, +) { + internal fun toNative(): NativeIDKitSessionConfig = + NativeIDKitSessionConfig( + appId = appId, + rpContext = rpContext, + actionDescription = actionDescription, + bridgeUrl = bridgeUrl, + requireUserPresence = requireUserPresence, + overrideConnectBaseUrl = overrideConnectBaseUrl, + returnTo = returnTo, + environment = environment, + ) +} class IDKitClientError(message: String) : IllegalArgumentException(message) @@ -181,19 +230,19 @@ object IDKit { fun request(config: IDKitRequestConfig): IDKitBuilder { require(config.appId.isNotBlank()) { "app_id is required" } require(config.action.isNotBlank()) { "action is required" } - return IDKitBuilder(nativeRequest(config)) + return IDKitBuilder(nativeRequest(config.toNative())) } // TODO: Re-enable when World ID 4.0 is live // fun createSession(config: IDKitSessionConfig): IDKitBuilder { // require(config.appId.isNotBlank()) { "app_id is required" } - // return IDKitBuilder(nativeCreateSession(config)) + // return IDKitBuilder(nativeCreateSession(config.toNative())) // } // fun proveSession(sessionId: String, config: IDKitSessionConfig): IDKitBuilder { // require(sessionId.isNotBlank()) { "session_id is required" } // require(config.appId.isNotBlank()) { "app_id is required" } - // return IDKitBuilder(nativeProveSession(sessionId, config)) + // return IDKitBuilder(nativeProveSession(sessionId, config.toNative())) // } fun hashSignal(signal: String): String = hashSignalFfi(Signal.fromString(signal)) diff --git a/swift/Sources/IDKit/IDKit.swift b/swift/Sources/IDKit/IDKit.swift index 140d0a27..59510422 100644 --- a/swift/Sources/IDKit/IDKit.swift +++ b/swift/Sources/IDKit/IDKit.swift @@ -1,27 +1,127 @@ import Foundation -public typealias IDKitRequestConfig = IdKitRequestConfig -public typealias IDKitSessionConfig = IdKitSessionConfig public typealias IDKitResult = IdKitResult +/// Configuration for uniqueness proof requests. +public struct IDKitRequestConfig { + public let appId: String + public let action: String + public let rpContext: RpContext + public let actionDescription: String? + public let bridgeUrl: String? + public let allowLegacyProofs: Bool + public let requireUserPresence: Bool + public let overrideConnectBaseUrl: String? + public let returnTo: String? + public let environment: Environment? + public let connectUrlMode: ConnectUrlMode? + + public init( + appId: String, + action: String, + rpContext: RpContext, + actionDescription: String? = nil, + bridgeUrl: String? = nil, + allowLegacyProofs: Bool = false, + requireUserPresence: Bool = false, + overrideConnectBaseUrl: String? = nil, + returnTo: String? = nil, + environment: Environment? = nil, + connectUrlMode: ConnectUrlMode? = nil + ) { + self.appId = appId + self.action = action + self.rpContext = rpContext + self.actionDescription = actionDescription + self.bridgeUrl = bridgeUrl + self.allowLegacyProofs = allowLegacyProofs + self.requireUserPresence = requireUserPresence + self.overrideConnectBaseUrl = overrideConnectBaseUrl + self.returnTo = returnTo + self.environment = environment + self.connectUrlMode = connectUrlMode + } + + fileprivate var native: IdKitRequestConfig { + IdKitRequestConfig( + appId: appId, + action: action, + rpContext: rpContext, + actionDescription: actionDescription, + bridgeUrl: bridgeUrl, + allowLegacyProofs: allowLegacyProofs, + requireUserPresence: requireUserPresence, + overrideConnectBaseUrl: overrideConnectBaseUrl, + returnTo: returnTo, + environment: environment, + connectUrlMode: connectUrlMode + ) + } +} + +/// Configuration for session requests. +public struct IDKitSessionConfig { + public let appId: String + public let rpContext: RpContext + public let actionDescription: String? + public let bridgeUrl: String? + public let requireUserPresence: Bool + public let overrideConnectBaseUrl: String? + public let returnTo: String? + public let environment: Environment? + + public init( + appId: String, + rpContext: RpContext, + actionDescription: String? = nil, + bridgeUrl: String? = nil, + requireUserPresence: Bool = false, + overrideConnectBaseUrl: String? = nil, + returnTo: String? = nil, + environment: Environment? = nil + ) { + self.appId = appId + self.rpContext = rpContext + self.actionDescription = actionDescription + self.bridgeUrl = bridgeUrl + self.requireUserPresence = requireUserPresence + self.overrideConnectBaseUrl = overrideConnectBaseUrl + self.returnTo = returnTo + self.environment = environment + } + + fileprivate var native: IdKitSessionConfig { + IdKitSessionConfig( + appId: appId, + rpContext: rpContext, + actionDescription: actionDescription, + bridgeUrl: bridgeUrl, + requireUserPresence: requireUserPresence, + overrideConnectBaseUrl: overrideConnectBaseUrl, + returnTo: returnTo, + environment: environment + ) + } +} + /// Main entry point for IDKit Swift SDK. public enum IDKit { public static let version = "4.0.7" /// Creates a builder for uniqueness proof requests. public static func request(config: IDKitRequestConfig) -> IDKitBuilder { - IDKitBuilder(inner: IdKitBuilder.fromRequest(config: config)) + IDKitBuilder(inner: IdKitBuilder.fromRequest(config: config.native)) } // TODO: Re-enable when World ID 4.0 is live // /// Creates a builder for creating a new session. // public static func createSession(config: IDKitSessionConfig) -> IDKitBuilder { - // IDKitBuilder(inner: IdKitBuilder.fromCreateSession(config: config)) + // IDKitBuilder(inner: IdKitBuilder.fromCreateSession(config: config.native)) // } // /// Creates a builder for proving an existing session. // public static func proveSession(sessionId: String, config: IDKitSessionConfig) -> IDKitBuilder { - // IDKitBuilder(inner: IdKitBuilder.fromProveSession(sessionId: sessionId, config: config)) + // IDKitBuilder(inner: IdKitBuilder.fromProveSession(sessionId: sessionId, config: config.native)) // } /// Hashes a string signal to the canonical 0x-prefixed field element string. From e85e4b06a034eba7c7623ffca63908793e1913da Mon Sep 17 00:00:00 2001 From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:37:48 -0700 Subject: [PATCH 4/5] docs(examples): add user presence examples --- js/examples/nextjs/app/ui.tsx | 13 +++++++++++++ .../java/com/worldcoin/idkit/sample/MainActivity.kt | 1 + kotlin/README.md | 1 + swift/Examples/BasicVerification.swift | 1 + .../IDKitSampleApp/IDKitSampleApp/ContentView.swift | 1 + swift/README.md | 1 + 6 files changed, 18 insertions(+) diff --git a/js/examples/nextjs/app/ui.tsx b/js/examples/nextjs/app/ui.tsx index a8ca40aa..ca91afcf 100644 --- a/js/examples/nextjs/app/ui.tsx +++ b/js/examples/nextjs/app/ui.tsx @@ -152,6 +152,7 @@ export function DemoClient(): ReactElement { const [useReturnTo, setUseReturnTo] = useState(false); const [returnTo, setReturnTo] = useState(""); const [isReturnToTooltipOpen, setIsReturnToTooltipOpen] = useState(false); + const [requireUserPresence, setRequireUserPresence] = useState(false); const genesisIssuedAtMin = genesisEnabled && genesisDate @@ -485,6 +486,17 @@ export function DemoClient(): ReactElement { placeholder="googlechromes://" /> +
+ + setRequireUserPresence(e.target.checked)} + /> +
@@ -514,6 +526,7 @@ export function DemoClient(): ReactElement { action={action || "test-action"} rp_context={widgetRpContext} allow_legacy_proofs={true} + require_user_presence={requireUserPresence} {...widgetConstraintsOrPreset} onSuccess={(result) => { setWidgetIdkitResult(result); diff --git a/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt b/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt index 57957a5d..dae800be 100644 --- a/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt +++ b/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt @@ -346,6 +346,7 @@ private class SampleModel { actionDescription = "Local Android sample", bridgeUrl = null, allowLegacyProofs = false, + requireUserPresence = false, overrideConnectBaseUrl = null, returnTo = returnToURL, environment = when (environment) { diff --git a/kotlin/README.md b/kotlin/README.md index 06b56991..4da56b4d 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -84,6 +84,7 @@ val config = IDKitRequestConfig( actionDescription = "Log in", bridgeUrl = null, allowLegacyProofs = false, + requireUserPresence = false, overrideConnectBaseUrl = null, returnTo = null, environment = Environment.STAGING, diff --git a/swift/Examples/BasicVerification.swift b/swift/Examples/BasicVerification.swift index 478abfd6..22d774b7 100644 --- a/swift/Examples/BasicVerification.swift +++ b/swift/Examples/BasicVerification.swift @@ -27,6 +27,7 @@ func basicVerification() async throws { actionDescription: "Example verification", bridgeUrl: nil, allowLegacyProofs: false, + requireUserPresence: false, overrideConnectBaseUrl: nil, returnTo: nil, environment: .staging, diff --git a/swift/Examples/IDKitSampleApp/IDKitSampleApp/ContentView.swift b/swift/Examples/IDKitSampleApp/IDKitSampleApp/ContentView.swift index 38b94838..79fb84bb 100644 --- a/swift/Examples/IDKitSampleApp/IDKitSampleApp/ContentView.swift +++ b/swift/Examples/IDKitSampleApp/IDKitSampleApp/ContentView.swift @@ -175,6 +175,7 @@ final class SampleModel: ObservableObject { actionDescription: "Local iOS sample", bridgeUrl: nil, allowLegacyProofs: false, + requireUserPresence: false, overrideConnectBaseUrl: nil, returnTo: returnToURL, environment: { diff --git a/swift/README.md b/swift/README.md index 70171361..b2d2dc9a 100644 --- a/swift/README.md +++ b/swift/README.md @@ -38,6 +38,7 @@ let config = IDKitRequestConfig( actionDescription: "Log in", bridgeUrl: nil, allowLegacyProofs: false, + requireUserPresence: false, overrideConnectBaseUrl: nil, returnTo: nil, environment: .staging From 85a0b2dbaf25db0bf980ec21738db42e39f0b441 Mon Sep 17 00:00:00 2001 From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com> Date: Mon, 4 May 2026 16:57:15 -0700 Subject: [PATCH 5/5] fix(core): preserve protocol errors before user presence --- rust/core/src/bridge.rs | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 6cdb7abd..4d844337 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -831,17 +831,17 @@ impl BridgeConnection { identity_attested: Option, user_presence_completed: bool, ) -> Result { + // Protocol-level errors take precedence over user-presence enforcement. + if let Some(error_code) = proof_response.error.as_deref() { + return Ok(Status::Failed(AppError::from_code(error_code))); + } + if let Some(status) = user_presence_failure_status(self.require_user_presence, user_presence_completed) { return Ok(status); } - // Check for protocol-level error - if let Some(error_code) = proof_response.error.as_deref() { - return Ok(Status::Failed(AppError::from_code(error_code))); - } - let responses: Vec = proof_response .responses .into_iter() @@ -1728,6 +1728,26 @@ mod tests { assert_eq!(user_presence_failure_status(false, false), None); } + #[test] + fn test_protocol_error_takes_precedence_over_user_presence_failure() { + let mut connection = sample_connection(None); + connection.require_user_presence = true; + + let proof_response = ProofResponse { + id: "req_failed".to_string(), + version: RequestVersion::V1, + session_id: None, + error: Some("invalid_rp_signature".to_string()), + responses: vec![], + }; + + let status = connection + .handle_bridge_v2_response(proof_response, None, false) + .unwrap(); + + assert_eq!(status, Status::Failed(AppError::InvalidRpSignature)); + } + #[test] fn test_bridge_response_error_deserialization() { let json = r#"{"error_code": "user_rejected"}"#;