diff --git a/js/examples/nextjs/app/globals.css b/js/examples/nextjs/app/globals.css index 27fe02c..7caea94 100644 --- a/js/examples/nextjs/app/globals.css +++ b/js/examples/nextjs/app/globals.css @@ -83,6 +83,11 @@ button.secondary { color: var(--text-color); } +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + pre { background: var(--code-bg); color: var(--code-text); @@ -127,17 +132,22 @@ pre { display: flex; align-items: center; gap: 10px; + min-height: 34px; } .config-row > label { - min-width: 100px; + width: 200px; + flex-shrink: 0; opacity: 0.7; font-size: 14px; } .config-row input[type="text"], +.config-row input[type="number"], +.config-row input[type="datetime-local"], .config-row select { flex: 1; + height: 34px; font-family: monospace; font-size: 14px; padding: 6px 10px; diff --git a/js/examples/nextjs/app/page.tsx b/js/examples/nextjs/app/page.tsx index a36467e..9079f3f 100644 --- a/js/examples/nextjs/app/page.tsx +++ b/js/examples/nextjs/app/page.tsx @@ -6,8 +6,8 @@ export default function HomePage(): ReactElement {

IDKit Next.js Example

- This example shows the widget request flow with the same legacy presets - as the browser example. + This example shows the widget request flow for legacy presets, World ID + 4.0 credential requests, and identity checks.

diff --git a/js/examples/nextjs/app/ui.tsx b/js/examples/nextjs/app/ui.tsx index cfb0002..3b5bf31 100644 --- a/js/examples/nextjs/app/ui.tsx +++ b/js/examples/nextjs/app/ui.tsx @@ -1,10 +1,13 @@ "use client"; import { useEffect, useMemo, useState, type ReactElement } from "react"; +import countries from "i18n-iso-countries"; +import enLocale from "i18n-iso-countries/langs/en.json"; import { CredentialRequest, documentLegacy, deviceLegacy, + identityCheck, selfieCheckLegacy, IDKitInviteCodeRequestWidget, IDKitRequestWidget, @@ -14,12 +17,15 @@ import { secureDocumentLegacy, setDebug, type ConstraintNode, + type DocumentType, + type IdentityAttribute, type IDKitResult, - type RpContext, type Preset, + type RpContext, } from "@worldcoin/idkit"; setDebug(true); +countries.registerLocale(enLocale); const APP_ID = process.env.NEXT_PUBLIC_APP_ID as `app_${string}` | undefined; const RP_ID = process.env.NEXT_PUBLIC_RP_ID; @@ -35,13 +41,37 @@ const RETURN_TO_TOOLTIP = type PresetKind = "orb" | "secure_document" | "document" | "device" | "selfie"; -type V4CredentialType = "proof_of_human" | "selfie" | "passport" | "mnc"; +type V4CredentialType = + | "proof_of_human" + | "selfie" + | "passport" + | "mnc" + | "identity_check"; + +type IdentityAttributesConfig = { + document_type: { enabled: boolean; value: DocumentType }; + document_number: { enabled: boolean; value: string }; + issuing_country: { enabled: boolean; value: string }; + full_name: { enabled: boolean; value: string }; + minimum_age: { enabled: boolean; value: string }; + nationality: { enabled: boolean; value: string }; +}; + +const DEFAULT_IDENTITY_ATTRIBUTES: IdentityAttributesConfig = { + document_type: { enabled: true, value: "passport" }, + document_number: { enabled: false, value: "" }, + issuing_country: { enabled: false, value: "" }, + full_name: { enabled: false, value: "" }, + minimum_age: { enabled: false, value: "" }, + nationality: { enabled: false, value: "" }, +}; const V4_CREDENTIAL_TO_NAME: Record = { proof_of_human: "Proof of Human", selfie: "Selfie", passport: "Passport", - mnc: "MNC", + mnc: "My Number Card", + identity_check: "Identity Check", }; const PRESET_KIND_TO_NAME: Record = { @@ -52,6 +82,58 @@ const PRESET_KIND_TO_NAME: Record = { selfie: "Selfie Check", }; +function isValidAlpha3(code: string): boolean { + return code.length === 3 && countries.isValid(code.toUpperCase()); +} + +function normalizeAlpha3(value: string): string { + return value.trim().toUpperCase(); +} + +function buildIdentityAttributes( + config: IdentityAttributesConfig, +): IdentityAttribute[] { + const attributes: IdentityAttribute[] = []; + + if (config.document_type.enabled) { + attributes.push({ + type: "document_type", + value: config.document_type.value, + }); + } + + const documentNumber = config.document_number.value.trim(); + if (config.document_number.enabled && documentNumber.length > 0) { + attributes.push({ type: "document_number", value: documentNumber }); + } + + const issuingCountry = normalizeAlpha3(config.issuing_country.value); + if (config.issuing_country.enabled && issuingCountry.length > 0) { + attributes.push({ type: "issuing_country", value: issuingCountry }); + } + + const fullName = config.full_name.value.trim(); + if (config.full_name.enabled && fullName.length > 0) { + attributes.push({ type: "full_name", value: fullName }); + } + + const minimumAge = Number(config.minimum_age.value); + if ( + config.minimum_age.enabled && + Number.isInteger(minimumAge) && + minimumAge > 0 + ) { + attributes.push({ type: "minimum_age", value: minimumAge }); + } + + const nationality = normalizeAlpha3(config.nationality.value); + if (config.nationality.enabled && nationality.length > 0) { + attributes.push({ type: "nationality", value: nationality }); + } + + return attributes; +} + function createChromeAppDeeplink(url: string): string { const parsed = new URL(url); return parsed.protocol === "https:" ? "googlechromes://" : "googlechrome://"; @@ -153,9 +235,11 @@ export function DemoClient(): ReactElement { const [useStagingConnectBaseUrl, setUseStagingConnectBaseUrl] = useState(false); const [isConnectUrlTooltipOpen, setIsConnectUrlTooltipOpen] = useState(false); - const [worldIdVersion, setWorldIdVersion] = useState<"3.0" | "4.0">("3.0"); + const [worldIdVersion, setWorldIdVersion] = useState<"3.0" | "4.0">("4.0"); const [v4CredentialType, setV4CredentialType] = useState("proof_of_human"); + const [identityAttributes, setIdentityAttributes] = + useState(DEFAULT_IDENTITY_ATTRIBUTES); const [presetKind, setPresetKind] = useState("orb"); const [genesisEnabled, setGenesisEnabled] = useState(false); const [genesisDate, setGenesisDate] = useState(""); @@ -164,41 +248,57 @@ export function DemoClient(): ReactElement { const [returnTo, setReturnTo] = useState(""); const [isReturnToTooltipOpen, setIsReturnToTooltipOpen] = useState(false); const [useInviteCode, setUseInviteCode] = useState(false); - const isV4PresetCredential = v4CredentialType !== "mnc"; + const isV4PresetCredential = + v4CredentialType !== "mnc" && v4CredentialType !== "identity_check"; const genesisIssuedAtMin = genesisEnabled && genesisDate ? Math.floor(new Date(genesisDate).getTime() / 1000) : undefined; - const widgetConstraintsOrPreset: - | { - constraints: ConstraintNode; - } - | { - preset: Preset; - } = useMemo( - () => - worldIdVersion === "4.0" - ? v4CredentialType === "proof_of_human" - ? { preset: proofOfHuman({ signal: widgetSignal }) } - : v4CredentialType === "passport" - ? { preset: passportPreset({ signal: widgetSignal }) } - : { - constraints: CredentialRequest(v4CredentialType, { - genesis_issued_at_min: genesisIssuedAtMin, - }), - } - : { preset: createPreset(presetKind, widgetSignal) }, - [ - worldIdVersion, - presetKind, - v4CredentialType, - genesisIssuedAtMin, - widgetSignal, - ], + const identityAttributesPayload = useMemo( + () => buildIdentityAttributes(identityAttributes), + [identityAttributes], ); + const isIdentityCheck = + worldIdVersion === "4.0" && v4CredentialType === "identity_check"; + const canStartWidgetFlow = + !isIdentityCheck || identityAttributesPayload.length > 0; + + const widgetConstraintsOrPreset: + | { constraints: ConstraintNode } + | { preset: Preset } = useMemo(() => { + if (worldIdVersion !== "4.0") { + return { preset: createPreset(presetKind, widgetSignal) }; + } + if (v4CredentialType === "proof_of_human") { + return { preset: proofOfHuman({ signal: widgetSignal }) }; + } + if (v4CredentialType === "passport") { + return { preset: passportPreset({ signal: widgetSignal }) }; + } + if (v4CredentialType === "identity_check") { + return { + preset: identityCheck({ + attributes: identityAttributesPayload, + }), + }; + } + return { + constraints: CredentialRequest(v4CredentialType, { + genesis_issued_at_min: genesisIssuedAtMin, + }), + }; + }, [ + worldIdVersion, + presetKind, + v4CredentialType, + genesisIssuedAtMin, + identityAttributesPayload, + widgetSignal, + ]); + const overrideConnectBaseUrl = environment === "staging" && useStagingConnectBaseUrl ? STAGING_CONNECT_BASE_URL @@ -225,9 +325,17 @@ export function DemoClient(): ReactElement { if (worldIdVersion !== "4.0" || isV4PresetCredential) { setGenesisEnabled(false); setGenesisDate(""); + setIdentityAttributes(DEFAULT_IDENTITY_ATTRIBUTES); } }, [isV4PresetCredential, worldIdVersion]); + useEffect(() => { + if (v4CredentialType === "identity_check") { + setGenesisEnabled(false); + setGenesisDate(""); + } + }, [v4CredentialType]); + useEffect(() => { if (typeof window === "undefined") { return; @@ -245,6 +353,11 @@ export function DemoClient(): ReactElement { setWidgetVerifyResult(null); setWidgetIdkitResult(null); + if (!canStartWidgetFlow) { + setWidgetError("Select at least one identity attribute."); + return; + } + try { const rpContext = await fetchRpContext(action || "test-action"); setWidgetSignal(`demo-signal-${Date.now()}`); @@ -412,60 +525,327 @@ export function DemoClient(): ReactElement { - + + -

- -
setIsGenesisTooltipOpen(true)} - onMouseLeave={() => setIsGenesisTooltipOpen(false)} - > - - {isGenesisTooltipOpen && ( - +
+ +
setIsGenesisTooltipOpen(true)} + onMouseLeave={() => setIsGenesisTooltipOpen(false)} > - {GENESIS_ISSUED_AT_MIN_TOOLTIP} - - )} -
- setGenesisEnabled(e.target.checked)} - /> - {genesisEnabled && ( - setGenesisDate(e.target.value)} - /> - )} -
+ + {isGenesisTooltipOpen && ( + + {GENESIS_ISSUED_AT_MIN_TOOLTIP} + + )} +
+ setGenesisEnabled(e.target.checked)} + /> + {genesisEnabled && ( + setGenesisDate(e.target.value)} + /> + )} +
+ + )} + {v4CredentialType === "identity_check" && ( + <> +
+ + + setIdentityAttributes((current) => ({ + ...current, + document_type: { + ...current.document_type, + enabled: e.target.checked, + }, + })) + } + /> + {identityAttributes.document_type.enabled && ( + + )} +
+
+ + + setIdentityAttributes((current) => ({ + ...current, + document_number: { + ...current.document_number, + enabled: e.target.checked, + }, + })) + } + /> + {identityAttributes.document_number.enabled && ( + + setIdentityAttributes((current) => ({ + ...current, + document_number: { + ...current.document_number, + value: e.target.value, + }, + })) + } + placeholder="A1234567" + /> + )} +
+
+ + + setIdentityAttributes((current) => ({ + ...current, + issuing_country: { + ...current.issuing_country, + enabled: e.target.checked, + }, + })) + } + /> + {identityAttributes.issuing_country.enabled && ( + <> + + setIdentityAttributes((current) => ({ + ...current, + issuing_country: { + ...current.issuing_country, + value: normalizeAlpha3(e.target.value), + }, + })) + } + maxLength={3} + placeholder="JPN" + /> + {identityAttributes.issuing_country.value && + (isValidAlpha3( + identityAttributes.issuing_country.value, + ) ? ( + + {countries.getName( + identityAttributes.issuing_country.value, + "en", + )} + + ) : ( + + ISO 3166-1 alpha-3 + + ))} + + )} +
+
+ + + setIdentityAttributes((current) => ({ + ...current, + full_name: { + ...current.full_name, + enabled: e.target.checked, + }, + })) + } + /> + {identityAttributes.full_name.enabled && ( + + setIdentityAttributes((current) => ({ + ...current, + full_name: { + ...current.full_name, + value: e.target.value, + }, + })) + } + placeholder="Jane Doe" + /> + )} +
+
+ + + setIdentityAttributes((current) => ({ + ...current, + minimum_age: { + ...current.minimum_age, + enabled: e.target.checked, + }, + })) + } + /> + {identityAttributes.minimum_age.enabled && ( + + setIdentityAttributes((current) => ({ + ...current, + minimum_age: { + ...current.minimum_age, + value: e.target.value, + }, + })) + } + min={1} + max={255} + placeholder="18" + /> + )} +
+
+ + + setIdentityAttributes((current) => ({ + ...current, + nationality: { + ...current.nationality, + enabled: e.target.checked, + }, + })) + } + /> + {identityAttributes.nationality.enabled && ( + <> + + setIdentityAttributes((current) => ({ + ...current, + nationality: { + ...current.nationality, + value: normalizeAlpha3(e.target.value), + }, + })) + } + maxLength={3} + placeholder="JPN" + /> + {identityAttributes.nationality.value && + (isValidAlpha3(identityAttributes.nationality.value) ? ( + + {countries.getName( + identityAttributes.nationality.value, + "en", + )} + + ) : ( + + ISO 3166-1 alpha-3 + + ))} + + )} +
+ + )} )}
@@ -527,12 +907,15 @@ export function DemoClient(): ReactElement { {worldIdVersion === "4.0" && ( <> - )}
+ {isIdentityCheck && identityAttributesPayload.length === 0 && ( +

Select at least one identity attribute.

+ )} {widgetError &&

Error: {widgetError}

} {widgetRpContext && @@ -585,6 +968,22 @@ export function DemoClient(): ReactElement { {widgetIdkitResult && ( <>

IDKit response

+ {widgetIdkitResult.protocol_version === "4.0" && + !("session_id" in widgetIdkitResult) && + widgetIdkitResult.identity_attested !== undefined && ( +

+ Identity Attested:{" "} + {widgetIdkitResult.identity_attested ? "✓ Yes" : "✗ No"} +

+ )}
             {JSON.stringify(widgetIdkitResult, null, 2)}
           
diff --git a/js/examples/nextjs/package.json b/js/examples/nextjs/package.json index 9507e67..a4576bb 100644 --- a/js/examples/nextjs/package.json +++ b/js/examples/nextjs/package.json @@ -12,6 +12,7 @@ "@worldcoin/idkit-server": "workspace:*", "takis-minikit-js": "2.0.0-dev.1", "eruda": "^3.4.1", + "i18n-iso-countries": "^7.14.0", "next": "^15.4.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/js/packages/core/src/__tests__/smoke.test.ts b/js/packages/core/src/__tests__/smoke.test.ts index ff7adb5..2b93e87 100644 --- a/js/packages/core/src/__tests__/smoke.test.ts +++ b/js/packages/core/src/__tests__/smoke.test.ts @@ -14,6 +14,7 @@ import { selfieCheckLegacy, proofOfHuman, passport, + identityCheck, isNode, IDKitErrorCodes, signRequest, @@ -109,6 +110,22 @@ describe("IDKitRequest API", () => { expect(preset).toHaveProperty("signal", "passport-signal"); }); + it("should create identityCheck preset correctly", () => { + const preset = identityCheck({ + attributes: [ + { type: "minimum_age", value: 21 }, + { type: "nationality", value: "JPN" }, + ], + }); + expect(preset).toEqual({ + type: "IdentityCheck", + attributes: [ + { type: "minimum_age", value: 21 }, + { type: "nationality", value: "JPN" }, + ], + }); + }); + it("should throw error when rp_context is missing", () => { expect(() => IDKit.request({ @@ -203,6 +220,45 @@ describe("IDKitRequest API", () => { expect(result.payload.signal).toBe(rawAddressSignalHash); expect(result.legacy_signal_hash).toBe(rawAddressSignalHash); }); + + it("should include identity attributes in native payload from preset", () => { + const rpContext = new WasmModule.RpContextWasm( + "rp_123456789abcdef0", + "0x0000000000000000000000000000000000000000000000000000000000000001", + 1n, + 2n, + "0x" + "00".repeat(64) + "1b", + ); + const builder = WasmModule.request( + "app_staging_test", + "test-action", + rpContext, + null, + null, + false, + null, + null, + null, + ); + + const result = builder.nativePayloadFromPreset( + identityCheck({ + attributes: [ + { type: "minimum_age", value: 21 }, + { type: "nationality", value: "JPN" }, + ], + }), + ) as { + payload: { + identity_attributes: Array<{ type: string; value: number | string }>; + }; + }; + + expect(result.payload.identity_attributes).toEqual([ + { type: "minimum_age", value: 21 }, + { type: "nationality", value: "JPN" }, + ]); + }); }); describe("Enums", () => { diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 8b8c0fa..b5f49d6 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -20,6 +20,7 @@ export { selfieCheckLegacy, proofOfHuman, passport, + identityCheck, // Types type IDKitRequest, type IDKitInviteCodeRequest, @@ -27,6 +28,8 @@ export { type WaitOptions, type RpContext, type Preset, + type IdentityAttribute, + type DocumentType, type OrbLegacyPreset, type SecureDocumentLegacyPreset, type DocumentLegacyPreset, @@ -34,6 +37,7 @@ export { type SelfieCheckLegacyPreset, type ProofOfHumanPreset, type PassportPreset, + type IdentityCheckPreset, } from "./request"; // Config types diff --git a/js/packages/core/src/lib/wasm.ts b/js/packages/core/src/lib/wasm.ts index a3205b6..023cbf5 100644 --- a/js/packages/core/src/lib/wasm.ts +++ b/js/packages/core/src/lib/wasm.ts @@ -60,6 +60,8 @@ export type { CredentialType, ConstraintNode, CredentialRequestType, + DocumentType, + IdentityAttribute, // Preset types Preset, OrbLegacyPreset, @@ -69,6 +71,7 @@ export type { SelfieCheckLegacyPreset, ProofOfHumanPreset, PassportPreset, + IdentityCheckPreset, // Native transport types NativePayloadResult, } from "../../wasm/idkit_wasm.js"; diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index dcba5a2..67f4f3c 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -298,6 +298,8 @@ export function enumerate(...nodes: ConstraintNode[]): { // Re-export preset types from WASM (source of truth in rust/core/src/wasm_bindings.rs) export type { Preset, + IdentityAttribute, + DocumentType, OrbLegacyPreset, SecureDocumentLegacyPreset, DocumentLegacyPreset, @@ -305,11 +307,13 @@ export type { DeviceLegacyPreset, ProofOfHumanPreset, PassportPreset, + IdentityCheckPreset, } from "./lib/wasm"; // Import WASM preset type for function return types import type { Preset, + IdentityAttribute, OrbLegacyPreset, SecureDocumentLegacyPreset, DocumentLegacyPreset, @@ -317,6 +321,7 @@ import type { DeviceLegacyPreset, ProofOfHumanPreset, PassportPreset, + IdentityCheckPreset, } from "./lib/wasm"; /** @@ -454,6 +459,23 @@ export function passport(opts: { signal?: string } = {}): PassportPreset { return { type: "Passport", signal: opts.signal }; } +/** + * Creates an IdentityCheck preset for document-based identity attestation. + * + * This preset requires World ID 4.0-compatible clients. + * + * @param params - Identity attribute filters and proof-of-humanity requirement + * @returns An IdentityCheck preset + */ +export function identityCheck(params: { + attributes: IdentityAttribute[]; +}): IdentityCheckPreset { + return { + type: "IdentityCheck", + attributes: params.attributes, + }; +} + // ───────────────────────────────────────────────────────────────────────────── // WASM builder factory (used for both native and bridge paths) // ───────────────────────────────────────────────────────────────────────────── @@ -1017,4 +1039,6 @@ export const IDKit = { proofOfHuman, /** Create a Passport preset for World ID 4.0 with legacy document fallback */ passport, + /** Create an IdentityCheck preset for World ID 4.0 identity attestation */ + identityCheck, }; diff --git a/js/packages/react/src/index.ts b/js/packages/react/src/index.ts index 04b9a4b..3ead7ee 100644 --- a/js/packages/react/src/index.ts +++ b/js/packages/react/src/index.ts @@ -41,6 +41,7 @@ export { selfieCheckLegacy, proofOfHuman, passport, + identityCheck, IDKitErrorCodes, signRequest, isDebug, @@ -63,4 +64,7 @@ export type { IDKitErrorCode, ProofOfHumanPreset, PassportPreset, + DocumentType, + IdentityAttribute, + IdentityCheckPreset, } from "@worldcoin/idkit-core"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2248c5..5a2e287 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: eruda: specifier: ^3.4.1 version: 3.4.3 + i18n-iso-countries: + specifier: ^7.14.0 + version: 7.14.0 next: specifier: ^15.4.0 version: 15.5.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1019,6 +1022,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + diacritics@1.3.0: + resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==} + dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -1172,6 +1178,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + i18n-iso-countries@7.14.0: + resolution: {integrity: sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==} + engines: {node: '>= 12'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2487,6 +2497,8 @@ snapshots: detect-libc@2.1.2: optional: true + diacritics@1.3.0: {} + dijkstrajs@1.0.3: {} dom-accessibility-api@0.5.16: {} @@ -2680,6 +2692,10 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + i18n-iso-countries@7.14.0: + dependencies: + diacritics: 1.3.0 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index e2cbbff..c497b13 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -725,6 +725,16 @@ impl IDKitConfigWasm { } } +fn validate_v1_preset_support(preset: &Preset) -> Result<(), &'static str> { + if matches!(preset, Preset::IdentityCheck { .. }) { + return Err( + "IdentityCheck presets are not supported for nativePayloadV1FromPreset. Use nativePayloadFromPreset with a World ID 4.0-compatible client instead.", + ); + } + + Ok(()) +} + /// Unified builder for creating `IDKit` requests and sessions (WASM) #[wasm_bindgen(js_name = IDKitBuilder)] pub struct IDKitBuilderWasm { @@ -933,6 +943,8 @@ impl IDKitBuilderWasm { let preset: Preset = serde_wasm_bindgen::from_value(preset_json) .map_err(|e| JsValue::from_str(&format!("Invalid preset: {e}")))?; + validate_v1_preset_support(&preset).map_err(JsValue::from_str)?; + let params = self.config.to_params_from_preset(preset)?; let payload = crate::bridge::build_native_v1_payload(¶ms) @@ -1437,6 +1449,8 @@ export interface IDKitResultV4 { responses: ResponseItemV4[]; /** The environment used for this request ("production" or "staging") */ environment: string; + /** Whether identity attributes were attested. Only present on IdentityCheck responses. */ + identity_attested?: boolean; } /** V4 result for session proofs */ @@ -1542,6 +1556,16 @@ export type Status = // Export preset types #[wasm_bindgen(typescript_custom_section)] const TS_PRESET: &str = r#" +export type DocumentType = "passport" | "eid" | "mnc"; + +export type IdentityAttribute = + | { type: "document_type"; value: DocumentType } + | { type: "document_number"; value: string } + | { type: "issuing_country"; value: string } + | { type: "full_name"; value: string } + | { type: "minimum_age"; value: number } + | { type: "nationality"; value: string }; + export interface OrbLegacyPreset { /** This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. */ type: "OrbLegacy"; @@ -1585,7 +1609,21 @@ export interface PassportPreset { signal?: string; } -export type Preset = OrbLegacyPreset | SecureDocumentLegacyPreset | DocumentLegacyPreset | SelfieCheckLegacyPreset | DeviceLegacyPreset | ProofOfHumanPreset | PassportPreset; +export interface IdentityCheckPreset { + /** This preset requires World ID 4.0-compatible clients. */ + type: "IdentityCheck"; + attributes: IdentityAttribute[]; +} + +export type Preset = + | OrbLegacyPreset + | SecureDocumentLegacyPreset + | DocumentLegacyPreset + | SelfieCheckLegacyPreset + | DeviceLegacyPreset + | ProofOfHumanPreset + | PassportPreset + | IdentityCheckPreset; export function orbLegacy(signal?: string): Preset; export function secureDocumentLegacy(signal?: string): Preset; @@ -1595,6 +1633,9 @@ export function selfieCheckLegacy(signal?: string): Preset; export function deviceLegacy(signal?: string): Preset; export function proofOfHuman(signal?: string): Preset; export function passport(signal?: string): Preset; +export function identityCheck(params: { + attributes: IdentityAttribute[]; +}): Preset; "#; // Export RP signature types @@ -1671,13 +1712,27 @@ export function proveSession( #[cfg(test)] mod tests { - use super::IDKitConfigWasm; - use crate::{ConstraintNode, RpContext}; + use super::{validate_v1_preset_support, IDKitConfigWasm}; + use crate::{types::IdentityAttribute, ConstraintNode, Preset, RpContext}; fn sample_rp_context() -> RpContext { RpContext::new("rp_123456789abcdef0", "0x01", 1, 2, "0x1234").expect("valid rp_context") } + fn sample_request_config() -> IDKitConfigWasm { + 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, + override_connect_base_url: None, + return_to: None, + environment: None, + } + } + #[test] fn request_params_preserve_return_to() { let config = IDKitConfigWasm::Request { @@ -1746,4 +1801,24 @@ mod tests { Some("idkit://callback?step=prove") ); } + + #[test] + fn native_payload_v1_from_preset_rejects_identity_check() { + let preset = Preset::identity_check(vec![IdentityAttribute::MinimumAge(21)]); + + assert!(validate_v1_preset_support(&preset) + .expect_err("identity check should be rejected for v1") + .contains("IdentityCheck presets are not supported")); + } + + #[test] + fn native_payload_v1_from_preset_allows_legacy_presets() { + let config = sample_request_config(); + let preset = Preset::device_legacy(Some("device-signal".to_string())); + + validate_v1_preset_support(&preset).expect("legacy preset should be allowed for v1"); + config + .to_params_from_preset(preset) + .expect("legacy preset should produce a v1 payload"); + } }