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 e9f0c4e8..83b44a8d 100644 --- a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt +++ b/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt @@ -37,6 +37,13 @@ typealias DocumentType = uniffi.idkit_core.DocumentType typealias IdentityAttribute = uniffi.idkit_core.IdentityAttribute typealias ConnectUrlMode = uniffi.idkit_core.ConnectUrlMode +/** Typed projection of the bridge request payload, exposed for building test fixtures. */ +typealias BridgeRequestPayload = uniffi.idkit_core.BridgeRequestPayloadWrapper +/** Protocol-level proof request inside a [BridgeRequestPayload]. */ +typealias ProofRequest = uniffi.idkit_core.ProofRequestWrapper +/** A per-credential request line item inside a [ProofRequest]. */ +typealias CredentialRequestItem = uniffi.idkit_core.CredentialRequestWrapper + data class IDKitRequestConfig( val appId: String, val action: String, @@ -258,6 +265,26 @@ object IDKit { return IDKitBuilder(nativeRequest(config.toNative())) } + /** + * Builds the bridge request payload from a preset without opening a network + * connection. Intended for building test fixtures. + */ + fun createBridgePayloadFromPresets( + config: IDKitRequestConfig, + preset: Preset, + ): BridgeRequestPayload = + nativeRequest(config.toNative()).bridgeRequestPayloadFromPreset(preset) + + /** + * Builds the bridge request payload from custom constraints without opening a + * network connection. Intended for building test fixtures. + */ + fun createBridgePayloadFromConstraints( + config: IDKitRequestConfig, + constraints: uniffi.idkit_core.ConstraintNode, + ): BridgeRequestPayload = + nativeRequest(config.toNative()).bridgeRequestPayload(constraints) + // TODO: Re-enable when World ID 4.0 is live // fun createSession(config: IDKitSessionConfig): IDKitBuilder { // require(config.appId.isNotBlank()) { "app_id is required" } @@ -397,3 +424,9 @@ fun idkitResultToJson(result: IDKitResult): String = nativeIdkitResultToJson(res fun idkitResultFromJson(json: String): IDKitResult = nativeIdkitResultFromJson(json) fun hashSignal(signal: Signal): String = hashSignalFfi(signal) + +val ProofRequest.credentialIdentifiers: List + get() = proofRequests.map { it.identifier } + +val BridgeRequestPayload.credentialIdentifiers: List + get() = proofRequest?.credentialIdentifiers.orEmpty() 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 e456a827..842c7115 100644 --- a/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt +++ b/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt @@ -18,6 +18,11 @@ import uniffi.idkit_core.Preset import uniffi.idkit_core.ResponseItem import uniffi.idkit_core.RpContext import uniffi.idkit_core.StatusWrapper +import uniffi.idkit_core.VerificationLevel +import uniffi.idkit_core.ConstraintKindWrapper +import uniffi.idkit_core.ConstraintNode +import uniffi.idkit_core.CredentialRequest +import uniffi.idkit_core.CredentialType class IDKitTests { private fun sampleResult( @@ -82,6 +87,112 @@ class IDKitTests { // IDKit.proveSession("0x01", sessionConfig) } + @Test + fun `bridge request payload exposes identity check contract fields`() { + val config = IDKitRequestConfig( + appId = "app_staging_1234567890abcdef", + action = "test-action", + rpContext = sampleRpContext(), + actionDescription = "Identity check", + bridgeUrl = null, + allowLegacyProofs = false, + requireUserPresence = true, + overrideConnectBaseUrl = null, + returnTo = "idkitsample://callback", + environment = Environment.STAGING, + connectUrlMode = null, + ) + + val preset = identityCheck( + attributes = listOf( + IdentityAttribute.MinimumAge(21u), + IdentityAttribute.Nationality("JPN"), + ), + ) + + val payload = IDKit.createBridgePayloadFromPresets(config, preset) + + assertEquals("app_staging_1234567890abcdef", payload.appId) + assertEquals("test-action", payload.action) + assertEquals("Identity check", payload.actionDescription) + assertEquals(VerificationLevel.DOCUMENT, payload.verificationLevel) + assertEquals(true, payload.requireUserPresence) + assertEquals(true, payload.allowLegacyProofs) + assertEquals("idkitsample://callback", payload.returnToUrl) + assertEquals(Environment.STAGING, payload.environment) + assertNull(payload.timestamp) + + val attributes = payload.identityAttributes!! + assertEquals( + listOf( + IdentityAttribute.MinimumAge(21u), + IdentityAttribute.Nationality("JPN"), + ), + attributes, + ) + + val proofRequest = payload.proofRequest!! + assertEquals(1u, proofRequest.version) + assertEquals("uniqueness", proofRequest.proofType) + assertEquals("rp_1234567890abcdef", proofRequest.rpId) + assertEquals(1_700_000_000u, proofRequest.createdAt) + assertEquals(1_700_003_600u, proofRequest.expiresAt) + assertTrue(proofRequest.id.isNotEmpty()) + + val constraints = proofRequest.constraints!! + assertEquals(ConstraintKindWrapper.ANY, constraints.kind()) + assertEquals(2, constraints.children().size) + assertEquals(ConstraintKindWrapper.TYPE, constraints.children()[0].kind()) + assertEquals("passport", constraints.children()[0].identifier()) + assertEquals(ConstraintKindWrapper.TYPE, constraints.children()[1].kind()) + assertEquals("mnc", constraints.children()[1].identifier()) + + assertEquals(listOf("passport", "mnc"), proofRequest.credentialIdentifiers) + } + + @Test + fun `bridge request payload from constraints exposes passport or mnc`() { + val config = IDKitRequestConfig( + appId = "app_staging_1234567890abcdef", + action = "test-action", + rpContext = sampleRpContext(), + actionDescription = "Identity check", + bridgeUrl = null, + allowLegacyProofs = false, + requireUserPresence = false, + overrideConnectBaseUrl = null, + returnTo = null, + environment = Environment.STAGING, + connectUrlMode = null, + ) + + val constraints = ConstraintNode.any( + nodes = listOf( + ConstraintNode.item( + request = CredentialRequest.withStringSignal( + credentialType = CredentialType.PASSPORT, + signal = null, + ), + ), + ConstraintNode.item( + request = CredentialRequest.withStringSignal( + credentialType = CredentialType.MNC, + signal = null, + ), + ), + ), + ) + + val payload = IDKit.createBridgePayloadFromConstraints(config, constraints) + + assertNull(payload.identityAttributes) + + val proofRequest = payload.proofRequest!! + val payloadConstraints = proofRequest.constraints!! + assertEquals(ConstraintKindWrapper.ANY, payloadConstraints.kind()) + assertEquals(listOf("passport", "mnc"), proofRequest.credentialIdentifiers) + } + @Test fun `status mapping covers all canonical variants`() { val result = sampleResult() diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 0f043b50..22599c9a 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -13,16 +13,15 @@ use crate::{ }; use serde::{Deserialize, Serialize}; use world_id_primitives::{ - FieldElement, OprfKeyId, ProofRequest, ProofResponse, ProofType, RequestVersion, + ConstraintExpr as ProtocolConstraintExpr, ConstraintNode as ProtocolConstraintNode, + FieldElement, OprfKeyId, ProofRequest, ProofResponse, ProofType, RequestItem, RequestVersion, ResponseItem as ProtocolResponseItem, SessionId, }; #[cfg(feature = "native-crypto")] use crate::crypto::CryptoKey; -use std::str::FromStr; -#[cfg(feature = "ffi")] -use std::sync::Arc; +use std::{str::FromStr, sync::Arc}; // ───────────────────────────────────────────────────────────────────────────── // Environment @@ -536,12 +535,11 @@ pub struct BridgeConnection { /// /// # Errors /// -/// Returns an error if constraints are invalid, the signature/nonce format is -/// wrong, or serialization fails. -pub fn build_request_payload( +/// Returns an error if constraints are invalid or the signature/nonce format is wrong. +fn build_request_payload( params: &BridgeConnectionParams, native: bool, -) -> Result { +) -> Result { if let Some(ref constraints) = params.constraints { constraints.validate()?; } @@ -632,6 +630,19 @@ pub fn build_request_payload( return_to: params.return_to.clone(), }; + Ok(payload) +} + +/// Serializes a [`BridgeRequestPayload`] built from `params` to wire JSON. +/// +/// # Errors +/// +/// Returns an error if payload construction fails or JSON serialization fails. +pub fn build_request_payload_json( + params: &BridgeConnectionParams, + native: bool, +) -> Result { + let payload = build_request_payload(params, native)?; serde_json::to_value(&payload).map_err(Into::into) } @@ -1226,6 +1237,281 @@ async fn try_create_invite_code_request( // UniFFI bindings // ───────────────────────────────────────────────────────────────────────────── +/// FFI projection of the plaintext bridge request payload, exposed for SDK +/// debug/contract inspection and fixture authoring. +/// +/// This is a strongly-typed view of the private wire type [`BridgeRequestPayload`]. +/// It re-declares the payload's fields as FFI-compatible types because a +/// `uniffi::Record` cannot reference the foreign `ProofRequest` or +/// `serde_json::Value` directly. Construct it via [`TryFrom`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "ffi", derive(uniffi::Record))] +pub struct BridgeRequestPayloadWrapper { + pub app_id: String, + pub action: Option, + pub action_description: Option, + pub signal: String, + pub verification_level: VerificationLevel, + pub timestamp: Option, + pub proof_request: Option, + /// Identity-attribute predicates, reusing the same native [`IdentityAttribute`] + /// enum developers configure for presets like `identity_check`. + pub identity_attributes: Option>, + pub allow_legacy_proofs: bool, + pub require_user_presence: bool, + pub environment: Environment, + pub return_to_url: Option, +} + +/// FFI projection of the protocol-level proof request embedded in +/// [`BridgeRequestPayloadWrapper`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "ffi", derive(uniffi::Record))] +pub struct ProofRequestWrapper { + pub id: String, + pub version: u8, + pub proof_type: String, + pub rp_id: String, + pub oprf_key_id: String, + pub session_id: Option, + pub action: Option, + pub signature: String, + pub nonce: String, + pub created_at: u64, + pub expires_at: u64, + pub proof_requests: Vec, + pub constraints: Option>, +} + +/// FFI projection of a credential request item inside +/// [`ProofRequestWrapper::proof_requests`]. +/// +/// Maps the protocol `RequestItem` 1:1. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "ffi", derive(uniffi::Record))] +pub struct CredentialRequestWrapper { + /// RP-defined identifier matched against constraints and responses (e.g. `passport`). + pub identifier: String, + /// Credential schema + issuer pair from the `CredentialSchemaIssuerRegistry`. + pub issuer_schema_id: u64, + /// `0x`-prefixed hex signal bound into the proof, when present. + pub signal: Option, + /// Minimum `genesis_issued_at` the credential must meet, when constrained. + pub genesis_issued_at_min: Option, + /// Minimum credential expiration required for the proof, when constrained. + pub expires_at_min: Option, +} + +/// Constraint tree branch kind exposed to mobile SDKs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "ffi", derive(uniffi::Enum))] +pub enum ConstraintKindWrapper { + Type, + All, + Any, + Enumerate, +} + +/// Mobile-safe view of a protocol constraint node. +/// +/// This is an object rather than a recursive enum because `UniFFI` clients cannot +/// consistently represent indirect recursive enums across Swift and Kotlin. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "ffi", derive(uniffi::Object))] +pub struct ConstraintNodeWrapper { + kind: ConstraintKindWrapper, + identifier: Option, + children: Vec>, +} + +#[cfg(feature = "ffi")] +#[uniffi::export] +impl ConstraintNodeWrapper { + #[must_use] + pub fn kind(&self) -> ConstraintKindWrapper { + self.kind + } + + #[must_use] + pub fn identifier(&self) -> Option { + self.identifier.clone() + } + + #[must_use] + pub fn children(&self) -> Vec> { + self.children.clone() + } +} + +fn proof_type_wire_name(proof_type: ProofType) -> &'static str { + match proof_type { + ProofType::Uniqueness => "uniqueness", + ProofType::CreateSession => "create_session", + ProofType::Session => "session", + } +} + +fn proof_request_wire_value(proof_request: &ProofRequest) -> Result { + serde_json::to_value(proof_request).map_err(Error::from) +} + +fn scalar_wire_string(value: &serde_json::Value, field_name: &str) -> Result { + match value { + serde_json::Value::String(value) => Ok(value.clone()), + serde_json::Value::Number(value) => Ok(value.to_string()), + serde_json::Value::Bool(value) => Ok(value.to_string()), + _ => Err(Error::InvalidConfiguration(format!( + "proof_request.{field_name} did not serialize as a scalar" + ))), + } +} + +fn required_wire_scalar(wire: &serde_json::Value, field_name: &str) -> Result { + let value = wire.get(field_name).ok_or_else(|| { + Error::InvalidConfiguration(format!("proof_request.{field_name} is missing")) + })?; + scalar_wire_string(value, field_name) +} + +fn optional_wire_scalar(wire: &serde_json::Value, field_name: &str) -> Result> { + match wire.get(field_name) { + Some(serde_json::Value::Null) | None => Ok(None), + Some(value) => scalar_wire_string(value, field_name).map(Some), + } +} + +fn required_wire_u8(wire: &serde_json::Value, field_name: &str) -> Result { + let value = wire.get(field_name).ok_or_else(|| { + Error::InvalidConfiguration(format!("proof_request.{field_name} is missing")) + })?; + let number = value.as_u64().ok_or_else(|| { + Error::InvalidConfiguration(format!( + "proof_request.{field_name} did not serialize as an integer" + )) + })?; + u8::try_from(number).map_err(|_| { + Error::InvalidConfiguration(format!("proof_request.{field_name} is out of range")) + }) +} + +impl TryFrom for CredentialRequestWrapper { + type Error = Error; + + fn try_from(item: RequestItem) -> Result { + Ok(Self { + identifier: item.identifier, + issuer_schema_id: item.issuer_schema_id, + signal: item + .signal + .map(|signal| format!("0x{}", hex::encode(signal))), + genesis_issued_at_min: item.genesis_issued_at_min, + expires_at_min: item.expires_at_min, + }) + } +} + +impl From> for ConstraintNodeWrapper { + fn from(expr: ProtocolConstraintExpr<'_>) -> Self { + match expr { + ProtocolConstraintExpr::All { all } => Self { + kind: ConstraintKindWrapper::All, + identifier: None, + children: all.into_iter().map(Self::from).map(Arc::new).collect(), + }, + ProtocolConstraintExpr::Any { any } => Self { + kind: ConstraintKindWrapper::Any, + identifier: None, + children: any.into_iter().map(Self::from).map(Arc::new).collect(), + }, + ProtocolConstraintExpr::Enumerate { enumerate } => Self { + kind: ConstraintKindWrapper::Enumerate, + identifier: None, + children: enumerate + .into_iter() + .map(Self::from) + .map(Arc::new) + .collect(), + }, + } + } +} + +impl From> for ConstraintNodeWrapper { + fn from(node: ProtocolConstraintNode<'_>) -> Self { + match node { + ProtocolConstraintNode::Type(identifier) => Self { + kind: ConstraintKindWrapper::Type, + identifier: Some(identifier.into_owned()), + children: Vec::new(), + }, + ProtocolConstraintNode::Expr(expr) => Self::from(expr), + } + } +} + +impl TryFrom for ProofRequestWrapper { + type Error = Error; + + fn try_from(proof_request: ProofRequest) -> Result { + let wire = proof_request_wire_value(&proof_request)?; + let constraints = proof_request + .constraints + .map(ConstraintNodeWrapper::from) + .map(Arc::new); + + Ok(Self { + id: proof_request.id, + version: required_wire_u8(&wire, "version")?, + proof_type: proof_type_wire_name(proof_request.proof_type).to_string(), + rp_id: proof_request.rp_id.to_string(), + oprf_key_id: required_wire_scalar(&wire, "oprf_key_id")?, + session_id: optional_wire_scalar(&wire, "session_id")?, + action: optional_wire_scalar(&wire, "action")?, + signature: required_wire_scalar(&wire, "signature")?, + nonce: required_wire_scalar(&wire, "nonce")?, + created_at: proof_request.created_at, + expires_at: proof_request.expires_at, + proof_requests: proof_request + .requests + .into_iter() + .map(CredentialRequestWrapper::try_from) + .collect::>>()?, + constraints, + }) + } +} + +impl TryFrom for BridgeRequestPayloadWrapper { + type Error = Error; + + fn try_from(payload: BridgeRequestPayload) -> Result { + Ok(Self { + app_id: payload.app_id, + action: payload.action, + action_description: payload.action_description, + signal: payload.signal, + verification_level: payload.verification_level, + timestamp: payload.timestamp, + proof_request: payload + .proof_request + .map(ProofRequestWrapper::try_from) + .transpose()?, + identity_attributes: payload.identity_attributes, + allow_legacy_proofs: payload.allow_legacy_proofs, + require_user_presence: payload.require_user_presence, + environment: payload.environment, + return_to_url: payload.return_to, + }) + } +} + +#[cfg(feature = "ffi")] +fn build_request_payload_wrapper( + params: &BridgeConnectionParams, +) -> Result { + BridgeRequestPayloadWrapper::try_from(build_request_payload(params, false)?) +} + /// Configuration for `request()` #[cfg(feature = "ffi")] #[derive(Clone, uniffi::Record)] @@ -1511,6 +1797,15 @@ impl IDKitConfig { } } +#[cfg(feature = "ffi")] +fn bridge_payload_json( + params: &BridgeConnectionParams, +) -> std::result::Result { + let payload = build_request_payload(params, false).map_err(crate::error::IdkitError::from)?; + serde_json::to_string(&payload).map_err(|err| crate::error::IdkitError::JsonError { + details: err.to_string(), + }) +} /// Unified builder for creating `IDKit` requests and sessions #[cfg(feature = "ffi")] #[derive(uniffi::Object)] @@ -1576,6 +1871,36 @@ impl IDKitBuilder { })) } + /// Builds the plaintext bridge payload JSON for the given constraints without + /// creating a bridge request. + /// + /// # Errors + /// + /// Returns an error if constraints are invalid or payload construction fails. + #[allow(clippy::needless_pass_by_value)] + pub fn bridge_request_payload_json( + &self, + constraints: Arc, + ) -> std::result::Result { + let params = self.config.to_params((*constraints).clone())?; + bridge_payload_json(¶ms) + } + + /// Builds the plaintext bridge payload for the given constraints without + /// creating a bridge request. + /// + /// # Errors + /// + /// Returns an error if constraints are invalid or payload construction fails. + #[allow(clippy::needless_pass_by_value)] + pub fn bridge_request_payload( + &self, + constraints: Arc, + ) -> std::result::Result { + let params = self.config.to_params((*constraints).clone())?; + build_request_payload_wrapper(¶ms).map_err(Into::into) + } + /// Creates a `BridgeConnection` from a preset (works for all request types) /// /// Presets provide a simplified way to create requests with predefined @@ -1607,6 +1932,36 @@ impl IDKitBuilder { })) } + /// Builds the plaintext bridge payload JSON for the given preset without + /// creating a bridge request. + /// + /// # Errors + /// + /// Returns an error if the preset is invalid or payload construction fails. + #[allow(clippy::needless_pass_by_value)] + pub fn bridge_request_payload_json_from_preset( + &self, + preset: Preset, + ) -> std::result::Result { + let params = self.config.to_params_from_preset(preset)?; + bridge_payload_json(¶ms) + } + + /// Builds the plaintext bridge payload for the given preset without + /// creating a bridge request. + /// + /// # Errors + /// + /// Returns an error if the preset is invalid or payload construction fails. + #[allow(clippy::needless_pass_by_value)] + pub fn bridge_request_payload_from_preset( + &self, + preset: Preset, + ) -> std::result::Result { + let params = self.config.to_params_from_preset(preset)?; + build_request_payload_wrapper(¶ms).map_err(Into::into) + } + /// Creates an invite-code mode `BridgeConnection` with the given constraints (WDP-73). /// /// Same proof-request shape as `constraints()`; the only difference is the @@ -1892,6 +2247,10 @@ mod tests { use super::*; use crate::types::{CredentialRequest, CredentialType, IntegritySignatureFormat, Signal}; + fn payload_json(params: &BridgeConnectionParams, native: bool) -> serde_json::Value { + build_request_payload_json(params, native).unwrap() + } + #[test] fn test_bridge_request_payload_serialization() { let item = CredentialRequest::new( @@ -1996,7 +2355,7 @@ mod tests { identity_attributes: None, }; - let payload = build_request_payload(¶ms, false).unwrap(); + let payload = payload_json(¶ms, false); let proof_request = payload.get("proof_request").unwrap(); assert_eq!(payload["action"], serde_json::json!("test-action")); assert_eq!(proof_request["proof_type"], serde_json::json!("uniqueness")); @@ -2047,7 +2406,7 @@ mod tests { identity_attributes: None, }; - let payload = build_request_payload(¶ms, false).unwrap(); + let payload = payload_json(¶ms, false); let proof_request = payload.get("proof_request").unwrap(); assert_eq!(payload["action"], serde_json::json!("")); assert_eq!( @@ -2087,7 +2446,7 @@ mod tests { identity_attributes: None, }; - let prove_payload = build_request_payload(&prove_params, false).unwrap(); + let prove_payload = payload_json(&prove_params, false); assert_eq!(prove_payload["action"], serde_json::json!("")); assert_eq!( prove_payload["proof_request"]["proof_type"], @@ -2133,7 +2492,7 @@ mod tests { ]), }; - let payload = build_request_payload(¶ms, false).unwrap(); + let payload = payload_json(¶ms, false); assert_eq!( payload["identity_attributes"], @@ -2145,6 +2504,100 @@ mod tests { assert!(payload.get("proof_request").is_some()); } + #[test] + fn test_build_bridge_request_payload_wrapper_identity_check() { + let app_id = AppId::new("app_staging_1234567890abcdef").unwrap(); + let rp_context = RpContext::new( + "rp_1234567890abcdef", + "0x0000000000000000000000000000000000000000000000000000000000000001", + 1_700_000_000, + 1_700_003_600, + &("0x".to_string() + &"00".repeat(64) + "1b"), + ) + .unwrap(); + let constraints = ConstraintNode::any(vec![ + ConstraintNode::item(CredentialRequest::new(CredentialType::Passport, None)), + ConstraintNode::item(CredentialRequest::new(CredentialType::Mnc, None)), + ]); + + let params = BridgeConnectionParams { + app_id, + kind: RequestKind::Uniqueness { + action: "test-action".to_string(), + }, + constraints: Some(constraints), + rp_context, + action_description: Some("Identity check".to_string()), + legacy_verification_level: VerificationLevel::Document, + legacy_signal: String::new(), + bridge_url: None, + allow_legacy_proofs: true, + require_user_presence: true, + override_connect_base_url: None, + return_to: Some("idkitsample://callback".to_string()), + environment: Some(Environment::Staging), + identity_attributes: Some(vec![ + IdentityAttribute::MinimumAge(21), + IdentityAttribute::Nationality("JPN".to_string()), + ]), + }; + + let payload = + BridgeRequestPayloadWrapper::try_from(build_request_payload(¶ms, false).unwrap()) + .unwrap(); + + assert_eq!(payload.app_id, "app_staging_1234567890abcdef"); + assert_eq!(payload.action.as_deref(), Some("test-action")); + assert_eq!( + payload.action_description.as_deref(), + Some("Identity check") + ); + assert_eq!(payload.verification_level, VerificationLevel::Document); + assert!(payload.timestamp.is_none()); + assert!(payload.allow_legacy_proofs); + assert!(payload.require_user_presence); + assert_eq!(payload.environment, Environment::Staging); + assert_eq!( + payload.return_to_url.as_deref(), + Some("idkitsample://callback") + ); + + let attributes = payload.identity_attributes.unwrap(); + assert_eq!( + attributes, + vec![ + IdentityAttribute::MinimumAge(21), + IdentityAttribute::Nationality("JPN".to_string()), + ] + ); + + let proof_request = payload.proof_request.unwrap(); + assert_eq!(proof_request.version, 1); + assert_eq!(proof_request.proof_type, "uniqueness"); + assert_eq!(proof_request.rp_id, "rp_1234567890abcdef"); + assert_eq!(proof_request.created_at, 1_700_000_000); + assert_eq!(proof_request.expires_at, 1_700_003_600); + assert!(!proof_request.id.is_empty()); + let constraints = proof_request.constraints.unwrap(); + assert_eq!(constraints.kind, ConstraintKindWrapper::Any); + assert_eq!(constraints.children.len(), 2); + assert_eq!(constraints.children[0].kind, ConstraintKindWrapper::Type); + assert_eq!( + constraints.children[0].identifier.as_deref(), + Some("passport") + ); + assert_eq!(constraints.children[1].kind, ConstraintKindWrapper::Type); + assert_eq!(constraints.children[1].identifier.as_deref(), Some("mnc")); + assert_eq!( + proof_request + .proof_requests + .iter() + .map(|item| item.identifier.as_str()) + .collect::>(), + vec!["passport", "mnc"] + ); + } + #[test] fn test_session_id_sdk_round_trip_uses_protocol_format() { let session_id = SessionId::default(); @@ -2809,7 +3262,7 @@ mod tests { identity_attributes: None, }; - let payload = build_request_payload(¶ms, false).unwrap(); + let payload = payload_json(¶ms, false); assert_eq!(payload["verification_level"], serde_json::json!("face")); } @@ -2851,7 +3304,7 @@ mod tests { identity_attributes: None, }; - let payload = build_request_payload(¶ms, false).unwrap(); + let payload = payload_json(¶ms, false); assert_eq!(payload["verification_level"], serde_json::json!("device")); } @@ -2892,7 +3345,7 @@ mod tests { identity_attributes: None, }; - let payload = build_request_payload(¶ms, false).unwrap(); + let payload = payload_json(¶ms, false); assert_eq!(payload["verification_level"], serde_json::json!("orb")); assert_eq!(payload["allow_legacy_proofs"], serde_json::json!(true)); assert_eq!( @@ -2938,7 +3391,7 @@ mod tests { identity_attributes: None, }; - let payload = build_request_payload(¶ms, false).unwrap(); + let payload = payload_json(¶ms, false); assert_eq!(payload["verification_level"], serde_json::json!("document")); assert_eq!(payload["allow_legacy_proofs"], serde_json::json!(true)); assert_eq!( @@ -3054,12 +3507,12 @@ mod tests { }; // native=true includes timestamp - let payload = build_request_payload(¶ms, true).unwrap(); + let payload = payload_json(¶ms, true); 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(); + let payload_bridge = payload_json(¶ms, false); assert!(payload_bridge.get("timestamp").is_none()); assert_eq!( payload_bridge["require_user_presence"], @@ -3105,13 +3558,13 @@ mod tests { identity_attributes: None, }; - let bridge_payload = build_request_payload(¶ms, false).unwrap(); + let bridge_payload = payload_json(¶ms, false); assert_eq!( bridge_payload["require_user_presence"], serde_json::json!(true) ); - let native_payload = build_request_payload(¶ms, true).unwrap(); + let native_payload = payload_json(¶ms, true); assert_eq!( native_payload["require_user_presence"], serde_json::json!(true) @@ -3150,7 +3603,7 @@ mod tests { identity_attributes: None, }; - let payload = build_request_payload(¶ms, false).unwrap(); + let payload = payload_json(¶ms, false); assert_eq!(payload["return_to_url"], "idkitsample://callback"); assert!(payload.get("return_to").is_none()); } @@ -3187,7 +3640,7 @@ mod tests { identity_attributes: None, }; - let payload = build_request_payload(¶ms, false).unwrap(); + let payload = payload_json(¶ms, false); assert!(payload.get("return_to_url").is_none()); } @@ -3233,10 +3686,10 @@ mod tests { let cached = CachedSignalHashes::compute(¶ms); assert_eq!(cached.legacy_signal_hash, expected); - let bridge_payload = build_request_payload(¶ms, false).unwrap(); + let bridge_payload = payload_json(¶ms, false); assert_eq!(bridge_payload["signal"], expected); - let native_payload = build_request_payload(¶ms, true).unwrap(); + let native_payload = payload_json(¶ms, true); assert_eq!(native_payload["signal"], expected); let native_v1_payload = build_native_v1_payload(¶ms).unwrap(); diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index 76ce37cd..d0899a4e 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -860,7 +860,7 @@ impl IDKitBuilderWasm { let params = self.config.to_params(Some(constraints))?; - let payload = crate::bridge::build_request_payload(¶ms, true) + let payload = crate::bridge::build_request_payload_json(¶ms, true) .map_err(|e| JsValue::from_str(&format!("Failed to build payload: {e}")))?; let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); @@ -912,7 +912,7 @@ impl IDKitBuilderWasm { let params = self.config.to_params_from_preset(preset)?; - let payload = crate::bridge::build_request_payload(¶ms, true) + let payload = crate::bridge::build_request_payload_json(¶ms, true) .map_err(|e| JsValue::from_str(&format!("Failed to build payload: {e}")))?; let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); diff --git a/scripts/package-swift.sh b/scripts/package-swift.sh index bd0ecaab..c939785f 100755 --- a/scripts/package-swift.sh +++ b/scripts/package-swift.sh @@ -13,7 +13,7 @@ echo "📦 Packaging IDKit Swift artifacts" rm -rf "$IOS_BUILD" "$PROJECT_ROOT/IDKitFFI.xcframework" mkdir -p "$FFI_INCLUDE_DIR" mkdir -p "$IOS_BUILD/bindings" \ - "$IOS_BUILD/Headers/IDKit" \ + "$IOS_BUILD/Headers" \ "$IOS_BUILD/target/universal-ios-sim/release" \ "$IOS_BUILD/target/universal-macos/release" \ "$GENERATED_DIR" @@ -82,8 +82,8 @@ module idkitFFI { } EOF -cp "$IOS_BUILD/bindings"/idkit_coreFFI.h "$IOS_BUILD/Headers/IDKit/" -cp "$IOS_BUILD/bindings"/idkit_coreFFI.modulemap "$IOS_BUILD/Headers/IDKit/module.modulemap" +cp "$IOS_BUILD/bindings"/idkit_coreFFI.h "$IOS_BUILD/Headers/" +cp "$IOS_BUILD/bindings"/idkit_coreFFI.modulemap "$IOS_BUILD/Headers/module.modulemap" echo "🏗️ Creating XCFramework" xcodebuild -create-xcframework \ diff --git a/swift/Sources/IDKit/IDKit.swift b/swift/Sources/IDKit/IDKit.swift index 224715ff..ab90ea4d 100644 --- a/swift/Sources/IDKit/IDKit.swift +++ b/swift/Sources/IDKit/IDKit.swift @@ -2,6 +2,13 @@ import Foundation public typealias IDKitResult = IdKitResult +/// Typed projection of the bridge request payload, exposed for building test fixtures. +public typealias BridgeRequestPayload = BridgeRequestPayloadWrapper +/// Protocol-level proof request inside a ``BridgeRequestPayload``. +public typealias ProofRequest = ProofRequestWrapper +/// A per-credential request line item inside a ``ProofRequest``. +public typealias CredentialRequestItem = CredentialRequestWrapper + /// Configuration for uniqueness proof requests. public struct IDKitRequestConfig { public let appId: String @@ -113,6 +120,24 @@ public enum IDKit { IDKitBuilder(inner: IdKitBuilder.fromRequest(config: config.native)) } + /// Builds the bridge request payload from a preset without opening a network + /// connection. Intended for building test fixtures. + public static func createBridgePayloadFromPresets( + config: IDKitRequestConfig, preset: Preset + ) throws -> BridgeRequestPayload { + try IdKitBuilder.fromRequest(config: config.native) + .bridgeRequestPayloadFromPreset(preset: preset) + } + + /// Builds the bridge request payload from custom constraints without opening a + /// network connection. Intended for building test fixtures. + public static func createBridgePayloadFromConstraints( + config: IDKitRequestConfig, constraints: ConstraintNode + ) throws -> BridgeRequestPayload { + try IdKitBuilder.fromRequest(config: config.native) + .bridgeRequestPayload(constraints: constraints) + } + // 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 { @@ -626,3 +651,17 @@ public extension Signal { self.asString() } } + +extension ProofRequest { + /// Credential identifiers from `proofRequests` (e.g. `["passport", "mnc"]`). + public var credentialIdentifiers: [String] { + proofRequests.map(\.identifier) + } +} + +extension BridgeRequestPayload { + /// Credential identifiers when a v4 `proofRequest` is present; empty otherwise. + public var credentialIdentifiers: [String] { + proofRequest?.credentialIdentifiers ?? [] + } +} diff --git a/swift/Tests/IDKitTests/IDKitTests.swift b/swift/Tests/IDKitTests/IDKitTests.swift index 048acd33..bd4fb691 100644 --- a/swift/Tests/IDKitTests/IDKitTests.swift +++ b/swift/Tests/IDKitTests/IDKitTests.swift @@ -43,20 +43,18 @@ private func sampleRpContext() throws -> RpContext { ) } + @Test("IDKit entrypoints expose canonical builders") func idkitEntrypoints() throws { - let requestConfig = IDKitRequestConfig( + let requestConfig = + IDKitRequestConfig( appId: "app_staging_1234567890abcdef", - action: "login", + action: "test-action", rpContext: try sampleRpContext(), - actionDescription: nil, + actionDescription: "Identity check", bridgeUrl: nil, allowLegacyProofs: false, - requireUserPresence: false, - overrideConnectBaseUrl: nil, - returnTo: nil, - environment: nil, - connectUrlMode: nil + requireUserPresence: true, ) // TODO: Re-enable when World ID 4.0 is live @@ -79,6 +77,104 @@ func idkitEntrypoints() throws { #expect(Bool(true)) } +@Test("bridge request payload exposes identity check contract fields") +func bridgeRequestPayloadIdentityCheck() throws { + let requestConfig = IDKitRequestConfig( + appId: "app_staging_1234567890abcdef", + action: "test-action", + rpContext: try sampleRpContext(), + actionDescription: "Identity check", + bridgeUrl: nil, + allowLegacyProofs: false, + requireUserPresence: true, + overrideConnectBaseUrl: nil, + returnTo: "idkitsample://callback", + environment: .staging, + connectUrlMode: nil + ) + + let preset = identityCheck( + attributes: [ + .minimumAge(21), + .nationality("JPN") + ] + ) + + let payload = try IDKit.createBridgePayloadFromPresets(config: requestConfig, preset: preset) + + #expect(payload.appId == "app_staging_1234567890abcdef") + #expect(payload.action == "test-action") + #expect(payload.actionDescription == "Identity check") + #expect(payload.verificationLevel == .document) + #expect(payload.requireUserPresence == true) + #expect(payload.allowLegacyProofs == true) + #expect(payload.returnToUrl == "idkitsample://callback") + #expect(payload.environment == .staging) + #expect(payload.timestamp == nil) + + let attributes = try #require(payload.identityAttributes) + #expect(attributes == [ + .minimumAge(21), + .nationality("JPN"), + ]) + + let proofRequest = try #require(payload.proofRequest) + #expect(proofRequest.version == 1) + #expect(proofRequest.proofType == "uniqueness") + #expect(proofRequest.rpId == "rp_1234567890abcdef") + #expect(proofRequest.createdAt == 1_700_000_000) + #expect(proofRequest.expiresAt == 1_700_003_600) + #expect(proofRequest.id.isEmpty == false) + + let constraints = try #require(proofRequest.constraints) + #expect(constraints.kind() == .any) + #expect(constraints.children().count == 2) + #expect(constraints.children()[0].kind() == .type) + #expect(constraints.children()[0].identifier() == "passport") + #expect(constraints.children()[1].kind() == .type) + #expect(constraints.children()[1].identifier() == "mnc") + + #expect(proofRequest.credentialIdentifiers == ["passport", "mnc"]) +} + +@Test("bridge request payload from constraints exposes passport or mnc") +func bridgeRequestPayloadFromConstraints() throws { + let requestConfig = IDKitRequestConfig( + appId: "app_staging_1234567890abcdef", + action: "test-action", + rpContext: try sampleRpContext(), + actionDescription: "Identity check", + bridgeUrl: nil, + allowLegacyProofs: false, + requireUserPresence: false, + overrideConnectBaseUrl: nil, + returnTo: nil, + environment: .staging, + connectUrlMode: nil + ) + + let constraints = ConstraintNode.any(nodes: [ + ConstraintNode.item( + request: CredentialRequest.withStringSignal(credentialType: .passport, signal: nil) + ), + ConstraintNode.item( + request: CredentialRequest.withStringSignal(credentialType: .mnc, signal: nil) + ), + ]) + + let payload = try IDKit.createBridgePayloadFromConstraints( + config: requestConfig, + constraints: constraints + ) + + #expect(payload.identityAttributes == nil) + + let proofRequest = try #require(payload.proofRequest) + let payloadConstraints = try #require(proofRequest.constraints) + #expect(payloadConstraints.kind() == .any) + #expect(proofRequest.credentialIdentifiers == ["passport", "mnc"]) +} + @Test("Status mapping covers all canonical variants") func statusMapping() { let result = sampleResult()