From 374462dd60a435fcca91d29f27472be6444ee1dd Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Thu, 24 Sep 2026 20:21:19 +0300 Subject: [PATCH 1/3] fix(eid-wallet): show the identity verification screens in the app's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Didit renders its own UI, so the translations added in #1145 never reached the verification screens: a Russian user saw English from the moment verification started. Didit takes a `language` on session creation and supports both ru and uk under the same ISO 639-1 codes the app already uses. Without it Didit falls back to detecting the browser language, which does not work inside the Tauri webview. The locale has to travel from the wallet through the provisioner, so this touches both sides. The wallet sends its active locale on the four calls that open a session — ePassport, onboarding, the KYC upgrade overlay and account recovery — and evault-core forwards it to Didit. Anything that is not a plausible ISO 639-1 code falls back to `en` rather than being handed to Didit as-is. Recovery is the one that mattered most: someone recovering an account is already in trouble, and that was the worst place to drop them into English. Closes #1153 --- .../src/routes/(app)/ePassport/+page.svelte | 3 ++- .../(app)/main/legacy/KycUpgradeOverlay.svelte | 3 ++- .../src/routes/(auth)/onboarding/+page.svelte | 3 ++- .../src/routes/(public)/recover/+page.svelte | 1 + .../src/controllers/RecoveryController.ts | 7 ++++++- .../src/controllers/VerificationController.ts | 4 +++- .../src/utils/verificationLanguage.spec.ts | 15 +++++++++++++++ .../evault-core/src/utils/verificationLanguage.ts | 15 +++++++++++++++ 8 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 infrastructure/evault-core/src/utils/verificationLanguage.spec.ts create mode 100644 infrastructure/evault-core/src/utils/verificationLanguage.ts diff --git a/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte index 42a222d48..f363ff60c 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte @@ -7,6 +7,7 @@ import { import { AppNav, IdentityCard } from "$lib/fragments"; import type { GlobalState } from "$lib/global"; import { m } from "$lib/i18n"; +import { getLocale } from "$lib/paraglide/runtime"; import { ButtonAction, CopyableEName } from "$lib/ui"; import { addCounterpartySignature, @@ -178,7 +179,7 @@ async function startKycUpgrade() { const { data } = await axios.post( new URL("/verification/v2", PUBLIC_PROVISIONER_URL).toString(), - {}, + { language: getLocale() }, { headers: { "x-shared-secret": PUBLIC_PROVISIONER_SHARED_SECRET, diff --git a/infrastructure/eid-wallet/src/routes/(app)/main/legacy/KycUpgradeOverlay.svelte b/infrastructure/eid-wallet/src/routes/(app)/main/legacy/KycUpgradeOverlay.svelte index 19c2ff2cf..7b8b1c581 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/legacy/KycUpgradeOverlay.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/legacy/KycUpgradeOverlay.svelte @@ -14,6 +14,7 @@ import { } from "$env/static/public"; import type { GlobalState } from "$lib/global"; import { m } from "$lib/i18n"; +import { getLocale } from "$lib/paraglide/runtime"; import { CopyableEName } from "$lib/ui"; import * as Button from "$lib/ui/Button"; import { capitalize } from "$lib/utils"; @@ -164,7 +165,7 @@ async function startKycUpgrade() { const { data } = await axios.post( new URL("/verification/v2", PUBLIC_PROVISIONER_URL).toString(), - {}, + { language: getLocale() }, { headers: { "x-shared-secret": PUBLIC_PROVISIONER_SHARED_SECRET, diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index 8dbde4543..98f0de741 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -9,6 +9,7 @@ import { import { Hero } from "$lib/fragments"; import { GlobalState } from "$lib/global"; import { m } from "$lib/i18n"; +import { getLocale } from "$lib/paraglide/runtime"; import { pendingRecovery } from "$lib/stores/pendingRecovery"; import { ButtonAction, CopyableEName, LoadingSheet } from "$lib/ui"; import { capitalize, getCanonicalBindingDocString } from "$lib/utils"; @@ -458,7 +459,7 @@ const handleKycNext = async () => { const { data } = await axios.post( new URL("/verification/v2", PUBLIC_PROVISIONER_URL).toString(), - {}, + { language: getLocale() }, { headers: { "x-shared-secret": PUBLIC_PROVISIONER_SHARED_SECRET, diff --git a/infrastructure/eid-wallet/src/routes/(public)/recover/+page.svelte b/infrastructure/eid-wallet/src/routes/(public)/recover/+page.svelte index 4f966446b..770814676 100644 --- a/infrastructure/eid-wallet/src/routes/(public)/recover/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(public)/recover/+page.svelte @@ -206,6 +206,7 @@ async function startVerifiedRecovery() { "/recovery/start-session", PUBLIC_PROVISIONER_URL, ).toString(), + { language: getLocale() }, ); if (!data.verificationUrl) { diff --git a/infrastructure/evault-core/src/controllers/RecoveryController.ts b/infrastructure/evault-core/src/controllers/RecoveryController.ts index 96fb631f6..c176eec09 100644 --- a/infrastructure/evault-core/src/controllers/RecoveryController.ts +++ b/infrastructure/evault-core/src/controllers/RecoveryController.ts @@ -4,6 +4,7 @@ import FormData from "form-data"; import { validate as uuidValidate } from "uuid"; import type { VerificationService } from "../services/VerificationService"; import { signAsProvisioner } from "../core/utils/provisioner-signer"; +import { verificationLanguage } from "../utils/verificationLanguage"; const diditClient = Axios.create({ baseURL: "https://verification.didit.me" }); @@ -229,7 +230,11 @@ export class RecoveryController { const { data: diditSession } = await diditClient.post( "/v3/session/", - { workflow_id: workflowId, vendor_data: verification.id }, + { + workflow_id: workflowId, + vendor_data: verification.id, + language: verificationLanguage(req.body?.language), + }, { headers: { "x-api-key": apiKey, "Content-Type": "application/json" } }, ); diff --git a/infrastructure/evault-core/src/controllers/VerificationController.ts b/infrastructure/evault-core/src/controllers/VerificationController.ts index cfcaca7de..b0b3314bb 100644 --- a/infrastructure/evault-core/src/controllers/VerificationController.ts +++ b/infrastructure/evault-core/src/controllers/VerificationController.ts @@ -3,6 +3,7 @@ import { default as Axios } from "axios"; import { validate as uuidValidate } from "uuid"; import { VerificationService } from "../services/VerificationService"; import type { ProvisioningService } from "../services/ProvisioningService"; +import { verificationLanguage } from "../utils/verificationLanguage"; const diditClient = Axios.create({ baseURL: "https://verification.didit.me", @@ -49,7 +50,7 @@ export class VerificationController { app.post("/verification/v2", async (req: Request, res: Response) => { if (!requireSharedSecret(req, res)) return; console.log("Creating new Didit verification session"); - const { referenceId } = req.body; + const { referenceId, language } = req.body; if (referenceId) { const existing = await this.verificationService.findOne({ referenceId }); @@ -73,6 +74,7 @@ export class VerificationController { { workflow_id: workflowId, vendor_data: verification.id, + language: verificationLanguage(language), }, { headers: { diff --git a/infrastructure/evault-core/src/utils/verificationLanguage.spec.ts b/infrastructure/evault-core/src/utils/verificationLanguage.spec.ts new file mode 100644 index 000000000..d7469fe55 --- /dev/null +++ b/infrastructure/evault-core/src/utils/verificationLanguage.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { verificationLanguage } from "./verificationLanguage"; + +describe("verificationLanguage", () => { + it.each(["en", "ru", "uk", "pt-BR"])("passes %s through", (code) => { + expect(verificationLanguage(code)).toBe(code); + }); + + it.each([undefined, null, "", "english", "RU", "r", 42, {}])( + "falls back to en for %s", + (value) => { + expect(verificationLanguage(value)).toBe("en"); + }, + ); +}); diff --git a/infrastructure/evault-core/src/utils/verificationLanguage.ts b/infrastructure/evault-core/src/utils/verificationLanguage.ts new file mode 100644 index 000000000..edebef26b --- /dev/null +++ b/infrastructure/evault-core/src/utils/verificationLanguage.ts @@ -0,0 +1,15 @@ +/** + * Didit renders its verification UI in the language passed at session + * creation. Without it Didit falls back to detecting the browser language, + * which does not work inside the wallet's webview, so the screens come out in + * English however the app is set. + * + * Codes are ISO 639-1, matching the wallet's own locales. + * See https://docs.didit.me/sessions-api/create-session + */ + +const ISO_639_1 = /^[a-z]{2}(-[A-Z]{2})?$/; + +export function verificationLanguage(value: unknown): string { + return typeof value === "string" && ISO_639_1.test(value) ? value : "en"; +} From 40f9168a861339e2686dc3d69c1998b8abc03596 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Fri, 25 Sep 2026 08:12:06 +0300 Subject: [PATCH 2/3] fix(evault-core): keep the locale in the fallback verification URL Verified against Didit's API: the locale is carried by the URL path, not by the session. Creating a session with language ru returns verify.didit.me/ru/session/, and the page renders in Russian; en and uk follow the same shape. Both controllers fell back to a hardcoded verify.didit.me/session/ when Didit's response carries neither verification_url nor url. That fallback would have produced an English page from a session created in Russian, which is the one thing this change exists to prevent. It now carries the same language sent to Didit. --- .../evault-core/src/controllers/RecoveryController.ts | 5 +++-- .../evault-core/src/controllers/VerificationController.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/infrastructure/evault-core/src/controllers/RecoveryController.ts b/infrastructure/evault-core/src/controllers/RecoveryController.ts index c176eec09..4a0edf582 100644 --- a/infrastructure/evault-core/src/controllers/RecoveryController.ts +++ b/infrastructure/evault-core/src/controllers/RecoveryController.ts @@ -226,6 +226,7 @@ export class RecoveryController { } try { + const language = verificationLanguage(req.body?.language); const verification = await this.verificationService.create({}); const { data: diditSession } = await diditClient.post( @@ -233,7 +234,7 @@ export class RecoveryController { { workflow_id: workflowId, vendor_data: verification.id, - language: verificationLanguage(req.body?.language), + language, }, { headers: { "x-api-key": apiKey, "Content-Type": "application/json" } }, ); @@ -243,7 +244,7 @@ export class RecoveryController { const verificationUrl: string = diditSession.verification_url ?? diditSession.url ?? - `https://verify.didit.me/session/${sessionToken}`; + `https://verify.didit.me/${language}/session/${sessionToken}`; await this.verificationService.findByIdAndUpdate(verification.id, { diditSessionId: sessionId, diff --git a/infrastructure/evault-core/src/controllers/VerificationController.ts b/infrastructure/evault-core/src/controllers/VerificationController.ts index b0b3314bb..e8c9d2f10 100644 --- a/infrastructure/evault-core/src/controllers/VerificationController.ts +++ b/infrastructure/evault-core/src/controllers/VerificationController.ts @@ -50,7 +50,8 @@ export class VerificationController { app.post("/verification/v2", async (req: Request, res: Response) => { if (!requireSharedSecret(req, res)) return; console.log("Creating new Didit verification session"); - const { referenceId, language } = req.body; + const { referenceId } = req.body; + const language = verificationLanguage(req.body?.language); if (referenceId) { const existing = await this.verificationService.findOne({ referenceId }); @@ -74,7 +75,7 @@ export class VerificationController { { workflow_id: workflowId, vendor_data: verification.id, - language: verificationLanguage(language), + language, }, { headers: { @@ -101,7 +102,7 @@ export class VerificationController { const verificationUrl: string = diditSession.verification_url ?? diditSession.url ?? - `https://verify.didit.me/session/${sessionToken}`; + `https://verify.didit.me/${language}/session/${sessionToken}`; await this.verificationService.findByIdAndUpdate(verification.id, { diditSessionId: sessionId, From 73d13385e378110fdfa0526b04e946fb0a320299 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Fri, 25 Sep 2026 08:30:57 +0300 Subject: [PATCH 3/3] fix(evault-core): validate the verification language against Didit's own list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking only the shape of the code accepted values like es-MX and zz, which Didit does not offer. Forwarding one risks a rejected session, and a rejected session means verification or recovery never starts — the opposite of failing safe. An unlisted code now falls back to en. The list will drift as Didit adds languages, but drift costs an English screen where a translated one existed, whereas the permissive version cost the whole flow. Nothing the wallet sends is affected: paraglide only ever yields en, ru or uk. Reported by CodeRabbit on #1154. --- .../src/utils/verificationLanguage.spec.ts | 20 +++++++++++++++---- .../src/utils/verificationLanguage.ts | 14 ++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/infrastructure/evault-core/src/utils/verificationLanguage.spec.ts b/infrastructure/evault-core/src/utils/verificationLanguage.spec.ts index d7469fe55..7db370629 100644 --- a/infrastructure/evault-core/src/utils/verificationLanguage.spec.ts +++ b/infrastructure/evault-core/src/utils/verificationLanguage.spec.ts @@ -2,11 +2,23 @@ import { describe, expect, it } from "vitest"; import { verificationLanguage } from "./verificationLanguage"; describe("verificationLanguage", () => { - it.each(["en", "ru", "uk", "pt-BR"])("passes %s through", (code) => { - expect(verificationLanguage(code)).toBe(code); - }); + it.each(["en", "ru", "uk", "pt-BR", "zh-TW"])( + "passes the supported code %s through", + (code) => { + expect(verificationLanguage(code)).toBe(code); + }, + ); + + // Well-formed but not offered by Didit. Forwarding these risks a rejected + // session, which would stop verification starting at all. + it.each(["es-MX", "zz", "en-GB", "uk-UA"])( + "falls back to en for the unsupported code %s", + (code) => { + expect(verificationLanguage(code)).toBe("en"); + }, + ); - it.each([undefined, null, "", "english", "RU", "r", 42, {}])( + it.each([undefined, null, "", "english", "RU", 42, {}])( "falls back to en for %s", (value) => { expect(verificationLanguage(value)).toBe("en"); diff --git a/infrastructure/evault-core/src/utils/verificationLanguage.ts b/infrastructure/evault-core/src/utils/verificationLanguage.ts index edebef26b..110acbf69 100644 --- a/infrastructure/evault-core/src/utils/verificationLanguage.ts +++ b/infrastructure/evault-core/src/utils/verificationLanguage.ts @@ -4,12 +4,20 @@ * which does not work inside the wallet's webview, so the screens come out in * English however the app is set. * - * Codes are ISO 639-1, matching the wallet's own locales. + * Codes Didit documents, as of 2026-09. An unlisted code falls back to `en` + * rather than reaching Didit: a rejected session creation would stop + * verification and recovery from starting at all. * See https://docs.didit.me/sessions-api/create-session */ -const ISO_639_1 = /^[a-z]{2}(-[A-Z]{2})?$/; +const SUPPORTED = new Set([ + "ar", "bg", "bn", "bs", "ca", "cnr", "cs", "da", "de", "el", "en", "es", + "et", "fa", "fi", "fr", "he", "hi", "hr", "hu", "hy", "id", "it", "ja", + "ka", "kk", "ko", "ky", "lt", "lv", "mk", "mn", "ms", "nl", "no", "pl", + "pt", "pt-BR", "ro", "ru", "sk", "sl", "so", "sq", "sr", "sv", "th", "tr", + "uk", "uz", "vi", "zh", "zh-CN", "zh-TW", +]); export function verificationLanguage(value: unknown): string { - return typeof value === "string" && ISO_639_1.test(value) ? value : "en"; + return typeof value === "string" && SUPPORTED.has(value) ? value : "en"; }