diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index 8f9b9575f..8458b7455 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -15,7 +15,9 @@ "check-lint": "npx @biomejs/biome lint ./src", "tauri": "tauri", "storybook": "svelte-kit sync && storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7", + "build:aab": "npm run tauri android build -- --aab --target aarch64 --target armv7" }, "license": "MIT", "dependencies": { diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts b/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts index 38f9dd433..0fe8797c9 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts @@ -18,7 +18,7 @@ android { compileSdk = 36 namespace = "foundation.metastate.eid_wallet" defaultConfig { - manifestPlaceholders["usesCleartextTraffic"] = "false" + manifestPlaceholders["usesCleartextTraffic"] = "true" applicationId = "foundation.metastate.eid_wallet" minSdk = 24 targetSdk = 36 diff --git a/infrastructure/eid-wallet/src/lib/ui/BottomSheet/BottomSheet.svelte b/infrastructure/eid-wallet/src/lib/ui/BottomSheet/BottomSheet.svelte index 5e1e4f624..43ed5a191 100644 --- a/infrastructure/eid-wallet/src/lib/ui/BottomSheet/BottomSheet.svelte +++ b/infrastructure/eid-wallet/src/lib/ui/BottomSheet/BottomSheet.svelte @@ -6,6 +6,7 @@ import type { HTMLAttributes } from "svelte/elements"; interface BottomSheetProps extends HTMLAttributes { isOpen?: boolean; dismissible?: boolean; + fullScreen?: boolean; children?: Snippet; onOpenChange?: (value: boolean) => void; } @@ -13,6 +14,7 @@ interface BottomSheetProps extends HTMLAttributes { let { isOpen = $bindable(false), dismissible = true, + fullScreen = false, children = undefined, onOpenChange, ...restProps @@ -45,10 +47,14 @@ $effect(() => { role="dialog" aria-modal="true" class={cn( - "fixed inset-x-0 bottom-0 z-50 bg-white rounded-t-3xl shadow-xl flex flex-col gap-4 max-h-[88svh] overflow-y-auto", + fullScreen + ? "fixed inset-0 z-50 bg-white shadow-xl flex flex-col gap-4 overflow-hidden" + : "fixed inset-x-0 bottom-0 z-50 bg-white rounded-t-3xl shadow-xl flex flex-col gap-4 max-h-[88svh] overflow-y-auto", restProps.class, )} - style={`padding: 1.5rem 1.5rem max(1.5rem, env(safe-area-inset-bottom)); ${restProps.style ?? ""}`} + style={`${fullScreen + ? "padding: max(1rem, env(safe-area-inset-top)) 1.5rem max(1.5rem, env(safe-area-inset-bottom));" + : "padding: 1.5rem 1.5rem max(1.5rem, env(safe-area-inset-bottom));"} ${restProps.style ?? ""}`} > {@render children?.()} diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/components/AuthDrawer.svelte b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/components/AuthDrawer.svelte index 68652652a..ca1a5186d 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/components/AuthDrawer.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/components/AuthDrawer.svelte @@ -32,10 +32,11 @@ $: if (internalOpen !== lastReportedOpen) { -
-
+
+
@@ -54,8 +55,8 @@ $: if (internalOpen !== lastReportedOpen) { />
-

Code scanned!

-

+

Code scanned!

+

Please review the connection details below.

@@ -65,7 +66,7 @@ $: if (internalOpen !== lastReportedOpen) { - -
+
@@ -80,7 +81,7 @@ $: if (internalOpen !== lastReportedOpen) {
+
@@ -128,7 +129,7 @@ $: if (internalOpen !== lastReportedOpen) { {/if}
-
+
diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/components/LoggedInDrawer.svelte b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/components/LoggedInDrawer.svelte index 7f33a4f22..a09f9c8ea 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/components/LoggedInDrawer.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/components/LoggedInDrawer.svelte @@ -27,13 +27,14 @@ $: if (internalOpen !== lastReportedOpen) { -
-
+
+
@@ -52,10 +53,10 @@ $: if (internalOpen !== lastReportedOpen) { />
-

+

You're logged in!

-

+

You're now connected to {platform ?? "the platform"}

@@ -70,7 +71,7 @@ $: if (internalOpen !== lastReportedOpen) {
-
+
-
-
+
+
+

Vote Decrypted

-

+

Your selection has been successfully retrieved.

@@ -93,8 +94,8 @@ $: if (internalOpen !== lastReportedOpen) {
{:else} -

Reveal Your Blind Vote

-

+

Reveal Your Blind Vote

+

Please review the request details below.

@@ -105,7 +106,7 @@ $: if (internalOpen !== lastReportedOpen) { - {#if signingData?.pollId} - - - -
+
@@ -145,7 +146,7 @@ $: if (internalOpen !== lastReportedOpen) { {/if}
-
+
{#if revealSuccess} -
-
+
+
@@ -96,7 +97,7 @@ $: hasPollDetails = {/if} -

+

{#if showSigningSuccess} Your request was processed successfully. {:else} @@ -111,7 +112,7 @@ $: hasPollDetails =

+
@@ -128,7 +129,7 @@ $: hasPollDetails = {#if isBlindVotingRequest && hasPollDetails}
+
@@ -145,7 +146,7 @@ $: hasPollDetails = {#if signingData?.message && !signingData?.pollId}
+
@@ -159,7 +160,7 @@ $: hasPollDetails =
+
@@ -222,7 +223,7 @@ $: hasPollDetails = {/if}
-
+
{#if showSigningSuccess} -
-
+
+
{success ? "Binding Signed!" : "Social Identity Binding"} -

+

{#if success} You've signed the social identity binding for {displayName}. They will counter-sign to complete the mutual binding. @@ -89,7 +90,7 @@ $: displayName = requesterName ?? requesterEname ?? "Unknown"; - {#if requesterEname} -
+
@@ -104,7 +105,7 @@ $: displayName = requesterName ?? requesterEname ?? "Unknown";
+
@@ -132,7 +133,7 @@ $: displayName = requesterName ?? requesterEname ?? "Unknown"; {/if}
-
+
{#if success} { const recovery = get(pendingRecovery); if (recovery) { localStorage.setItem(RECOVERY_SKIP_PROFILE_SETUP_KEY, "true"); + // Recovery can happen on a fresh device, so make sure a key exists + // and is synced immediately before entering the app. + await globalState.walletSdkAdapter.ensureKey("default", "onboarding"); globalState.vaultController.vault = { uri: recovery.uri, ename: recovery.ename, }; + await globalState.vaultController.syncPublicKey(recovery.ename); pendingRecovery.set(null); } await goto("/main"); diff --git a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte index b04246e5a..befa50ad5 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte @@ -2,7 +2,7 @@ import { goto } from "$app/navigation"; import { Hero } from "$lib/fragments"; import type { GlobalState } from "$lib/global"; -import { BottomSheet, InputPin } from "$lib/ui"; +import { InputPin } from "$lib/ui"; import * as Button from "$lib/ui/Button"; import { type AuthOptions, @@ -18,7 +18,6 @@ let clearPin = $state(async () => {}); let handlePinInput = $state((pin: string) => {}); let globalState: GlobalState | undefined = $state(undefined); let hasPendingDeepLink = $state(false); -let isDeletedVaultModalOpen = $state(false); const authOpts: AuthOptions = { allowDeviceCredential: false, @@ -38,15 +37,6 @@ const getGlobalState = getContext<() => GlobalState>("globalState"); const setGlobalState = getContext<(value: GlobalState) => void>("setGlobalState"); -async function nukeWallet() { - if (!globalState) return; - const newGlobalState = await globalState.reset(); - setGlobalState(newGlobalState); - globalState = newGlobalState; - isDeletedVaultModalOpen = false; - await goto("/onboarding"); -} - onMount(async () => { globalState = getContext<() => GlobalState>("globalState")(); if (!globalState) { @@ -96,11 +86,6 @@ onMount(async () => { healthCheck.error, ); - // If eVault was deleted (404), show modal - if (healthCheck.deleted) { - isDeletedVaultModalOpen = true; - return; // Don't continue to app - } // For other errors, continue to app - non-blocking } @@ -181,11 +166,6 @@ onMount(async () => { healthCheck.error, ); - // If eVault was deleted (404), show modal - if (healthCheck.deleted) { - isDeletedVaultModalOpen = true; - return; // Don't continue to app - } // For other errors, continue to app - non-blocking } @@ -307,37 +287,3 @@ onMount(async () => { {/if} - - -
-

- eVault Has Been Deleted -

-

- Your eVault has been deleted from the registry and is no longer - accessible. -

-
-

- To continue using the app, you need to delete your local account - data and start fresh. -

-
-
    -
  • • All your local data will be deleted
  • -
  • • Your ePassport will be removed
  • -
  • • You will need to onboard again
  • -
  • • This action cannot be undone
  • -
-

- You must delete your local data to continue. -

-
- Delete Local Data -
-
-
diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index f99fc02ee..5cd776aac 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -89,9 +89,13 @@ let showNotaryDrawer = $state(false); let diditLocalId = $state(null); let diditSessionId = $state(null); let diditActualSessionId = $state(null); // real Didit sessionId from onComplete -let diditResult = $state<"approved" | "declined" | "in_review" | null>(null); +let diditResult = $state< + "approved" | "declined" | "in_review" | "duplicate" | null +>(null); let diditDecision = $state(null); let diditRejectionReason = $state(null); +let duplicateExistingW3id = $state(null); +let duplicateDocumentNumber = $state(null); // Upgrade mode — set when ?upgrade=1 is present (existing eVault KYC upgrade) let upgradeMode = $state(false); @@ -139,6 +143,8 @@ const handleIdentityPath = async () => { const handleKycNext = async () => { loading = true; error = null; + duplicateExistingW3id = null; + duplicateDocumentNumber = null; try { await globalState.walletSdkAdapter.ensureKey(KEY_ID, "onboarding"); @@ -222,6 +228,8 @@ const handleDiditComplete = async (result: DiditCompleteResult) => { console.log("[Didit] decision:", decision); diditDecision = decision; + duplicateExistingW3id = null; + duplicateDocumentNumber = null; const rawStatus: string = decision.status ?? ""; diditResult = rawStatus.toLowerCase().replace(" ", "_") as | "approved" @@ -247,6 +255,34 @@ const handleDiditComplete = async (result: DiditCompleteResult) => { } }; +const lookupDuplicateByDocument = async () => { + const sessionId = + diditActualSessionId ?? + diditDecision?.session_id ?? + diditDecision?.session?.sessionId; + if (!sessionId) return; + + try { + const { data } = await axios.get( + new URL( + `/verification/v2/lookup-by-document/${sessionId}`, + PUBLIC_PROVISIONER_URL, + ).toString(), + { + headers: { + "x-shared-secret": PUBLIC_PROVISIONER_SHARED_SECRET, + }, + }, + ); + if (data?.success) { + duplicateExistingW3id = data.existingW3id ?? null; + duplicateDocumentNumber = data.documentNumber ?? null; + } + } catch (lookupErr) { + console.warn("[Onboarding] duplicate lookup failed:", lookupErr); + } +}; + const handleProvision = async () => { if (!diditDecision || !diditLocalId) return; @@ -289,6 +325,8 @@ const handleProvision = async () => { }); if (result.duplicate) { + await lookupDuplicateByDocument(); + diditResult = "duplicate"; error = "An eVault already exists for this identity. You cannot create a duplicate — please reclaim your existing eVault instead."; step = "verif-result"; @@ -1338,6 +1376,44 @@ onMount(() => { {upgradeMode ? "Back to ePassport" : "Back to Start"}
+ {:else if diditResult === "duplicate"} +
+
+ ! +
+

Identity Already Registered

+
+

+ This identity document is already linked to an existing eVault. + Please recover that eVault instead of creating a duplicate. +

+ {#if duplicateDocumentNumber} +

+ Document: {duplicateDocumentNumber} +

+ {/if} + {#if duplicateExistingW3id} +
+

Existing eVault eName

+

{duplicateExistingW3id}

+
+ {/if} +
+ goto("/recover")}> + Recover existing eVault + + { + step = "home"; + }} + > + Back to Start + +
{:else}
{ + const registryUrl = process.env.PUBLIC_REGISTRY_URL; + const platformName = process.env.PLATFORM_NAME ?? "provisioner"; + if (!registryUrl) throw new Error("PUBLIC_REGISTRY_URL not set"); + const tokenRes = await Axios.post( + new URL("/platforms/certification", registryUrl).toString(), + { platform: platformName }, + { headers: { "Content-Type": "application/json" } }, + ); + return tokenRes.data.token as string; + } + + private async fetchBindingDocumentTypes( + w3id: string, + token: string, + ): Promise> { + const evaultUrl = process.env.PUBLIC_EVAULT_SERVER_URI; + if (!evaultUrl) return new Set(); + const subject = w3id.startsWith("@") ? w3id : `@${w3id}`; + const gqlUrl = new URL("/graphql", evaultUrl).toString(); + + const gqlRes = await Axios.post( + gqlUrl, + { + query: `query { + bindingDocuments(first: 50) { + edges { node { parsed } } + } + }`, + }, + { + headers: { + "Content-Type": "application/json", + "X-ENAME": subject, + Authorization: `Bearer ${token}`, + }, + }, + ); + + const edges: { node: { parsed: { type?: string } | null } }[] = + gqlRes.data?.data?.bindingDocuments?.edges ?? []; + const types = new Set(); + for (const edge of edges) { + const t = edge?.node?.parsed?.type; + if (typeof t === "string") types.add(t); + } + return types; + } + + private async createIdDocumentBindingDocument( + w3id: string, + reference: string, + fullName: string, + token: string, + ): Promise { + const evaultUrl = process.env.PUBLIC_EVAULT_SERVER_URI; + if (!evaultUrl) return; + const subject = w3id.startsWith("@") ? w3id : `@${w3id}`; + const data = { vendor: "didit", reference, name: fullName }; + const ownerSignature = signAsProvisioner({ + subject, + type: "id_document", + data: data as any, + }); + + const gqlUrl = new URL("/graphql", evaultUrl).toString(); + await Axios.post( + gqlUrl, + { + query: `mutation CreateBindingDocument($input: CreateBindingDocumentInput!) { + createBindingDocument(input: $input) { + metaEnvelopeId + errors { message code } + } + }`, + variables: { + input: { subject, type: "id_document", data, ownerSignature }, + }, + }, + { + headers: { + "Content-Type": "application/json", + "X-ENAME": subject, + Authorization: `Bearer ${token}`, + }, + }, + ); + } + + private async createPhotographBindingDocument( + w3id: string, + portraitImageUrl: string, + token: string, + apiKey: string, + ): Promise { + const evaultUrl = process.env.PUBLIC_EVAULT_SERVER_URI; + if (!evaultUrl) return; + const subject = w3id.startsWith("@") ? w3id : `@${w3id}`; + const imageResponse = await diditClient.get(portraitImageUrl, { + headers: { "x-api-key": apiKey }, + responseType: "arraybuffer", + }); + const contentType = + (imageResponse.headers["content-type"] as string | undefined) ?? + "image/jpeg"; + const base64 = Buffer.from(imageResponse.data as ArrayBuffer).toString( + "base64", + ); + const photoBlob = `data:${contentType};base64,${base64}`; + const data = { photoBlob }; + const ownerSignature = signAsProvisioner({ + subject, + type: "photograph", + data: data as any, + }); + + const gqlUrl = new URL("/graphql", evaultUrl).toString(); + await Axios.post( + gqlUrl, + { + query: `mutation CreateBindingDocument($input: CreateBindingDocumentInput!) { + createBindingDocument(input: $input) { + metaEnvelopeId + errors { message code } + } + }`, + variables: { + input: { subject, type: "photograph", data, ownerSignature }, + }, + }, + { + headers: { + "Content-Type": "application/json", + "X-ENAME": subject, + Authorization: `Bearer ${token}`, + }, + }, + ); + } + + private async ensureRecoveryBindingDocuments( + w3id: string, + idVerif: any, + diditSessionId: string, + apiKey: string, + ): Promise { + const fullName = ( + idVerif?.full_name ?? + `${idVerif?.first_name ?? ""} ${idVerif?.last_name ?? ""}` + ).trim(); + const portraitUrl: string = idVerif?.portrait_image ?? ""; + + if (!fullName && !portraitUrl) return; + + const token = await this.getPlatformToken(); + const existingTypes = await this.fetchBindingDocumentTypes(w3id, token); + + if (!existingTypes.has("id_document") && fullName) { + await this.createIdDocumentBindingDocument( + w3id, + diditSessionId, + fullName, + token, + ); + console.log(`[RECOVERY] Backfilled id_document for ${w3id}`); + } + + if (!existingTypes.has("photograph") && portraitUrl) { + await this.createPhotographBindingDocument( + w3id, + portraitUrl, + token, + apiKey, + ); + console.log(`[RECOVERY] Backfilled photograph for ${w3id}`); + } + } + registerRoutes(app: any) { /** * POST /recovery/start-session @@ -96,6 +295,10 @@ export class RecoveryController { `/v3/session/${encodeURIComponent(diditSessionId)}/decision/`, { headers: { "x-api-key": apiKey } }, ); + const recoveryIdVerif = decision?.id_verifications?.[0] ?? null; + const recoveryDocumentNumber = this.normalizeDocumentNumber( + recoveryIdVerif?.document_number, + ); const liveness = decision?.liveness_checks?.[0]; if (!liveness || liveness.status?.toLowerCase() !== "approved") { @@ -195,23 +398,72 @@ export class RecoveryController { success: true, w3id: record.linkedEName, uri: evaultUrl, - idVerif: idVerif - ? { - full_name: idVerif.full_name, - first_name: idVerif.first_name, - last_name: idVerif.last_name, - date_of_birth: idVerif.date_of_birth, - document_type: idVerif.document_type, - document_number: idVerif.document_number, - issuing_state_name: idVerif.issuing_state_name, - issuing_state: idVerif.issuing_state, - expiration_date: idVerif.expiration_date, - date_of_issue: idVerif.date_of_issue, - } - : null, + idVerif: this.toIdVerifPayload(idVerif), }); } + // Fallback for older or low-quality captures: + // try recovering by normalized document number. + if (recoveryDocumentNumber) { + let [docMatches] = + await this.verificationService.findManyAndCount({ + documentId: recoveryDocumentNumber, + }); + + // Backward compatibility for historical non-normalized rows + if (docMatches.length === 0) { + const rawDocumentNumber = + typeof recoveryIdVerif?.document_number === "string" + ? recoveryIdVerif.document_number + : ""; + if ( + rawDocumentNumber && + rawDocumentNumber !== recoveryDocumentNumber + ) { + [docMatches] = + await this.verificationService.findManyAndCount({ + documentId: rawDocumentNumber, + }); + } + } + + const linkedMatches = docMatches + .filter((v) => !!v.linkedEName) + .sort( + (a, b) => + new Date(b.updatedAt).getTime() - + new Date(a.updatedAt).getTime(), + ); + const fallbackRecord = linkedMatches[0]; + + if (fallbackRecord?.linkedEName) { + // Ensure recovery artifacts exist when dumb fallback succeeds. + try { + await this.ensureRecoveryBindingDocuments( + fallbackRecord.linkedEName, + recoveryIdVerif, + diditSessionId, + apiKey, + ); + } catch (ensureErr: any) { + console.warn( + "[RECOVERY] Could not ensure binding docs during fallback:", + ensureErr?.response?.data ?? ensureErr?.message, + ); + } + + console.log( + `[RECOVERY] eVault found via document fallback: eName=${fallbackRecord.linkedEName}`, + ); + return res.json({ + success: true, + w3id: fallbackRecord.linkedEName, + uri: evaultUrl, + idVerif: this.toIdVerifPayload(recoveryIdVerif), + }); + } + } + console.warn("[RECOVERY] No matching eVault found above threshold"); return res.json({ success: false, reason: "no_match" }); } catch (err: any) { diff --git a/infrastructure/evault-core/src/controllers/RecoveryDocumentFallback.spec.ts b/infrastructure/evault-core/src/controllers/RecoveryDocumentFallback.spec.ts new file mode 100644 index 000000000..c048a22bd --- /dev/null +++ b/infrastructure/evault-core/src/controllers/RecoveryDocumentFallback.spec.ts @@ -0,0 +1,156 @@ +import "reflect-metadata"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + diditGet: vi.fn(), + diditPost: vi.fn(), + axiosPost: vi.fn(), +})); + +vi.mock("axios", () => { + return { + default: { + create: vi.fn(() => ({ + get: mocks.diditGet, + post: mocks.diditPost, + })), + post: mocks.axiosPost, + }, + }; +}); + +import { RecoveryController } from "./RecoveryController"; + +describe("Recovery document fallback", () => { + const previousEnv = { + DIDIT_API_KEY: process.env.DIDIT_API_KEY, + PUBLIC_EVAULT_SERVER_URI: process.env.PUBLIC_EVAULT_SERVER_URI, + PUBLIC_REGISTRY_URL: process.env.PUBLIC_REGISTRY_URL, + PLATFORM_NAME: process.env.PLATFORM_NAME, + }; + + beforeEach(() => { + process.env.DIDIT_API_KEY = "test-api-key"; + process.env.PUBLIC_EVAULT_SERVER_URI = "https://evault.example.com"; + process.env.PUBLIC_REGISTRY_URL = "https://registry.example.com"; + process.env.PLATFORM_NAME = "provisioner"; + mocks.diditGet.mockReset(); + mocks.diditPost.mockReset(); + mocks.axiosPost.mockReset(); + }); + + afterEach(() => { + process.env.DIDIT_API_KEY = previousEnv.DIDIT_API_KEY; + process.env.PUBLIC_EVAULT_SERVER_URI = previousEnv.PUBLIC_EVAULT_SERVER_URI; + process.env.PUBLIC_REGISTRY_URL = previousEnv.PUBLIC_REGISTRY_URL; + process.env.PLATFORM_NAME = previousEnv.PLATFORM_NAME; + }); + + it("recovers by uppercased document number when face-search has no matches", async () => { + const verificationServiceStub = { + create: async () => ({}), + findByIdAndUpdate: async () => null, + findOne: async () => null, + findManyAndCount: vi.fn(async ({ documentId }: { documentId: string }) => { + if (documentId === "CAA000000") { + return [[{ + linkedEName: "@existing-recovery-user", + updatedAt: new Date(), + }], 1]; + } + return [[], 0]; + }), + } as any; + + mocks.diditGet.mockImplementation(async (url: string) => { + if (url.includes("/decision/")) { + return { + data: { + liveness_checks: [ + { + status: "Approved", + reference_image: "https://example.com/reference.jpg", + }, + ], + id_verifications: [ + { + document_number: "caa000000", + full_name: "Case Test", + }, + ], + }, + }; + } + return { + data: Buffer.from("image-data"), + headers: { + "content-type": "image/jpeg", + }, + }; + }); + + mocks.diditPost.mockResolvedValue({ + data: { + face_search: { + matches: [], + }, + }, + }); + + mocks.axiosPost + .mockResolvedValueOnce({ data: { token: "platform-token" } }) // /platforms/certification + .mockResolvedValueOnce({ + data: { + data: { + bindingDocuments: { + edges: [], + }, + }, + }, + }) // query existing binding docs + .mockResolvedValueOnce({ + data: { + data: { + createBindingDocument: { + errors: [], + }, + }, + }, + }); // create id_document if missing + + const app = express(); + app.use(express.json()); + new RecoveryController(verificationServiceStub).registerRoutes(app); + + const server = app.listen(0); + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + try { + const response = await fetch(`${baseUrl}/recovery/face-search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + diditSessionId: "11111111-2222-4333-8444-555555555555", + }), + }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.w3id).toBe("@existing-recovery-user"); + expect(body.idVerif?.document_number).toBe("CAA000000"); + expect(mocks.axiosPost).toHaveBeenCalled(); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + }); +}); diff --git a/infrastructure/evault-core/src/controllers/SessionIdValidation.spec.ts b/infrastructure/evault-core/src/controllers/SessionIdValidation.spec.ts index dc84212e5..6959f6c2a 100644 --- a/infrastructure/evault-core/src/controllers/SessionIdValidation.spec.ts +++ b/infrastructure/evault-core/src/controllers/SessionIdValidation.spec.ts @@ -98,4 +98,45 @@ describe("Session ID validation in controllers", () => { }); } }); + + it("rejects invalid sessionId in /verification/v2/lookup-by-document/:sessionId", async () => { + const app = express(); + app.use(express.json()); + + const verificationServiceStub = { + findById: async () => null, + findOne: async () => null, + create: async () => ({}), + findByIdAndUpdate: async () => null, + findManyAndCount: async () => [[], 0], + } as any; + + new VerificationController(verificationServiceStub).registerRoutes(app); + + const server = app.listen(0); + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + try { + const response = await fetch( + `${baseUrl}/verification/v2/lookup-by-document/not-a-uuid`, + { + method: "GET", + headers: { + "x-shared-secret": "test-shared-secret", + }, + }, + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toContain("valid UUID"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + }); }); diff --git a/infrastructure/evault-core/src/controllers/VerificationController.ts b/infrastructure/evault-core/src/controllers/VerificationController.ts index f6215d52a..cfcaca7de 100644 --- a/infrastructure/evault-core/src/controllers/VerificationController.ts +++ b/infrastructure/evault-core/src/controllers/VerificationController.ts @@ -8,6 +8,10 @@ const diditClient = Axios.create({ baseURL: "https://verification.didit.me", }); +function normalizeDocumentNumber(value: unknown): string { + return typeof value === "string" ? value.trim().toUpperCase() : ""; +} + function requireSharedSecret(req: Request, res: Response): boolean { const secret = process.env.PROVISIONER_SHARED_SECRET; if (!secret) { @@ -158,5 +162,91 @@ export class VerificationController { ); } }); + + // Resolve duplicate onboarding record by document number from Didit session decision + app.get( + "/verification/v2/lookup-by-document/:sessionId", + async (req: Request, res: Response) => { + if (!requireSharedSecret(req, res)) return; + const { sessionId } = req.params; + if (!uuidValidate(sessionId)) { + return res.status(400).json({ + error: "sessionId must be a valid UUID", + }); + } + + const apiKey = process.env.DIDIT_API_KEY; + if (!apiKey) { + return res + .status(500) + .json({ error: "DIDIT_API_KEY not configured" }); + } + + try { + const { data: decision } = await diditClient.get( + `/v3/session/${encodeURIComponent(sessionId)}/decision/`, + { headers: { "x-api-key": apiKey } }, + ); + const rawDocumentNumber = + decision?.id_verifications?.[0]?.document_number; + const normalizedDocumentNumber = + normalizeDocumentNumber(rawDocumentNumber); + + if (!normalizedDocumentNumber) { + return res.json({ + success: false, + reason: "missing_document_number", + }); + } + + let [matches] = + await this.verificationService.findManyAndCount({ + documentId: normalizedDocumentNumber, + }); + + // Backward compatibility for non-normalized historic rows. + if ( + matches.length === 0 && + typeof rawDocumentNumber === "string" && + rawDocumentNumber !== normalizedDocumentNumber + ) { + [matches] = + await this.verificationService.findManyAndCount({ + documentId: rawDocumentNumber, + }); + } + + const existing = matches + .filter((v) => !!v.linkedEName) + .sort( + (a, b) => + new Date(b.updatedAt).getTime() - + new Date(a.updatedAt).getTime(), + )[0]; + + if (!existing?.linkedEName) { + return res.json({ success: false, reason: "no_match" }); + } + + return res.json({ + success: true, + existingW3id: existing.linkedEName, + documentNumber: normalizedDocumentNumber, + }); + } catch (err: any) { + console.error( + "[LOOKUP BY DOCUMENT]", + err?.response?.data ?? err?.message, + ); + return res + .status(err?.response?.status ?? 500) + .json( + err?.response?.data ?? { + error: "Failed to lookup by document", + }, + ); + } + }, + ); } } diff --git a/infrastructure/evault-core/src/controllers/VerificationLookupByDocument.spec.ts b/infrastructure/evault-core/src/controllers/VerificationLookupByDocument.spec.ts new file mode 100644 index 000000000..5261b55be --- /dev/null +++ b/infrastructure/evault-core/src/controllers/VerificationLookupByDocument.spec.ts @@ -0,0 +1,99 @@ +import "reflect-metadata"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + diditGet: vi.fn(), +})); + +vi.mock("axios", () => { + return { + default: { + create: vi.fn(() => ({ + get: mocks.diditGet, + post: vi.fn(), + })), + post: vi.fn(), + }, + }; +}); + +import { VerificationController } from "./VerificationController"; + +describe("Verification lookup-by-document v2", () => { + const previousEnv = { + PROVISIONER_SHARED_SECRET: process.env.PROVISIONER_SHARED_SECRET, + DIDIT_API_KEY: process.env.DIDIT_API_KEY, + }; + + beforeEach(() => { + process.env.PROVISIONER_SHARED_SECRET = "test-shared-secret"; + process.env.DIDIT_API_KEY = "test-api-key"; + mocks.diditGet.mockReset(); + }); + + afterEach(() => { + process.env.PROVISIONER_SHARED_SECRET = previousEnv.PROVISIONER_SHARED_SECRET; + process.env.DIDIT_API_KEY = previousEnv.DIDIT_API_KEY; + }); + + it("returns existingW3id using uppercase-normalized document number", async () => { + const verificationServiceStub = { + findById: async () => null, + findOne: async () => null, + create: async () => ({}), + findByIdAndUpdate: async () => null, + findManyAndCount: vi.fn(async ({ documentId }: { documentId: string }) => { + if (documentId === "CAA000000") { + return [[{ + linkedEName: "@existing-user", + updatedAt: new Date(), + }], 1]; + } + return [[], 0]; + }), + } as any; + + mocks.diditGet.mockResolvedValue({ + data: { + id_verifications: [ + { + document_number: "caa000000", + }, + ], + }, + }); + + const app = express(); + app.use(express.json()); + new VerificationController(verificationServiceStub).registerRoutes(app); + + const server = app.listen(0); + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + try { + const response = await fetch( + `${baseUrl}/verification/v2/lookup-by-document/11111111-2222-4333-8444-555555555555`, + { + headers: { + "x-shared-secret": "test-shared-secret", + }, + }, + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.existingW3id).toBe("@existing-user"); + expect(body.documentNumber).toBe("CAA000000"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + }); +}); diff --git a/infrastructure/evault-core/src/services/ProvisioningService.ts b/infrastructure/evault-core/src/services/ProvisioningService.ts index cfbd063da..327a7b612 100644 --- a/infrastructure/evault-core/src/services/ProvisioningService.ts +++ b/infrastructure/evault-core/src/services/ProvisioningService.ts @@ -27,6 +27,10 @@ export interface ProvisionResponse { export class ProvisioningService { constructor(private verificationService: VerificationService) {} + private normalizeDocumentNumber(value: unknown): string { + return typeof value === "string" ? value.trim().toUpperCase() : ""; + } + private async checkForDuplicateIdentity( idVerif: any, documentNumber?: string, @@ -50,8 +54,10 @@ export class ProvisioningService { } if (documentNumber) { + const normalizedDocumentNumber = + this.normalizeDocumentNumber(documentNumber); const [docMatches] = await this.verificationService.findManyAndCount( - { documentId: documentNumber }, + { documentId: normalizedDocumentNumber }, ); const existing = docMatches.find( (v) => @@ -297,7 +303,9 @@ export class ProvisioningService { const firstName = idVerif?.first_name ?? ""; const lastName = idVerif?.last_name ?? ""; const fullName = (idVerif?.full_name ?? `${firstName} ${lastName}`).trim(); - const documentNumber: string = idVerif?.document_number ?? ""; + const documentNumber = this.normalizeDocumentNumber( + idVerif?.document_number, + ); const duplicateCheck = await this.checkForDuplicateIdentity( idVerif, @@ -503,7 +511,9 @@ export class ProvisioningService { } const idVerif = decision.id_verifications?.[0]; - const documentNumber: string = idVerif?.document_number ?? ""; + const documentNumber = this.normalizeDocumentNumber( + idVerif?.document_number, + ); const duplicateCheck = await this.checkForDuplicateIdentity( idVerif, documentNumber,