From ac69802c57d37c8cb95d0af8292eeca242f64fd3 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Mon, 14 Sep 2026 19:38:51 +0530 Subject: [PATCH 01/31] fix(eid-wallet): await cold-start deep links before authentication --- infrastructure/eid-wallet/src/routes/+layout.svelte | 7 +++++++ infrastructure/eid-wallet/src/routes/+page.svelte | 10 ++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index fd1271a54..36a20b4d4 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -66,8 +66,13 @@ let globalDeepLinkHandler: ((event: Event) => void) | undefined; let mainWrapper: HTMLElement | undefined = $state(undefined); let isAppReady = $state(false); let pendingDeepLinks: string[] = $state([]); +let resolveInitialDeepLink = () => {}; +const initialDeepLinkReady = new Promise((resolve) => { + resolveInitialDeepLink = resolve; +}); setContext("globalState", () => globalState); +setContext("initialDeepLinkReady", initialDeepLinkReady); setContext("setGlobalState", (value: GlobalState | undefined) => { globalState = value; }); @@ -186,6 +191,8 @@ onMount(async () => { window.addEventListener("deepLinkReceived", globalDeepLinkHandler); } catch (error) { console.error("Failed to initialize deep link listener:", error); + } finally { + resolveInitialDeepLink(); } // Helper function to check if user is on an authenticated route. diff --git a/infrastructure/eid-wallet/src/routes/+page.svelte b/infrastructure/eid-wallet/src/routes/+page.svelte index 481057663..c267ade96 100644 --- a/infrastructure/eid-wallet/src/routes/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/+page.svelte @@ -23,6 +23,7 @@ const authOpts: AuthOptions = { }; const getGlobalState = getContext<() => GlobalState | undefined>("globalState"); +const initialDeepLinkReady = getContext>("initialDeepLinkReady"); // Read sync (before first paint) so backward-nav from /onboarding lands // directly in state C without flashing state A for a frame. @@ -93,14 +94,19 @@ onMount(async () => { return; } + // The root layout discovers a cold-start URL asynchronously. Wait for + // that discovery before deciding whether this is a normal app launch; + // otherwise fast biometric authentication can win the race and route + // to /main before pendingDeepLink exists. + await initialDeepLinkReady; + // A third-party login deep link opened the app. The root layout has // already stored it and redirected to /login, which runs its own // biometric prompt. If we ALSO prompt here, two native authenticate() // calls race on a cold start — the collision, plus a duplicate // post-auth routine consuming the pending deep link, leaves the user // on /main with the consent screen never shown. Defer to /login as the - // single authenticator. The layout writes pendingDeepLink synchronously - // and early, so it's reliably visible by the time we reach here. + // single authenticator. if (sessionStorage.getItem("pendingDeepLink")) { await goto("/login"); return; From cbf967562b32553c090f8b3194638fa0f408eaea Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 15 Sep 2026 17:14:53 +0300 Subject: [PATCH 02/31] chore(eid-wallet): bump version to 1.1.1 Android versionCode 28 -> 30. Co-Authored-By: Claude Opus 5 (1M context) --- infrastructure/eid-wallet/package.json | 2 +- .../gen/apple/eid-wallet.xcodeproj/project.pbxproj | 8 ++++---- .../src-tauri/gen/apple/eid-wallet_iOS/Info.plist | 4 ++-- infrastructure/eid-wallet/src-tauri/tauri.conf.json | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index 2c830ea8b..671d8b33f 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -1,6 +1,6 @@ { "name": "eid-wallet", - "version": "1.0.1", + "version": "1.1.1", "description": "", "type": "module", "scripts": { diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj index 6f0906889..bc19e87a1 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj @@ -388,7 +388,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1.0.1; + CURRENT_PROJECT_VERSION = 1.1.1.0; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -415,7 +415,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; @@ -436,7 +436,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1.0.1; + CURRENT_PROJECT_VERSION = 1.1.1.0; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -463,7 +463,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist index 546198af6..74f93a253 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.1 + 1.1.1 CFBundleURLTypes @@ -28,7 +28,7 @@ CFBundleVersion - 1.0.1 + 1.1.1.0 LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/infrastructure/eid-wallet/src-tauri/tauri.conf.json b/infrastructure/eid-wallet/src-tauri/tauri.conf.json index 31b0e52b0..b457ff127 100644 --- a/infrastructure/eid-wallet/src-tauri/tauri.conf.json +++ b/infrastructure/eid-wallet/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "eID for W3DS", - "version": "1.0.1", + "version": "1.1.1", "identifier": "foundation.metastate.eid-wallet", "build": { "beforeDevCommand": "pnpm dev", @@ -29,7 +29,7 @@ "active": true, "targets": "all", "android": { - "versionCode": 28 + "versionCode": 30 }, "icon": [ "icons/32x32.png", From ec781f62dff133aba5ed0132004238e9bb568061 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 15 Sep 2026 17:16:13 +0300 Subject: [PATCH 03/31] fix(eid-wallet): put node/pnpm on PATH for the Xcode build phase Xcode launched from the Dock runs script phases with a minimal PATH, so pnpm from a version manager is not found. The generated phase only sourced nvm; cover mise, volta and the Homebrew/local prefixes too. Co-Authored-By: Claude Opus 5 (1M context) --- .../gen/apple/eid-wallet.xcodeproj/project.pbxproj | 2 +- infrastructure/eid-wallet/src-tauri/gen/apple/project.yml | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj index bc19e87a1..6dea3982b 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj @@ -245,7 +245,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/zsh; - shellScript = "[ -s \"$NVM_DIR/nvm.sh\" ] && \\. \"$NVM_DIR/nvm.sh\"\npnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n"; + shellScript = "[ -s \"$NVM_DIR/nvm.sh\" ] && \\. \"$NVM_DIR/nvm.sh\"\nexport PATH=\"$HOME/.local/share/mise/shims:$HOME/.volta/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"\npnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml b/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml index acf221017..08b82d5ff 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml @@ -80,7 +80,12 @@ targets: - sdk: UIKit.framework - sdk: WebKit.framework preBuildScripts: - - script: pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + # Xcode launched from the Dock gets a minimal PATH, so node/pnpm from a + # version manager are invisible. Cover the common install locations. + - script: | + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + export PATH="$HOME/.local/share/mise/shims:$HOME/.volta/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" + pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} name: Build Rust Code basedOnDependencyAnalysis: false outputFiles: From 27222ddfe57eb37a709162a2f54b9a549bd5809d Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 15 Sep 2026 17:16:13 +0300 Subject: [PATCH 04/31] chore(scripts): add eVault seed scripts for legal ID and social bindings Seed an id_document binding doc, and N social bindings with real counterparty vaults, so the wallet screens can be exercised without a KYC run or scanning QR codes. Both read their config from the repo-root .env. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/seed-legal-id.mjs | 183 ++++++++++++++ scripts/seed-social-bindings.mjs | 409 +++++++++++++++++++++++++++++++ 2 files changed, 592 insertions(+) create mode 100644 scripts/seed-legal-id.mjs create mode 100644 scripts/seed-social-bindings.mjs diff --git a/scripts/seed-legal-id.mjs b/scripts/seed-legal-id.mjs new file mode 100644 index 000000000..8062f6e64 --- /dev/null +++ b/scripts/seed-legal-id.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * Seed an `id_document` binding doc onto an existing eVault, so the home screen + * renders a Legal ID and a VERIFIED badge without a real KYC run. + * + * Why this exists: on the iOS Simulator you can't complete Didit verification + * (no camera), so an anonymous-onboarded account has `isFake = true` and no + * id_document. The home derives `verified = isFake === false || legalId !== null` + * (main/+page.svelte), so the badge is permanently UNVERIFIED — and the #1086 + * "loses its verified state" bug has nothing to lose. This gives it something. + * + * The payload is exactly what ProvisioningService.createBindingDocumentForUser + * writes in the real flow (ProvisioningService.ts:154): { vendor, reference, + * name }. Nothing else — validateBindingDocumentData drops unknown keys + * silently (BindingDocumentService.ts:42-53), and the legacy-signature hash is + * computed over the VALIDATED data, so an extra key (e.g. a `kind`, which this + * type does NOT take) would make the hash mismatch and surface as the very + * misleading "Invalid owner signature". + * + * Usage: + * node scripts/seed-legal-id.mjs --ename @your-ename [--name "Julien Connault"] + * + * Reads PUBLIC_REGISTRY_URL / PUBLIC_EID_WALLET_TOKEN from the repo-root .env. + */ + +import { createHash, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), ".."); + +function loadDotEnv(path) { + const out = {}; + let raw; + try { + raw = readFileSync(path, "utf8"); + } catch { + return out; + } + for (const line of raw.split("\n")) { + const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line); + if (!m) continue; + let v = m[2]; + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + v = v.slice(1, -1); + } + out[m[1]] = v; + } + return out; +} + +const dotEnv = loadDotEnv(resolvePath(ROOT, ".env")); +const env = (key, fallback) => process.env[key] || dotEnv[key] || fallback; + +const REGISTRY_URL = env("PUBLIC_REGISTRY_URL", "http://localhost:4321"); +const TOKEN = env("PUBLIC_EID_WALLET_TOKEN", ""); + +const argv = process.argv.slice(2); +const arg = (name, fallback) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; +}; + +const rawEname = arg("ename"); +const fullName = arg("name", "Julien Connault"); + +if (!rawEname) { + console.error( + "Usage: node scripts/seed-legal-id.mjs --ename @your-ename [--name \"Full Name\"]", + ); + process.exit(1); +} + +const at = (e) => (e.startsWith("@") ? e : `@${e}`); +const SELF = at(rawEname); + +/** Mirror of evault-core's stableStringify (binding-document-hash.ts:7). */ +function stableStringify(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + const keys = Object.keys(value).sort(); + return `{${keys + .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`) + .join(",")}}`; +} + +function docHash(subject, type, data) { + return createHash("sha256") + .update(Buffer.from(stableStringify({ subject, type, data }), "utf8")) + .digest("hex"); +} + +const CREATE_BINDING_DOC = ` + mutation CreateBindingDoc($input: CreateBindingDocumentInput!) { + createBindingDocument(input: $input) { + metaEnvelopeId + errors { message code } + } + } +`; + +async function main() { + const resolveUrl = new URL( + `resolve?w3id=${encodeURIComponent(SELF)}`, + REGISTRY_URL, + ).toString(); + const resolveRes = await fetch(resolveUrl); + if (!resolveRes.ok) { + throw new Error(`registry resolve -> HTTP ${resolveRes.status}`); + } + const { uri } = await resolveRes.json(); + if (!uri) throw new Error(`registry returned no uri for ${SELF}`); + const gqlUrl = new URL("/graphql", uri).toString(); + + console.log(`registry: ${REGISTRY_URL}`); + console.log(`vault: ${uri}`); + console.log(`target: ${SELF}`); + console.log(`name: ${fullName}\n`); + + // Exactly the three keys validateBindingDocumentData keeps. No `kind`. + const data = { + vendor: "didit", + reference: randomUUID(), + name: fullName, + }; + + const res = await fetch(gqlUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-ENAME": SELF, + ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}), + }, + body: JSON.stringify({ + query: CREATE_BINDING_DOC, + variables: { + input: { + subject: SELF, + type: "id_document", + data, + ownerSignature: { + signer: SELF, + signature: docHash(SELF, "id_document", data), + timestamp: new Date().toISOString(), + }, + }, + }, + }), + }); + + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`); + } + if (json.errors?.length) { + throw new Error(json.errors.map((e) => e.message).join("; ")); + } + const result = json.data?.createBindingDocument; + if (result?.errors?.length) { + throw new Error(result.errors.map((e) => e.message).join("; ")); + } + if (!result?.metaEnvelopeId) { + throw new Error(`no metaEnvelopeId returned: ${text.slice(0, 300)}`); + } + + console.log(`Created id_document ${result.metaEnvelopeId}`); + console.log( + "\nReload the wallet home: Legal ID should populate and the eName badge\n" + + "should read VERIFIED (legalId !== null is enough — isFake stays true).", + ); +} + +main().catch((err) => { + console.error(`\nFatal: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/seed-social-bindings.mjs b/scripts/seed-social-bindings.mjs new file mode 100644 index 000000000..f30e33799 --- /dev/null +++ b/scripts/seed-social-bindings.mjs @@ -0,0 +1,409 @@ +#!/usr/bin/env node +/** + * Seed N social bindings onto an existing eVault, for testing the Social + * Bindings screens (issues #1080 / #1086) without scanning N QR codes. + * + * Each seeded binding mirrors exactly what a real scan produces, so the + * screens hit the same code paths (including the per-binding reconcile that + * makes the Full List slow): + * + * 1. a real anonymous eVault is provisioned for the counterparty, so the + * registry resolves it and cross-vault reads actually go over the wire; + * 2. a `self` doc gives it a display name, and a `photograph` doc gives it a + * photo blob — the Full List drags those blobs across the network because + * it calls fetchNameFromVault without { nameOnly: true }, so a seed with + * no photos would under-report the latency badly; + * 3. the primary `social_connection` doc lands in the counterparty's vault + * (subject=@them, signed by you), then they counter-sign it -> confirmed; + * 4. a single-signature mirror lands in your vault (subject=@you, signed by + * you) -> role "sent", which is what triggers the remote reconcile. + * + * Signatures use the SHA-256-of-canonical-form path that the server accepts as + * a legacy signature (BindingDocumentService.ts:271), so no keypair is needed. + * + * Usage: + * node scripts/seed-social-bindings.mjs --ename @your-ename [--count 8] + * [--photo-kb 250] [--no-photos] + * + * Reads PUBLIC_REGISTRY_URL / PUBLIC_PROVISIONER_URL / PUBLIC_EID_WALLET_TOKEN + * from the repo-root .env; each can be overridden by a real env var. + */ + +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), ".."); + +// Skips the whole KYC block server-side (ProvisioningService.ts:474). +const DEMO_VERIFICATION_CODE = "d66b7138-538a-465f-a6ce-f6985854c3f4"; + +const FAKE_NAMES = [ + "Ada Lovelace", + "Grace Hopper", + "Alan Turing", + "Katherine Johnson", + "Linus Torvalds", + "Margaret Hamilton", + "Dennis Ritchie", + "Barbara Liskov", + "Ken Thompson", + "Radia Perlman", + "Tim Berners-Lee", + "Anita Borg", +]; + +const RELATIONS = [ + "Met at a conference", + "Colleague", + "Friend", + "Met at a meetup", + "Family", + "Business contact", +]; + +// --- env ---------------------------------------------------------------- + +function loadDotEnv(path) { + const out = {}; + let raw; + try { + raw = readFileSync(path, "utf8"); + } catch { + return out; + } + for (const line of raw.split("\n")) { + const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line); + if (!m) continue; + let v = m[2]; + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + v = v.slice(1, -1); + } + out[m[1]] = v; + } + return out; +} + +const dotEnv = loadDotEnv(resolvePath(ROOT, ".env")); +const env = (key, fallback) => process.env[key] || dotEnv[key] || fallback; + +const REGISTRY_URL = env("PUBLIC_REGISTRY_URL", "http://localhost:4321"); +const PROVISIONER_URL = env("PUBLIC_PROVISIONER_URL", "http://localhost:3001"); +const TOKEN = env("PUBLIC_EID_WALLET_TOKEN", ""); + +// --- args --------------------------------------------------------------- + +const argv = process.argv.slice(2); +function arg(name, fallback) { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; +} + +const rawEname = arg("ename"); +const count = Number.parseInt(arg("count", "8"), 10); +const withPhotos = !argv.includes("--no-photos"); +const photoKb = Number.parseInt(arg("photo-kb", "250"), 10); + +if (!rawEname) { + console.error( + "Usage: node scripts/seed-social-bindings.mjs --ename @your-ename [--count 8]\n\n" + + "Find your eName in the wallet (eName card), or in the Safari Web Inspector\n" + + "console — it looks like @a56dfc50-a3ba-5828-ab64-47194a27f1e6.", + ); + process.exit(1); +} +if (!Number.isInteger(count) || count < 1) { + console.error(`--count must be a positive integer, got: ${arg("count")}`); + process.exit(1); +} +if (withPhotos && (!Number.isInteger(photoKb) || photoKb < 1)) { + console.error(`--photo-kb must be a positive integer, got: ${arg("photo-kb")}`); + process.exit(1); +} + +const at = (e) => (e.startsWith("@") ? e : `@${e}`); +const SELF = at(rawEname); + +// --- primitives --------------------------------------------------------- + +/** Mirror of evault-core's stableStringify (binding-document-hash.ts:7). */ +function stableStringify(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + const keys = Object.keys(value).sort(); + return `{${keys + .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`) + .join(",")}}`; +} + +/** + * The server recomputes this over the *validated* data and accepts an exact + * match as a valid signature, so the payload must carry exactly the keys + * validateBindingDocumentData returns — no more, no less. + */ +function docHash(subject, type, data) { + return createHash("sha256") + .update(Buffer.from(stableStringify({ subject, type, data }), "utf8")) + .digest("hex"); +} + +async function gql(gqlUrl, eName, query, variables) { + const res = await fetch(gqlUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-ENAME": eName, + ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}), + }, + body: JSON.stringify({ query, variables }), + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error(`${gqlUrl} -> HTTP ${res.status}: ${text.slice(0, 300)}`); + } + if (json.errors?.length) { + throw new Error(json.errors.map((e) => e.message).join("; ")); + } + return json.data; +} + +const CREATE_BINDING_DOC = ` + mutation CreateBindingDoc($input: CreateBindingDocumentInput!) { + createBindingDocument(input: $input) { + metaEnvelopeId + errors { message code } + } + } +`; + +const ADD_SIGNATURE = ` + mutation AddSignature($input: CreateBindingDocumentSignatureInput!) { + createBindingDocumentSignature(input: $input) { + bindingDocument { subject signatures { signer } } + errors { message code } + } + } +`; + +async function createBindingDoc(gqlUrl, vaultEname, subject, type, data, signer) { + const payload = await gql(gqlUrl, vaultEname, CREATE_BINDING_DOC, { + input: { + subject, + type, + data, + ownerSignature: { + signer, + signature: docHash(subject, type, data), + timestamp: new Date().toISOString(), + }, + }, + }); + const result = payload.createBindingDocument; + if (result.errors?.length) { + throw new Error(result.errors.map((e) => e.message).join("; ")); + } + if (!result.metaEnvelopeId) { + throw new Error(`createBindingDocument(${type}) returned no metaEnvelopeId`); + } + return result.metaEnvelopeId; +} + +async function counterSign(gqlUrl, vaultEname, docId, subject, type, data, signer) { + const payload = await gql(gqlUrl, vaultEname, ADD_SIGNATURE, { + input: { + bindingDocumentId: docId, + signature: { + signer, + signature: docHash(subject, type, data), + timestamp: new Date().toISOString(), + }, + }, + }); + const result = payload.createBindingDocumentSignature; + if (result.errors?.length) { + throw new Error(result.errors.map((e) => e.message).join("; ")); + } +} + +async function resolveVaultUri(ename) { + const url = new URL( + `resolve?w3id=${encodeURIComponent(ename)}`, + REGISTRY_URL, + ).toString(); + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `registry resolve ${ename} -> HTTP ${res.status} (${url})`, + ); + } + const json = await res.json(); + if (!json?.uri) throw new Error(`registry returned no uri for ${ename}`); + return json.uri; +} + +async function provisionVault() { + const entropyRes = await fetch(new URL("/entropy", REGISTRY_URL).toString()); + if (!entropyRes.ok) { + throw new Error(`registry /entropy -> HTTP ${entropyRes.status}`); + } + const { token: registryEntropy } = await entropyRes.json(); + if (!registryEntropy) throw new Error("registry /entropy returned no token"); + + const res = await fetch(new URL("/provision", PROVISIONER_URL).toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + registryEntropy, + namespace: randomUUID(), + verificationId: DEMO_VERIFICATION_CODE, + }), + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error(`provisioner -> HTTP ${res.status}: ${text.slice(0, 300)}`); + } + if (!json.success || !json.w3id || !json.uri) { + throw new Error(`provision failed: ${text.slice(0, 300)}`); + } + return { ename: at(json.w3id), uri: json.uri }; +} + +// --- main --------------------------------------------------------------- + +async function seedOne(index, selfGqlUrl) { + const name = FAKE_NAMES[index % FAKE_NAMES.length]; + const suffix = index >= FAKE_NAMES.length ? ` ${Math.floor(index / FAKE_NAMES.length) + 1}` : ""; + const displayName = `${name}${suffix}`; + const relation = RELATIONS[index % RELATIONS.length]; + + const peer = await provisionVault(); + const peerGqlUrl = new URL("/graphql", peer.uri).toString(); + + // Display name, so the list shows a person instead of a raw eName. + const selfData = { kind: "self", name: displayName }; + await createBindingDoc( + peerGqlUrl, + peer.ename, + peer.ename, + "self", + selfData, + peer.ename, + ); + + // A photo blob, because the Full List pulls every doc type from each + // counterparty vault. Random bytes: incompressible, like a real JPEG. + if (withPhotos) { + const photoData = { + photoBlob: randomBytes(Math.ceil((photoKb * 1024 * 3) / 4)).toString( + "base64", + ), + description: "Seeded portrait", + }; + await createBindingDoc( + peerGqlUrl, + peer.ename, + peer.ename, + "photograph", + photoData, + peer.ename, + ); + } + + // Primary doc: lives in the counterparty's vault, signed by us first. + const primaryData = { + kind: "social_connection", + name: displayName, + parties: [SELF, peer.ename], + relation_description: relation, + }; + const primaryId = await createBindingDoc( + peerGqlUrl, + peer.ename, + peer.ename, + "social_connection", + primaryData, + SELF, + ); + + // They counter-sign -> 2 signatures -> the binding reads as confirmed. + await counterSign( + peerGqlUrl, + peer.ename, + primaryId, + peer.ename, + "social_connection", + primaryData, + peer.ename, + ); + + // Our single-signature mirror. This is what puts the binding in our list, + // with role "sent" -> reconciled against their vault on every load. + const mirrorData = { + kind: "social_connection", + name: displayName, + parties: [SELF, peer.ename], + relation_description: relation, + }; + await createBindingDoc( + selfGqlUrl, + SELF, + SELF, + "social_connection", + mirrorData, + SELF, + ); + + return { displayName, ename: peer.ename }; +} + +async function main() { + console.log(`registry: ${REGISTRY_URL}`); + console.log(`provisioner: ${PROVISIONER_URL}`); + console.log(`token: ${TOKEN ? "present" : "MISSING (writes will fail)"}`); + console.log(`target: ${SELF}`); + console.log(`count: ${count}`); + console.log( + `photos: ${withPhotos ? `${photoKb} KB per counterparty` : "disabled"}\n`, + ); + + const selfUri = await resolveVaultUri(SELF); + const selfGqlUrl = new URL("/graphql", selfUri).toString(); + console.log(`Resolved your vault -> ${selfUri}\n`); + + const seeded = []; + for (let i = 0; i < count; i++) { + const label = `[${i + 1}/${count}]`; + try { + const { displayName, ename } = await seedOne(i, selfGqlUrl); + seeded.push({ displayName, ename }); + console.log(`${label} ${displayName.padEnd(20)} ${ename}`); + } catch (err) { + console.error(`${label} FAILED: ${err.message}`); + } + } + + console.log( + `\nSeeded ${seeded.length}/${count} social bindings onto ${SELF}.`, + ); + if (seeded.length) { + console.log( + "Open the wallet -> Social Bindings -> Full List. Each contact costs a\n" + + "registry resolve + a paginated read of their vault + a name lookup.", + ); + } +} + +main().catch((err) => { + console.error(`\nFatal: ${err.message}`); + process.exit(1); +}); From 1e37a388edc9f7f1d0a6b3e07b2590077942cef2 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 15 Sep 2026 09:34:41 +0530 Subject: [PATCH 05/31] fix(eid-wallet): show consent screen when a deep link races cold-start auth Logging in from a third-party platform while the wallet was not already running would authenticate the user and then drop them on /main, with the consent screen never appearing. It only reproduced when biometric authentication completed quickly; authenticating slowly worked fine. On a cold start four things run concurrently: the root layout receiving the URL, the splash or /login authenticating the user, the post-auth routine routing onward, and /scan-qr rendering the consent drawer. The code assumed a fixed ordering between them and detected "a deep link is in progress" by reading the pendingDeepLink key directly. That key is deliberately short-lived, being renamed to deepLinkData the moment authentication succeeds, so fast authentication let the splash observe the gap where neither key was set, conclude this was an ordinary launch, and route to /main. Two concurrent authenticate() calls and two navigations then competed over a single payload. Introduce lib/utils/deepLinkFlow.ts as the single owner of that state: - A sticky deepLinkFlowActive marker spans the whole journey, so concurrent code can ask whether a deep link is in flight without catching the handover gap. - walletAuthenticated lets a late-arriving URL route straight to the consent screen instead of bouncing off a stale pathname check. - An auth-prompt bracket makes the post-auth routine the sole owner of navigation while a prompt is on screen, so the deep-link handler no longer issues a competing goto(). Also fold the layout's three near-identical 130-line handlers into one order-independent router, drop a self-triggering deepLinkReceived listener, dedupe URLs delivered through both getCurrent() and onOpenUrl, and read the post-consent redirect from the in-memory store rather than storage keys that have already been cleared by the time the user taps Confirm. Covered by unit tests over each cold-start interleaving. --- .../src/lib/utils/deepLinkFlow.spec.ts | 186 +++++ .../eid-wallet/src/lib/utils/deepLinkFlow.ts | 152 ++++ .../eid-wallet/src/lib/utils/postLogin.ts | 32 +- .../src/routes/(app)/scan-qr/scanLogic.ts | 136 ++-- .../src/routes/(auth)/login/+page.svelte | 19 +- .../eid-wallet/src/routes/+layout.svelte | 657 ++++-------------- .../eid-wallet/src/routes/+page.svelte | 36 +- 7 files changed, 594 insertions(+), 624 deletions(-) create mode 100644 infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts create mode 100644 infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts new file mode 100644 index 000000000..0fe8db29f --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + beginAuthPrompt, + clearDeepLinkFlow, + endAuthPrompt, + isAuthPromptInFlight, + isDeepLinkFlowActive, + isWalletAuthenticated, + markDeepLinkPending, + markDeepLinkReady, + markWalletAuthenticated, + peekDeepLinkPayload, + promotePendingDeepLink, +} from "./deepLinkFlow"; + +/** + * Minimal sessionStorage stand-in — the module is deliberately storage-backed + * so that state survives the full-page navigations the wallet performs. + */ +class MemoryStorage implements Storage { + private map = new Map(); + get length() { + return this.map.size; + } + clear() { + this.map.clear(); + } + getItem(key: string) { + return this.map.get(key) ?? null; + } + key(index: number) { + return Array.from(this.map.keys())[index] ?? null; + } + removeItem(key: string) { + this.map.delete(key); + } + setItem(key: string, value: string) { + this.map.set(key, value); + } +} + +const AUTH_PAYLOAD = { + type: "auth", + session: "sess-1", + platform: "example", + redirect: "https://example.com/cb", +}; + +beforeEach(() => { + vi.stubGlobal("sessionStorage", new MemoryStorage()); +}); + +describe("deep link flow state", () => { + it("reports an active flow for a payload awaiting authentication", () => { + expect(isDeepLinkFlowActive()).toBe(false); + markDeepLinkPending(AUTH_PAYLOAD); + expect(isDeepLinkFlowActive()).toBe(true); + expect(JSON.parse(peekDeepLinkPayload() as string)).toEqual( + AUTH_PAYLOAD, + ); + }); + + it("stays active across the pending -> ready handover", () => { + // This is the exact window the original bug fell through: between the + // promotion and the consent screen mounting, `pendingDeepLink` is gone + // but the flow is very much still in progress. Fast biometric auth hit + // this window; slow auth did not, which is why it only reproduced when + // the user authenticated quickly. + markDeepLinkPending(AUTH_PAYLOAD); + + expect(promotePendingDeepLink()).toBe(true); + + expect(sessionStorage.getItem("pendingDeepLink")).toBeNull(); + expect(isDeepLinkFlowActive()).toBe(true); + expect(JSON.parse(peekDeepLinkPayload() as string)).toEqual( + AUTH_PAYLOAD, + ); + }); + + it("treats a payload that arrives post-authentication as active", () => { + markDeepLinkReady(AUTH_PAYLOAD); + expect(isDeepLinkFlowActive()).toBe(true); + expect(sessionStorage.getItem("pendingDeepLink")).toBeNull(); + }); + + it("has nothing to promote when no payload is pending", () => { + expect(promotePendingDeepLink()).toBe(false); + expect(isDeepLinkFlowActive()).toBe(false); + }); + + it("clears every key once the consent drawer has consumed the payload", () => { + markDeepLinkPending(AUTH_PAYLOAD); + promotePendingDeepLink(); + + clearDeepLinkFlow(); + + expect(isDeepLinkFlowActive()).toBe(false); + expect(peekDeepLinkPayload()).toBeNull(); + }); + + it("survives a pending payload being overwritten by a newer one", () => { + markDeepLinkPending(AUTH_PAYLOAD); + const newer = { ...AUTH_PAYLOAD, session: "sess-2" }; + markDeepLinkPending(newer); + + promotePendingDeepLink(); + expect(JSON.parse(peekDeepLinkPayload() as string)).toEqual(newer); + }); +}); + +describe("authentication signals", () => { + it("records that the user got through authentication", () => { + expect(isWalletAuthenticated()).toBe(false); + markWalletAuthenticated(); + expect(isWalletAuthenticated()).toBe(true); + }); + + it("brackets an in-flight prompt so the layout defers navigation", () => { + expect(isAuthPromptInFlight()).toBe(false); + beginAuthPrompt(); + expect(isAuthPromptInFlight()).toBe(true); + endAuthPrompt(); + expect(isAuthPromptInFlight()).toBe(false); + }); + + it("is safe to end a prompt that was never begun", () => { + expect(() => endAuthPrompt()).not.toThrow(); + expect(isAuthPromptInFlight()).toBe(false); + }); +}); + +describe("cold-start orderings", () => { + /** + * Each case walks one interleaving of the four concurrent actors and + * asserts the user ends up at the consent screen. The fix is only correct + * if EVERY ordering lands there — the previous implementations worked for + * the slow ordering and dropped the payload on the fast one. + */ + + it("URL arrives, then the user authenticates (slow biometric)", () => { + markDeepLinkPending(AUTH_PAYLOAD); + + // Splash sees an active flow, so it defers to /login rather than + // running a second competing authenticate() call. + expect(isDeepLinkFlowActive()).toBe(true); + + beginAuthPrompt(); + endAuthPrompt(); + markWalletAuthenticated(); + promotePendingDeepLink(); + + expect(peekDeepLinkPayload()).not.toBeNull(); + }); + + it("URL arrives while the biometric prompt is already on screen (fast auth)", () => { + // Splash starts its prompt before the cold-start URL is delivered. + beginAuthPrompt(); + + // The URL lands mid-prompt. The layout must park it and NOT navigate, + // because the post-auth routine owns routing from here. + markDeepLinkPending(AUTH_PAYLOAD); + expect(isAuthPromptInFlight()).toBe(true); + + // Auth succeeds; the post-auth routine collects the parked payload. + endAuthPrompt(); + markWalletAuthenticated(); + expect(promotePendingDeepLink()).toBe(true); + expect(peekDeepLinkPayload()).not.toBeNull(); + }); + + it("URL arrives after authentication already completed", () => { + beginAuthPrompt(); + endAuthPrompt(); + markWalletAuthenticated(); + + // Nothing was pending at auth time, so the post-auth routine routed to + // /main. The late URL must still reach the consent screen: the layout + // sees an authenticated session and marks the payload ready directly. + expect(promotePendingDeepLink()).toBe(false); + + markDeepLinkReady(AUTH_PAYLOAD); + expect(isWalletAuthenticated()).toBe(true); + expect(peekDeepLinkPayload()).not.toBeNull(); + }); +}); diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts new file mode 100644 index 000000000..c27446f3d --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts @@ -0,0 +1,152 @@ +/** + * Deep-link flow state, shared by the root layout, the splash screen, /login + * and /scan-qr. + * + * WHY THIS MODULE EXISTS + * + * A `w3ds://` URL that cold-starts the app has to survive a journey across + * four independent pieces of code before the consent drawer can be shown: + * + * root +layout receives the URL (getCurrent / onOpenUrl) + * splash or /login authenticates the user (biometric or PIN) + * postLogin routes the authenticated user onward + * /scan-qr finally renders the consent drawer + * + * Those pieces run CONCURRENTLY on a cold start. The URL may be delivered + * before, during, or after authentication finishes, and on Android a + * cold-start URL frequently arrives through the async `onOpenUrl` callback + * *after* `getCurrent()` has already returned nothing. Every previous attempt + * to fix the "consent screen disappears" bug assumed a fixed ordering, which + * is why it only reproduced when biometric authentication completed quickly. + * + * The state here is deliberately order-independent. Whoever gets there first + * records a fact; nobody infers ordering from the absence of a key. + * + * KEYS + * + * pendingDeepLink payload waiting for the user to authenticate + * deepLinkData payload ready for /scan-qr to consume + * deepLinkFlowActive sticky marker: a deep link is somewhere in this flow. + * Survives the pending -> data handover, so concurrent + * code can ask "is a deep link in flight?" without + * catching the one-instruction gap where neither payload + * key is set. + * walletAuthenticated the user completed authentication this session. Lets a + * LATE-arriving URL route straight to the consent screen + * instead of bouncing off a stale "not on an + * authenticated route" pathname check. + * authInFlight an authenticate() call is currently awaiting the user. + * While set, the layout must NOT issue its own + * navigation: the post-auth routine owns routing, and two + * concurrent goto() calls are exactly what used to strand + * the user on /main with the payload unconsumed. + */ + +const PENDING_KEY = "pendingDeepLink"; +const DATA_KEY = "deepLinkData"; +const ACTIVE_KEY = "deepLinkFlowActive"; +const AUTHED_KEY = "walletAuthenticated"; +const AUTH_IN_FLIGHT_KEY = "walletAuthInFlight"; + +function store(): Storage | null { + try { + return typeof sessionStorage === "undefined" ? null : sessionStorage; + } catch { + // Private-mode / disabled storage: degrade to "no deep link in flight" + // rather than throwing inside a deep-link callback. + return null; + } +} + +/* ---------------------------------------------------------------- payloads */ + +/** A deep link arrived and the user still has to authenticate. */ +export function markDeepLinkPending(data: unknown): void { + const s = store(); + if (!s) return; + s.setItem(PENDING_KEY, JSON.stringify(data)); + s.setItem(ACTIVE_KEY, "true"); +} + +/** A deep link arrived and the user is already authenticated. */ +export function markDeepLinkReady(data: unknown): void { + const s = store(); + if (!s) return; + s.setItem(DATA_KEY, JSON.stringify(data)); + s.removeItem(PENDING_KEY); + s.setItem(ACTIVE_KEY, "true"); +} + +/** + * True from the moment a deep link is received until /scan-qr has consumed it. + * Safe to call from code running concurrently with authentication. + */ +export function isDeepLinkFlowActive(): boolean { + const s = store(); + if (!s) return false; + return ( + s.getItem(ACTIVE_KEY) === "true" || + !!s.getItem(PENDING_KEY) || + !!s.getItem(DATA_KEY) + ); +} + +/** + * Promote a pending payload to a ready one once authentication succeeds. + * Returns true when there was something to promote. + */ +export function promotePendingDeepLink(): boolean { + const s = store(); + if (!s) return false; + const pending = s.getItem(PENDING_KEY); + if (!pending) return false; + s.setItem(DATA_KEY, pending); + s.removeItem(PENDING_KEY); + s.setItem(ACTIVE_KEY, "true"); + return true; +} + +/** Read the payload without consuming it. */ +export function peekDeepLinkPayload(): string | null { + const s = store(); + if (!s) return null; + return s.getItem(DATA_KEY) ?? s.getItem(PENDING_KEY); +} + +/** The flow is finished: handled, declined, or failed. */ +export function clearDeepLinkFlow(): void { + const s = store(); + if (!s) return; + s.removeItem(PENDING_KEY); + s.removeItem(DATA_KEY); + s.removeItem(ACTIVE_KEY); +} + +/* ------------------------------------------------------------ auth signals */ + +/** Record that the user finished authenticating in this session. */ +export function markWalletAuthenticated(): void { + store()?.setItem(AUTHED_KEY, "true"); +} + +/** Has the user authenticated at any point in this session? */ +export function isWalletAuthenticated(): boolean { + return store()?.getItem(AUTHED_KEY) === "true"; +} + +/** + * Bracket an authenticate() / PIN-verify call. While a prompt is in flight the + * deep-link handler defers all navigation to the post-auth routine, so the two + * cannot race each other to a different destination. + */ +export function beginAuthPrompt(): void { + store()?.setItem(AUTH_IN_FLIGHT_KEY, "true"); +} + +export function endAuthPrompt(): void { + store()?.removeItem(AUTH_IN_FLIGHT_KEY); +} + +export function isAuthPromptInFlight(): boolean { + return store()?.getItem(AUTH_IN_FLIGHT_KEY) === "true"; +} diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index d90085955..28b5a9a94 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -1,5 +1,11 @@ import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; +import { + isDeepLinkFlowActive, + markWalletAuthenticated, + peekDeepLinkPayload, + promotePendingDeepLink, +} from "$lib/utils/deepLinkFlow"; /** * Shared post-authentication routine: fires the background eVault chores @@ -14,6 +20,12 @@ import type { GlobalState } from "$lib/global"; export async function continueAfterSuccessfulAuth( gs: GlobalState, ): Promise { + // Record the session as authenticated BEFORE any await. A deep link that + // lands while the chores below are in flight must be able to see that the + // user is already through the gate, so it routes itself straight to the + // consent screen instead of parking a payload nobody will collect. + markWalletAuthenticated(); + // Fire-and-forget post-login chores. They hit the network with no client // timeout, so awaiting them here can strand the user on a spinner — the // app pages will retry as needed. @@ -52,17 +64,23 @@ export async function continueAfterSuccessfulAuth( console.error("Error reading vault during login:", error); } - const pendingDeepLink = sessionStorage.getItem("pendingDeepLink"); - if (pendingDeepLink) { + // Promote any payload that was parked while the user authenticated, then + // re-check the flow flag. Both steps matter: the payload may have been + // stored before the prompt (promote finds it) or delivered during the + // awaits above and written straight to deepLinkData (only the flag sees + // it). Checking just one of the two is what let the consent screen slip + // through the cracks on a fast cold start. + promotePendingDeepLink(); + + if (isDeepLinkFlowActive() && peekDeepLinkPayload()) { try { - sessionStorage.setItem("deepLinkData", pendingDeepLink); - sessionStorage.removeItem("pendingDeepLink"); await goto("/scan-qr"); return; } catch (error) { - console.error("Error processing pending deep link:", error); - sessionStorage.removeItem("pendingDeepLink"); - sessionStorage.removeItem("deepLinkData"); + // Leave the payload in place — /scan-qr clears it once handled, and + // a failed navigation here should not silently discard the user's + // pending login request. + console.error("Error navigating to pending deep link:", error); } } diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts index ae9d4aec9..c3ac7a966 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts @@ -21,6 +21,10 @@ import { getCanonicalBindingDocString, resolveVaultUri, } from "$lib/utils"; +import { + clearDeepLinkFlow, + peekDeepLinkPayload, +} from "$lib/utils/deepLinkFlow"; export interface SigningData { type?: string; @@ -428,81 +432,39 @@ export function createScanLogic({ // Close the auth drawer first codeScannedDrawerOpen.set(false); - let deepLinkData = sessionStorage.getItem("deepLinkData"); - if (!deepLinkData) { - deepLinkData = sessionStorage.getItem("pendingDeepLink"); - } - - if (deepLinkData) { + // This request came from a deep link (not the camera), so send the + // user back to the platform that asked for the login. + // + // Read the redirect from the in-memory store rather than from + // sessionStorage: initialize() clears the deep-link keys as soon + // as the consent drawer is on screen, which is long before the + // user taps Confirm, so a storage read here always came back empty + // and dropped the user on the "logged in" drawer instead of + // returning them to the platform. + const deepLinkRedirect = get(isFromScan) ? null : get(redirect); + + if (deepLinkRedirect) { + let isValidRedirect = false; try { - const data = JSON.parse(deepLinkData) as DeepLinkData; - console.log( - "Deep link data found after auth completion:", - data, - ); - - if (data.type === "auth") { - if ( - !data.redirect || - typeof data.redirect !== "string" - ) { - console.error( - "Invalid redirect URL:", - data.redirect, - ); - // Ensure auth drawer is closed before opening logged in drawer - codeScannedDrawerOpen.set(false); - loggedInDrawerOpen.set(true); - return; - } - - try { - new URL(data.redirect); - } catch (urlError) { - console.error("Invalid URL format:", urlError); - // Ensure auth drawer is closed before opening logged in drawer - codeScannedDrawerOpen.set(false); - loggedInDrawerOpen.set(true); - return; - } - - try { - window.location.href = data.redirect; - } catch (error1) { - console.log( - "Method 1 failed, trying method 2:", - error1, - ); - try { - window.location.assign(data.redirect); - } catch (error2) { - console.log( - "Method 2 failed, trying method 3:", - error2, - ); - try { - window.location.replace(data.redirect); - } catch (error3) { - console.log( - "Method 3 failed, using fallback:", - error3, - ); - throw new Error( - "All redirect methods failed", - ); - } - } - } - return; - } - } catch (error) { + new URL(deepLinkRedirect); + isValidRedirect = true; + } catch (urlError) { console.error( - "Error parsing deep link data for redirect:", - error, + "Invalid redirect URL from deep link:", + deepLinkRedirect, + urlError, ); } + + if (isValidRedirect) { + codeScannedDrawerOpen.set(false); + window.location.href = deepLinkRedirect; + return; + } } else { - console.log("No deep link data found after auth completion"); + console.log( + "No deep link redirect found after auth completion", + ); } // Ensure auth drawer is closed before opening logged in drawer @@ -946,18 +908,13 @@ export function createScanLogic({ } showSigningSuccess.set(true); - const deepLinkData = sessionStorage.getItem("deepLinkData"); - if (deepLinkData) { - try { - const data = JSON.parse(deepLinkData) as DeepLinkData; - if (data.type === "sign") { - console.log("Signing completed via deep link"); - startScan(); - return; - } - } catch (error) { - console.error("Error parsing deep link data:", error); - } + // Came from a deep link rather than the camera: resume scanning + // behind the success sheet. Uses the in-memory flag because the + // deep-link storage keys are cleared once the drawer opens. + if (!get(isFromScan)) { + console.log("Signing completed via deep link"); + startScan(); + return; } } catch (error) { console.error("Error signing vote:", error); @@ -1666,10 +1623,7 @@ export function createScanLogic({ window.addEventListener("deepLinkAuth", authHandler); window.addEventListener("deepLinkSign", signHandler); - let deepLinkData = sessionStorage.getItem("deepLinkData"); - if (!deepLinkData) { - deepLinkData = sessionStorage.getItem("pendingDeepLink"); - } + const deepLinkData = peekDeepLinkPayload(); if (deepLinkData) { console.log("Found deep link data:", deepLinkData); @@ -1680,8 +1634,14 @@ export function createScanLogic({ } catch (error) { console.error("Error parsing deep link data:", error); } finally { - sessionStorage.removeItem("deepLinkData"); - sessionStorage.removeItem("pendingDeepLink"); + // Clear only after the payload has been turned into an open + // drawer. handleDeepLinkData is synchronous up to the point + // where it sets the drawer store, so by here the consent UI + // is already committed and the keys are safe to drop. Doing + // this earlier meant a re-mount of /scan-qr (which a racing + // navigation can trigger on a cold start) found nothing left + // to show and silently fell through to the camera. + clearDeepLinkFlow(); } } else { console.log("No deep link data found, starting normal scanning"); diff --git a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte index 724448e3b..bcf2ad1b7 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte @@ -4,6 +4,11 @@ import { keyboardInset } from "$lib/actions/keyboardInset"; import type { GlobalState } from "$lib/global"; import { LoadingSheet, PinDots } from "$lib/ui"; import * as Button from "$lib/ui/Button"; +import { + beginAuthPrompt, + endAuthPrompt, + isDeepLinkFlowActive, +} from "$lib/utils/deepLinkFlow"; import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; import { type AuthOptions, @@ -61,6 +66,9 @@ async function verifyAndAdvance(currentPin: string) { isError = false; isPostAuthLoading = true; + // A deep link arriving mid-verification must not issue its own + // navigation; continueAfterSuccessfulAuth below owns where we go next. + beginAuthPrompt(); try { const ok = await globalState.securityController.verifyPin(currentPin); if (!ok) { @@ -69,12 +77,14 @@ async function verifyAndAdvance(currentPin: string) { return; } + endAuthPrompt(); await continueAfterSuccessfulAuth(globalState); } catch (e) { console.error("PIN verification failed", e); isError = true; pin = ""; } finally { + endAuthPrompt(); isPostAuthLoading = false; } } @@ -100,8 +110,9 @@ onMount(async () => { } globalState = gs; - const pendingDeepLink = sessionStorage.getItem("pendingDeepLink"); - hasPendingDeepLink = !!pendingDeepLink; + // Sticky flow flag, not the raw key: the payload may already have been + // promoted from pendingDeepLink to deepLinkData by the time we mount. + hasPendingDeepLink = isDeepLinkFlowActive(); // If the splash already prompted biometric over its own screen, skip the // retry here and let the user enter their PIN. The flag survives the @@ -118,16 +129,20 @@ onMount(async () => { (await gs.securityController.biometricSupport) && (await checkStatus()).isAvailable ) { + beginAuthPrompt(); try { await authenticate( "You must authenticate with PIN first", authOpts, ); isPostAuthLoading = true; + endAuthPrompt(); await continueAfterSuccessfulAuth(gs); } catch (e) { console.error("Biometric authentication failed", e); isPostAuthLoading = false; + } finally { + endAuthPrompt(); } } }); diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index 36a20b4d4..f1c68ab17 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -1,5 +1,5 @@ diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index b8f500274..8b466718c 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -15,6 +15,7 @@ import { isWalletAuthenticated, markDeepLinkPending, markDeepLinkReady, + shouldRedirectToLogin, } from "$lib/utils/deepLinkFlow"; import { installTerminalConsoleBridge } from "$lib/utils/terminalConsole"; import { type Status, checkStatus } from "@tauri-apps/plugin-biometric"; @@ -258,13 +259,13 @@ onMount(async () => { if (!alreadyInsideApp) { markDeepLinkPending(payload); - // A biometric/PIN prompt is already on screen. Its post-auth - // routine will pick the payload up and navigate. Issuing our own - // goto() here would race that navigation — the bug where the - // consent screen flashed and vanished on fast authentication. - if (isAuthPromptInFlight()) { + // The splash is the single biometric prompt site, so the handler + // must not steer away from whoever currently owns that prompt. + // See shouldRedirectToLogin for the full reasoning. + if (!shouldRedirectToLogin(currentPath)) { console.log( - "Auth prompt in flight, deferring navigation to post-auth routine", + "Deferring navigation: the auth prompt owner will route", + { currentPath, authPromptInFlight: isAuthPromptInFlight() }, ); return; } @@ -307,7 +308,6 @@ onNavigate((navigation) => { const to = navigation.to?.url.pathname; if (!from || !to || from === to) return; - console.log(`from: ${from}, to: ${to}`); // Mark routes that have their own mount-time refresh guard. A SvelteKit // navigation (link/goto) fires this hook; a hard reload does not — so diff --git a/infrastructure/eid-wallet/src/routes/+page.svelte b/infrastructure/eid-wallet/src/routes/+page.svelte index 96630a406..86adfb4ab 100644 --- a/infrastructure/eid-wallet/src/routes/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/+page.svelte @@ -6,7 +6,6 @@ import type { GlobalState } from "$lib/global"; import { beginAuthPrompt, endAuthPrompt, - isDeepLinkFlowActive, isWalletAuthenticated, shouldAbortStaleContinuation, } from "$lib/utils/deepLinkFlow"; @@ -18,8 +17,6 @@ import { } from "@tauri-apps/plugin-biometric"; import { getContext, onDestroy, onMount } from "svelte"; -const BIOMETRIC_ATTEMPTED_KEY = "biometricAttemptedOnSplash"; - const authOpts: AuthOptions = { allowDeviceCredential: false, cancelTitle: "Cancel", @@ -135,26 +132,21 @@ onMount(async () => { await initialDeepLinkReady; if (superseded()) return; - // A third-party login deep link opened the app. The root layout has - // stored it and redirected to /login, which runs its own biometric - // prompt. Prompting here as well puts two native authenticate() calls - // on screen at once and lets two post-auth routines fight over a - // single payload — the user ends up on /main and the consent screen is - // never shown. Defer to /login as the single authenticator. + // NOTE: a pending deep link deliberately does NOT divert to /login + // here. The splash is the single place a biometric prompt is allowed + // to appear, so diverting would mean a deep-link launch never offers + // biometrics at all. continueAfterSuccessfulAuth below collects the + // parked payload and routes to the consent screen itself. + + // Fire biometric over the splash. This is the ONLY biometric prompt in + // the pre-app flow: /login is the PIN fallback and never prompts. That + // is what makes the placement deterministic — previously both screens + // could prompt, and whichever won the race decided which background + // the system dialog appeared over. // - // Ask the sticky flow flag rather than reading pendingDeepLink: by the - // time we get here the payload may already have been promoted to - // deepLinkData, and the raw key read would report "no deep link". - if (isDeepLinkFlowActive()) { - await goto("/login"); - return; - } - - // Fire biometric over the splash itself so the prompt isn't competing - // with the /login slide-in. On success we run the post-auth chores - // and route straight to /main (no /login flash). On cancel/fail we - // slide into /login with a sessionStorage flag so /login knows the - // biometric attempt already happened and skips re-prompting. + // On success we run the post-auth chores and route onward (no /login + // flash). On cancel/fail/unavailable we slide into /login for PIN + // entry, so a user without biometrics is never stuck on the splash. let biometricAvailable = false; try { biometricAvailable = @@ -167,7 +159,6 @@ onMount(async () => { if (superseded()) return; if (biometricAvailable && globalState) { - sessionStorage.setItem(BIOMETRIC_ATTEMPTED_KEY, "true"); // Tell the deep-link handler that a prompt owns the screen. A URL // arriving while the user's finger is on the sensor must park its // payload and let continueAfterSuccessfulAuth route, instead of @@ -178,9 +169,8 @@ onMount(async () => { "You must authenticate with PIN first", authOpts, ); - // Success — clear the flag (we won't reach /login at all) - // and run the shared post-auth routine. - sessionStorage.removeItem(BIOMETRIC_ATTEMPTED_KEY); + // Success — run the shared post-auth routine, which routes to + // the pending deep link if there is one and /main otherwise. // NOTE: the prompt bracket stays OPEN here on purpose. // continueAfterSuccessfulAuth closes it itself, at the exact // point where it has collected any pending payload. Closing it @@ -189,8 +179,7 @@ onMount(async () => { await continueAfterSuccessfulAuth(globalState); return; } catch (e) { - // Cancel/fail. Leave the flag set so /login skips its own - // biometric retry, then slide into /login for PIN entry. + // Cancel/fail — fall through to /login for PIN entry. console.warn("Biometric on splash failed", e); } finally { // Idempotent: a no-op on the success path, where From 6feacb497b12125bd41b9a7481c82bfb2520a6ff Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Wed, 16 Sep 2026 11:18:00 +0530 Subject: [PATCH 25/31] fix(eid-wallet): stop the scanner opening after a login is acknowledged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approving a deep-link login calls openUrl, which restarts the Activity. The confirmation card is restored from durable storage so it survives that restart. But if the user taps Ok during the short window BEFORE the restart lands, the rebuilt webview reloads /scan-qr with the payload suppressed (the request is handled) and the confirmation already consumed (takeCompletedDeepLink is single-use). It then took the "no deep link data" branch and started the camera — a scanner the user never asked for. The original code had no concept of a completed deep link at all: its Ok handler was just goto("/main"), and an Activity restart simply destroyed the confirmation and dropped the user on the camera. The concept exists because that lost the screen entirely. What was missing is that the record has two consumers, and only one of them means "the user is done". takeCompletedDeepLink now takes an `acknowledged` flag. Rendering the card consumes the record without marking it answered; tapping Ok records the dismissal, and a webview rebuilt shortly after returns to /main instead of opening the camera. The marker is written before the single-use read, or the restore path (which consumed the record when it rendered) would never record the dismissal at all. The marker is bounded by the same replay window as the dedupe guard, is cleared on logout, and is cleared by any in-app navigation to /scan-qr. That last part is what keeps it honest: it can only ever suppress a webview rebuild, which fires no navigation hook, and never a scan the user deliberately asked for. --- .../src/lib/utils/deepLinkFlow.spec.ts | 82 +++++++++++++++++++ .../eid-wallet/src/lib/utils/deepLinkFlow.ts | 64 ++++++++++++++- .../src/routes/(app)/scan-qr/+page.svelte | 6 +- .../src/routes/(app)/scan-qr/scanLogic.ts | 8 ++ .../eid-wallet/src/routes/+layout.svelte | 10 +++ 5 files changed, 166 insertions(+), 4 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts index a9ae3a20e..e2ed6dad7 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { beginAuthPrompt, + clearDeepLinkAcknowledged, clearDeepLinkFlow, endAuthPrompt, isAuthPromptInFlight, @@ -19,6 +20,7 @@ import { shouldAbortStaleContinuation, shouldRedirectToLogin, takeCompletedDeepLink, + wasDeepLinkJustAcknowledged, } from "./deepLinkFlow"; /** @@ -584,3 +586,83 @@ describe("single biometric prompt site", () => { expect(shouldRedirectToLogin("/onboarding")).toBe(true); }); }); + +describe("acknowledged confirmation", () => { + // Approving a login calls openUrl, which restarts the Activity. If the + // user taps Ok inside the short window BEFORE that restart lands, the + // rebuilt webview reloads /scan-qr with the payload suppressed and the + // confirmation already consumed. It then saw "no deep link" and opened the + // camera — a scanner the user never asked for. + + it("suppresses the scanner after the user dismisses the confirmation", () => { + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + + // The user taps Ok before the restart lands. + expect(takeCompletedDeepLink(true)).not.toBeNull(); + + // The rebuilt webview finds nothing to show and must NOT start the + // camera. + expect(takeCompletedDeepLink()).toBeNull(); + expect(wasDeepLinkJustAcknowledged()).toBe(true); + }); + + it("does not suppress the scanner merely for rendering the confirmation", () => { + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + + // Restoring the card after a restart is not a dismissal: the user has + // not answered yet, so a later genuine scan must still work. + expect(takeCompletedDeepLink()).not.toBeNull(); + + expect(wasDeepLinkJustAcknowledged()).toBe(false); + }); + + it("records the dismissal even when the card was already restored", () => { + // The restore path consumes the record when it RENDERS, so by the time + // Ok is tapped there is nothing left to take. The acknowledgement must + // still be written or the restart reopens the camera. + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + takeCompletedDeepLink(); + + expect(takeCompletedDeepLink(true)).toBeNull(); + + expect(wasDeepLinkJustAcknowledged()).toBe(true); + }); + + it("lets the user open the scanner deliberately right after", () => { + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + takeCompletedDeepLink(true); + + // Tapping Scan is an in-app navigation, which proves intent. Without + // this the user would be bounced back to /main for 30 seconds. + clearDeepLinkAcknowledged(); + + expect(wasDeepLinkJustAcknowledged()).toBe(false); + }); + + it("expires so it can never suppress a later scan", () => { + vi.useFakeTimers(); + try { + markDeepLinkCompleted({ + platform: "pictique", + hostname: "p.example", + }); + takeCompletedDeepLink(true); + expect(wasDeepLinkJustAcknowledged()).toBe(true); + + vi.advanceTimersByTime(31_000); + + expect(wasDeepLinkJustAcknowledged()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("forgets the acknowledgement on logout", () => { + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + takeCompletedDeepLink(true); + + resetAuthSession(); + + expect(wasDeepLinkJustAcknowledged()).toBe(false); + }); +}); diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts index 2dec23c75..7e90affe4 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts @@ -59,6 +59,7 @@ const LAST_URL_KEY = "deepLinkLastUrl"; const HANDLED_URL_KEY = "deepLinkHandledUrl"; const HANDLED_AT_KEY = "deepLinkHandledAt"; const COMPLETED_KEY = "deepLinkCompleted"; +const ACKNOWLEDGED_KEY = "deepLinkAcknowledgedAt"; /** * How long a just-handled URL keeps suppressing further deliveries. @@ -228,10 +229,27 @@ export function markDeepLinkCompleted( ); } -/** Consume the pending confirmation, if one is waiting. Single-use. */ -export function takeCompletedDeepLink(): CompletedDeepLink | null { +/** + * Consume the pending confirmation, if one is waiting. Single-use. + * + * `acknowledged` distinguishes the two callers, and the distinction matters + * because the Activity restart can still be pending when the user acts: + * + * false (default) the confirmation is being RENDERED. It must not be shown + * twice, but the user has not dismissed it yet. + * true the user tapped Ok. The request is finished for good, so + * a webview rebuilt after this must not reopen the scanner + * as if the user had asked to scan something. + */ +export function takeCompletedDeepLink( + acknowledged = false, +): CompletedDeepLink | null { const d = durableStore(); if (!d) return null; + // Record the acknowledgement FIRST. On the restore path the confirmation + // was already consumed when it was rendered, so by the time the user taps + // Ok there is no record left and the early return below would skip this. + if (acknowledged) d.setItem(ACKNOWLEDGED_KEY, String(Date.now())); const value = d.getItem(COMPLETED_KEY); if (value === null) return null; d.removeItem(COMPLETED_KEY); @@ -249,6 +267,47 @@ export function takeCompletedDeepLink(): CompletedDeepLink | null { /* ------------------------------------------------------------ dedupe guard */ +/** + * Did the user just dismiss a deep-link confirmation? + * + * /scan-qr is two different screens wearing one route. Reached from the Scan + * button it is a camera; reached by a deep link it is a consent/confirmation + * screen that happens to fall through to the camera when it finds no payload. + * That fall-through is correct for a deliberate scan and wrong for the tail of + * a finished login. + * + * Tapping Ok can be followed by the Activity restart that the login's own + * `openUrl` set in motion. The rebuilt webview reloads /scan-qr with the + * payload suppressed and the confirmation consumed, so it sees "no deep link" + * and opens the camera — a scanner the user never asked for. + * + * Bounded by the same window as the replay guard: it suppresses only the + * restart caused by the login just acknowledged, never a later genuine scan. + */ +export function wasDeepLinkJustAcknowledged(): boolean { + const d = durableStore(); + if (!d) return false; + const at = Number(d.getItem(ACKNOWLEDGED_KEY) ?? 0); + if (!at) return false; + if (Date.now() - at < REPLAY_WINDOW_MS) return true; + d.removeItem(ACKNOWLEDGED_KEY); + return false; +} + +/** + * Forget the acknowledgement, because the user has deliberately asked for the + * scanner. + * + * The marker must only ever suppress a webview REBUILD, never a navigation the + * user performed. A SPA navigation proves intent (the Scan button was tapped); + * an Activity restart produces a fresh page load and fires no navigation hook + * at all. Clearing here is what keeps "tap Ok, then immediately tap Scan" from + * bouncing the user straight back to /main. + */ +export function clearDeepLinkAcknowledged(): void { + durableStore()?.removeItem(ACKNOWLEDGED_KEY); +} + /** * True when this exact URL is already being handled. * @@ -428,4 +487,5 @@ export function resetAuthSession(): void { d?.removeItem(HANDLED_AT_KEY); d?.removeItem(LAST_URL_KEY); d?.removeItem(COMPLETED_KEY); + d?.removeItem(ACKNOWLEDGED_KEY); } diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte index 0dba47ee5..46481ec9c 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte @@ -147,8 +147,10 @@ function handleAuthDrawerOpenChange(value: boolean) { async function handleLoggedInDrawerConfirm() { setLoggedInDrawerOpen(false); // Acknowledged: drop the stored confirmation so a later app restart cannot - // resurrect it. - takeCompletedDeepLink(); + // resurrect it, and record the dismissal. The Activity restart caused by + // this login's own openUrl can still be pending, and the rebuilt webview + // would otherwise find no payload and open the camera. + takeCompletedDeepLink(true); // /scan-qr is a transient deep-link destination. Replace it so Android // back cannot reopen the camera after login, and never start the camera // after this page has navigated away. diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts index d8572033a..91c5168d1 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts @@ -27,6 +27,7 @@ import { markDeepLinkHandled, peekDeepLinkPayload, takeCompletedDeepLink, + wasDeepLinkJustAcknowledged, } from "$lib/utils/deepLinkFlow"; export interface SigningData { @@ -1742,6 +1743,13 @@ export function createScanLogic({ // to show and silently fell through to the camera. clearDeepLinkFlow(); } + } else if (wasDeepLinkJustAcknowledged()) { + // The Activity restart that this login's own openUrl set in motion + // landed after the user had already tapped Ok. There is nothing + // left to show, but starting the camera would hand them a scanner + // they never asked for. Return home instead. + console.log("Deep link already acknowledged, returning to main"); + await goto("/main"); } else { console.log("No deep link data found, starting normal scanning"); startScan(); diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index 8b466718c..b7ec81d27 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import { GlobalState } from "$lib/global/state"; import { runtime } from "$lib/global/runtime.svelte"; import { swipedetect } from "$lib/utils"; import { + clearDeepLinkAcknowledged, isAuthPromptInFlight, isDeepLinkFlowActive, isDuplicateDelivery, @@ -318,6 +319,15 @@ onNavigate((navigation) => { sessionStorage.setItem("navigatingToOnboarding", "true"); } + // Reaching /scan-qr through an in-app navigation means the user tapped + // Scan, so the camera is what they want. The "just acknowledged" marker + // exists only to stop a webview REBUILD from opening the scanner after a + // finished login; a rebuild is a fresh page load and never fires this + // hook, so clearing here cannot mask the case it guards. + if (to === "/scan-qr") { + clearDeepLinkAcknowledged(); + } + // Direction comes from the navigation TYPE, not stack indices. Indexing // into the stack gave wrong answers when the user navigated forward to a // route they'd visited before in the same session: the old entry was still From 12983713cede28853481bc858ed8069f407a3d02 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Wed, 16 Sep 2026 12:06:01 +0530 Subject: [PATCH 26/31] perf(eid-wallet): stop the splash waiting on unrelated startup work The splash blocks on initialDeepLinkReady before it may show the biometric prompt. That gate was added to fix the cold-start race, but it was placed at the END of a serial chain in the root layout: await checkStatus() plugin IPC await GlobalState.create() disk-backed store load await import(deep-link) dynamic chunk await onOpenUrl() / getCurrent() -> resolveInitialDeepLink() So every launch paid for the biometry probe and the store load before the prompt could appear, even though deep-link discovery needs neither: handleDeepLink and parseDeepLink touch neither globalState nor runtime.biometry. Two changes, both ordering only: - Deep-link discovery starts immediately, concurrently with the rest of onMount, so the gate resolves as soon as getCurrent() returns. - GlobalState.create() no longer waits for checkStatus(). The probe's only product is runtime.biometry, which nothing reads back today, and globalState is what the splash actually polls for. The invariants the deep-link fix depends on are unchanged: onOpenUrl is still registered before getCurrent(), so a URL cannot fall into a gap between them, and resolveInitialDeepLink() still runs in a finally, so the gate opens even when the plugin import fails. --- .../eid-wallet/src/routes/+layout.svelte | 127 +++++++++++------- 1 file changed, 75 insertions(+), 52 deletions(-) diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index b7ec81d27..b8abdbcf5 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -19,7 +19,7 @@ import { shouldRedirectToLogin, } from "$lib/utils/deepLinkFlow"; import { installTerminalConsoleBridge } from "$lib/utils/terminalConsole"; -import { type Status, checkStatus } from "@tauri-apps/plugin-biometric"; +import { checkStatus } from "@tauri-apps/plugin-biometric"; // Mirror console.* to the Tauri host stdout so logs land in `pnpm tauri dev`. installTerminalConsoleBridge(); @@ -90,65 +90,88 @@ onMount(async () => { preloadCode("/onboarding").catch(() => {}); preloadCode("/recover").catch(() => {}); - let status: Status | undefined = undefined; - try { - status = await checkStatus(); - } catch (error) { - status = { - biometryType: 0, - isAvailable: false, - }; - } - runtime.biometry = status.biometryType; - try { - globalState = await GlobalState.create(); - } catch (error) { - console.error("Failed to initialize global state:", error); - // Consider adding fallback behavior or user notification - } - - // Handle deep links - try { - const { onOpenUrl, getCurrent } = await import( - "@tauri-apps/plugin-deep-link" - ); - - // Register first so a URL delivered while getCurrent() is checking the - // cold-start payload cannot fall into a gap between the two calls. - await onOpenUrl((urls) => { - if (urls && urls.length > 0) { - try { - // handleDeepLink stores pendingDeepLink synchronously when - // authentication is required, before starting navigation. - handleDeepLink(urls[0]); - } catch (error) { - console.error( - "Error handling deep link from onOpenUrl:", - error, - ); + // Deep-link discovery starts FIRST and runs concurrently with the work + // below, because the splash blocks on initialDeepLinkReady before it can + // show the biometric prompt. + // + // It used to sit after `await checkStatus()` and `await + // GlobalState.create()`, so the prompt waited on two plugin IPCs and a + // disk-backed store load that the deep-link path does not need: + // handleDeepLink and parseDeepLink touch neither globalState nor + // runtime.biometry. That serialisation was pure latency on the splash, + // on every single launch. + // + // These are function declarations inside this onMount, so they are + // hoisted and safe to call from here. + const deepLinkReady = (async () => { + try { + const { onOpenUrl, getCurrent } = await import( + "@tauri-apps/plugin-deep-link" + ); + + // Register first so a URL delivered while getCurrent() is checking + // the cold-start payload cannot fall into a gap between the two + // calls. + await onOpenUrl((urls) => { + if (urls && urls.length > 0) { + try { + // handleDeepLink stores pendingDeepLink synchronously + // when authentication is required, before starting + // navigation. + handleDeepLink(urls[0]); + } catch (error) { + console.error( + "Error handling deep link from onOpenUrl:", + error, + ); + } } + }); + + // Check if app was started via deep link. + const initialUrls = await getCurrent(); + if (initialUrls && initialUrls.length > 0) { + handleDeepLink(initialUrls[0]); } - }); - // Check if app was started via deep link. - const initialUrls = await getCurrent(); - if (initialUrls && initialUrls.length > 0) { - handleDeepLink(initialUrls[0]); + // NOTE: there is deliberately no window-level "deepLinkReceived" + // listener here. handleDeepLink dispatches that event itself, so a + // listener in this layout would re-handle its own dispatch, + // re-write sessionStorage and fire a second goto("/scan-qr") — a + // duplicate navigation that could unmount /scan-qr's drawer just + // after it opened. /scan-qr subscribes to the event directly; that + // is the only consumer it needs. + } catch (error) { + console.error("Failed to initialize deep link listener:", error); + } finally { + resolveInitialDeepLink(); } + })(); + + // globalState is what the splash actually polls for before it can read + // the PIN hash and prompt, so create it FIRST and let the biometry probe + // run alongside. checkStatus() is a plugin IPC whose only product is + // runtime.biometry, which nothing reads back yet, so blocking the store + // load behind it was pure dead time on the splash. + const biometryReady = checkStatus() + .then((status) => { + runtime.biometry = status.biometryType; + }) + .catch(() => { + runtime.biometry = 0; + }); - // NOTE: there is deliberately no window-level "deepLinkReceived" - // listener here. handleDeepLink dispatches that event itself, so a - // listener in this layout would re-handle its own dispatch, re-write - // sessionStorage and fire a second goto("/scan-qr") — a duplicate - // navigation that could unmount /scan-qr's drawer just after it - // opened. /scan-qr subscribes to the event directly; that is the only - // consumer it needs. + try { + globalState = await GlobalState.create(); } catch (error) { - console.error("Failed to initialize deep link listener:", error); - } finally { - resolveInitialDeepLink(); + console.error("Failed to initialize global state:", error); + // Consider adding fallback behavior or user notification } + // Both are already running; awaiting them here only keeps onMount's + // ordering honest and stops an unexpected rejection going unhandled. + await Promise.all([deepLinkReady, biometryReady]); + // Helper function to check if user is on an authenticated route. // Routes under (app)/ are protected by the auth guard. Since SvelteKit // route groups (parentheses) don't appear in the URL, enumerate the From b9eb573bbf568befd3463c4ac1612846ff31ff4c Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Wed, 16 Sep 2026 12:24:45 +0530 Subject: [PATCH 27/31] fix(eid-wallet): make splash auth ownership an explicit claim The consent screen disappeared again on fast authentication, the original bug this branch exists to fix. The cause was the ownership rule added when the biometric prompt was canonicalized onto the splash: if (promptInFlight) return false; if (currentPath === "/") return false; // <- wrong return true; Inferring "the splash owns the prompt" from the pathname is not sound. SvelteKit navigation is asynchronous, so location.pathname is still "/" for as long as a goto() takes to land. After continueAfterSuccessfulAuth collected the payload, released the prompt bracket and called goto("/scan-qr"), a duplicate delivery arriving in that window saw pathname "/" and deferred to an owner that had already finished. The payload stayed parked, nobody collected it, and the consent screen never appeared. Authenticating slowly moved the delivery out of that window, which is why it only reproduced on fast authentication. Ownership is now an explicit claim. The splash claims it before awaiting deep-link discovery (a URL delivered during that await must already see an owner) and releases it in a finally covering every exit path: success, cancel, failure, or a superseded continuation. On success the release happens only after continueAfterSuccessfulAuth has awaited its goto to completion, so there is no window where the payload is collected but ownership has lapsed. shouldRedirectToLogin no longer takes a path at all, so the unsound inference cannot be reintroduced by accident. --- .../src/lib/utils/deepLinkFlow.spec.ts | 83 +++++++--- .../eid-wallet/src/lib/utils/deepLinkFlow.ts | 52 ++++-- .../eid-wallet/src/routes/+layout.svelte | 13 +- .../eid-wallet/src/routes/+page.svelte | 151 +++++++++++------- 4 files changed, 207 insertions(+), 92 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts index e2ed6dad7..2a590366e 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { beginAuthPrompt, + claimSplashAuthOwnership, clearDeepLinkAcknowledged, clearDeepLinkFlow, endAuthPrompt, @@ -16,6 +17,7 @@ import { markWalletAuthenticated, peekDeepLinkPayload, promotePendingDeepLink, + releaseSplashAuthOwnership, resetAuthSession, shouldAbortStaleContinuation, shouldRedirectToLogin, @@ -179,7 +181,8 @@ describe("cold-start orderings", () => { // The splash stays mounted and owns the prompt even when a deep link // is pending, so that a deep-link launch still gets biometrics. expect(isDeepLinkFlowActive()).toBe(true); - expect(shouldRedirectToLogin("/")).toBe(false); + claimSplashAuthOwnership(); + expect(shouldRedirectToLogin()).toBe(false); beginAuthPrompt(); endAuthPrompt(); @@ -549,41 +552,83 @@ describe("superseded splash/login continuation", () => { describe("single biometric prompt site", () => { // The biometric dialog used to be fired from BOTH the splash and /login. // Both screens prompted on mount, so whichever won the race decided which - // backdrop the system dialog appeared over — users saw it over the purple - // splash sometimes and over a half-painted PIN pad other times. The splash - // is now the only prompt site, which means the deep-link handler must stop - // navigating away from whoever owns that prompt. + // backdrop the system dialog appeared over. The splash is now the only + // prompt site, which means the deep-link handler must stop navigating away + // from whoever owns that prompt. it("keeps a deep-link launch on the splash so it still gets biometrics", () => { - // The regression this pins: the handler used to goto("/login") the - // moment a cold-start URL was parked. That unmounted the splash before - // it could prompt, so a deep-link launch was PIN-only by construction. + // The handler used to goto("/login") the moment a cold-start URL was + // parked. That unmounted the splash before it could prompt, so a + // deep-link launch was PIN-only by construction. markDeepLinkPending(AUTH_PAYLOAD); + claimSplashAuthOwnership(); - expect(shouldRedirectToLogin("/", false)).toBe(false); + expect(shouldRedirectToLogin(false)).toBe(false); }); it("does not navigate while a prompt is on screen", () => { beginAuthPrompt(); - // Applies even away from the splash: the post-auth routine owns - // routing, and a goto() here would race it. - expect(shouldRedirectToLogin("/login", true)).toBe(false); - expect(shouldRedirectToLogin("/", true)).toBe(false); + expect(shouldRedirectToLogin(true, false)).toBe(false); }); it("still routes to login when no screen owns the prompt", () => { // Without this the payload would be parked with nobody to collect it. - expect(shouldRedirectToLogin("/onboarding", false)).toBe(true); - expect(shouldRedirectToLogin("/recover", false)).toBe(true); + expect(shouldRedirectToLogin(false, false)).toBe(true); }); - it("reads the live prompt bracket when none is supplied", () => { - expect(shouldRedirectToLogin("/onboarding")).toBe(true); + it("reads the live claims when none are supplied", () => { + expect(shouldRedirectToLogin()).toBe(true); + + claimSplashAuthOwnership(); + expect(shouldRedirectToLogin()).toBe(false); + releaseSplashAuthOwnership(); + expect(shouldRedirectToLogin()).toBe(true); + + beginAuthPrompt(); + expect(shouldRedirectToLogin()).toBe(false); + endAuthPrompt(); + expect(shouldRedirectToLogin()).toBe(true); + }); + + it("routes a URL re-delivered after the splash finished its handover", () => { + // THE regression that made the consent screen vanish on fast + // authentication, and the reason ownership cannot be a pathname check. + // + // The splash authenticates, continueAfterSuccessfulAuth collects the + // payload, releases the prompt bracket and calls goto("/scan-qr"). + // SvelteKit navigation is async, so location.pathname is STILL "/" + // while that goto is in flight. A duplicate delivery landing in that + // window used to see path "/" and defer to an owner that had already + // finished, leaving the payload parked with nobody to collect it. + claimSplashAuthOwnership(); beginAuthPrompt(); - expect(shouldRedirectToLogin("/onboarding")).toBe(false); + + // Handover completes and the splash hands off. + markWalletAuthenticated(); endAuthPrompt(); - expect(shouldRedirectToLogin("/onboarding")).toBe(true); + releaseSplashAuthOwnership(); + + // The re-delivered URL must now be routed, not deferred, even though + // the pathname has not caught up yet. + expect(shouldRedirectToLogin()).toBe(true); + }); + + it("releases ownership when the user declines biometrics", () => { + // The splash falls through to /login on cancel. If the claim leaked, + // every later deep link would defer to a screen that is gone. + claimSplashAuthOwnership(); + releaseSplashAuthOwnership(); + + expect(shouldRedirectToLogin()).toBe(true); + }); + + it("forgets a leaked ownership claim on logout", () => { + claimSplashAuthOwnership(); + + resetAuthSession(); + + expect(shouldRedirectToLogin()).toBe(true); }); }); diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts index 7e90affe4..989c9dc5e 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts @@ -60,6 +60,7 @@ const HANDLED_URL_KEY = "deepLinkHandledUrl"; const HANDLED_AT_KEY = "deepLinkHandledAt"; const COMPLETED_KEY = "deepLinkCompleted"; const ACKNOWLEDGED_KEY = "deepLinkAcknowledgedAt"; +const SPLASH_OWNS_AUTH_KEY = "splashOwnsAuthPrompt"; /** * How long a just-handled URL keeps suppressing further deliveries. @@ -397,6 +398,34 @@ export function isAuthPromptInFlight(): boolean { return store()?.getItem(AUTH_IN_FLIGHT_KEY) === "true"; } +/** + * Claim/release the pre-app auth prompt for the splash screen. + * + * Distinct from beginAuthPrompt, which brackets the moment the user's finger + * is actually on the sensor. This is the WIDER window: from the splash + * deciding it will authenticate, until it has handed the user onward. During + * that window the splash is the one screen responsible for routing a parked + * payload, so the deep-link handler must not navigate on its own. + * + * It exists because "the splash owns the prompt" cannot be inferred from + * `window.location.pathname === "/"`. SvelteKit navigation is asynchronous, so + * the pathname is still "/" for a while after the splash has called goto(). + * Treating that as "a prompt is coming" made the handler defer to an owner + * that had already finished, and the parked payload was never collected — the + * consent screen simply never appeared. + */ +export function claimSplashAuthOwnership(): void { + store()?.setItem(SPLASH_OWNS_AUTH_KEY, "true"); +} + +export function releaseSplashAuthOwnership(): void { + store()?.removeItem(SPLASH_OWNS_AUTH_KEY); +} + +export function splashOwnsAuthPrompt(): boolean { + return store()?.getItem(SPLASH_OWNS_AUTH_KEY) === "true"; +} + /** * Should the deep-link handler navigate an unauthenticated user to /login? * @@ -411,20 +440,24 @@ export function isAuthPromptInFlight(): boolean { * * - A prompt is already up: its post-auth routine collects the parked payload * and routes. A goto() here would race that navigation. - * - We are still on the splash: it is about to prompt (or has just decided it - * cannot). Unmounting it now would discard the biometric prompt entirely and - * dump the user on the PIN pad. It routes to /login by itself when - * biometrics are unavailable or refused, so waiting costs nothing. - * - * Anywhere else there is no prompt owner, so the handler must navigate or the - * parked payload would sit with nobody to collect it. + * - The splash has CLAIMED the prompt: it is about to authenticate, or is + * mid-handover. Unmounting it now would discard the biometric prompt and + * dump the user on the PIN pad. It routes onward by itself in every exit + * path, so waiting costs nothing. + * + * Both conditions are explicit claims, never inferred from the pathname. The + * regression that made the consent screen vanish on fast authentication came + * from inferring ownership from `pathname === "/"`: after the splash's + * handover released the prompt bracket, a re-delivered URL still saw "/" for + * as long as the goto() took to land, so the handler deferred to an owner that + * no longer existed and the payload was left parked forever. */ export function shouldRedirectToLogin( - currentPath: string, promptInFlight = isAuthPromptInFlight(), + splashOwns = splashOwnsAuthPrompt(), ): boolean { if (promptInFlight) return false; - if (currentPath === "/") return false; + if (splashOwns) return false; return true; } @@ -478,6 +511,7 @@ export function resetAuthSession(): void { const s = store(); clearDeepLinkFlow(); s?.removeItem(AUTH_IN_FLIGHT_KEY); + s?.removeItem(SPLASH_OWNS_AUTH_KEY); s?.removeItem(AUTHED_KEY); // The in-flight marker is mirrored in both stores; clear both or a link // followed before logging out stays blocked afterwards. diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index b8abdbcf5..4fe571505 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -17,6 +17,7 @@ import { markDeepLinkPending, markDeepLinkReady, shouldRedirectToLogin, + splashOwnsAuthPrompt, } from "$lib/utils/deepLinkFlow"; import { installTerminalConsoleBridge } from "$lib/utils/terminalConsole"; import { checkStatus } from "@tauri-apps/plugin-biometric"; @@ -285,11 +286,17 @@ onMount(async () => { // The splash is the single biometric prompt site, so the handler // must not steer away from whoever currently owns that prompt. - // See shouldRedirectToLogin for the full reasoning. - if (!shouldRedirectToLogin(currentPath)) { + // Ownership is an explicit claim, never inferred from the pathname + // — see shouldRedirectToLogin for why that distinction is what + // makes the consent screen survive fast authentication. + if (!shouldRedirectToLogin()) { console.log( "Deferring navigation: the auth prompt owner will route", - { currentPath, authPromptInFlight: isAuthPromptInFlight() }, + { + currentPath, + authPromptInFlight: isAuthPromptInFlight(), + splashOwnsAuth: splashOwnsAuthPrompt(), + }, ); return; } diff --git a/infrastructure/eid-wallet/src/routes/+page.svelte b/infrastructure/eid-wallet/src/routes/+page.svelte index 86adfb4ab..d15461f7d 100644 --- a/infrastructure/eid-wallet/src/routes/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/+page.svelte @@ -5,8 +5,10 @@ import SplashScreen from "$lib/fragments/SplashScreen/SplashScreen.svelte"; import type { GlobalState } from "$lib/global"; import { beginAuthPrompt, + claimSplashAuthOwnership, endAuthPrompt, isWalletAuthenticated, + releaseSplashAuthOwnership, shouldAbortStaleContinuation, } from "$lib/utils/deepLinkFlow"; import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; @@ -125,76 +127,103 @@ onMount(async () => { return; } - // The root layout discovers a cold-start URL asynchronously. Wait for - // that discovery before deciding whether this is an ordinary launch, - // otherwise fast biometric authentication wins the race and routes to - // /main before the payload has even been stored. - await initialDeepLinkReady; - if (superseded()) return; - - // NOTE: a pending deep link deliberately does NOT divert to /login - // here. The splash is the single place a biometric prompt is allowed - // to appear, so diverting would mean a deep-link launch never offers - // biometrics at all. continueAfterSuccessfulAuth below collects the - // parked payload and routes to the consent screen itself. - - // Fire biometric over the splash. This is the ONLY biometric prompt in - // the pre-app flow: /login is the PIN fallback and never prompts. That - // is what makes the placement deterministic — previously both screens - // could prompt, and whichever won the race decided which background - // the system dialog appeared over. + // From here this screen is the sole owner of pre-app authentication, + // and stays the owner until it has handed the user onward. Claimed + // BEFORE the await below, because a URL delivered during that await + // must already see an owner: otherwise the handler navigates to /login + // on its own and the splash's prompt is lost. // - // On success we run the post-auth chores and route onward (no /login - // flash). On cancel/fail/unavailable we slide into /login for PIN - // entry, so a user without biometrics is never stuck on the splash. - let biometricAvailable = false; + // This is a durable claim rather than a pathname check. SvelteKit + // navigation is async, so `location.pathname` is still "/" for a while + // after we call goto() — inferring ownership from that made the + // handler defer to an owner that had already finished, and the parked + // payload was never collected. + claimSplashAuthOwnership(); try { - biometricAvailable = - !!globalState && - (await globalState.securityController.biometricSupport) && - (await checkStatus()).isAvailable; - } catch (error) { - console.error("Biometric availability check failed:", error); - } - if (superseded()) return; - - if (biometricAvailable && globalState) { - // Tell the deep-link handler that a prompt owns the screen. A URL - // arriving while the user's finger is on the sensor must park its - // payload and let continueAfterSuccessfulAuth route, instead of - // firing its own competing navigation. - beginAuthPrompt(); - try { - await authenticate( - "You must authenticate with PIN first", - authOpts, - ); - // Success — run the shared post-auth routine, which routes to - // the pending deep link if there is one and /main otherwise. - // NOTE: the prompt bracket stays OPEN here on purpose. - // continueAfterSuccessfulAuth closes it itself, at the exact - // point where it has collected any pending payload. Closing it - // here would leave that routine's awaits unbracketed and - // reopen the navigation race. - await continueAfterSuccessfulAuth(globalState); - return; - } catch (e) { - // Cancel/fail — fall through to /login for PIN entry. - console.warn("Biometric on splash failed", e); - } finally { - // Idempotent: a no-op on the success path, where - // continueAfterSuccessfulAuth has already released it. - endAuthPrompt(); - } + await runReturningUserAuth(globalState); + } finally { + // Every exit path releases: success (already navigated), cancel, + // failure, or a superseded continuation. Leaving it set would make + // a later deep link defer to an owner that no longer exists. + releaseSplashAuthOwnership(); } - - await goto("/login"); return; } // First-time user — reveal the drawer with CTAs. splashShowDrawer = true; }); + +/** + * Authenticate a returning user and hand them onward. Split out so the + * ownership claim above has exactly one release point. + */ +async function runReturningUserAuth(globalState: GlobalState | undefined) { + // The root layout discovers a cold-start URL asynchronously. Wait for + // that discovery before deciding whether this is an ordinary launch, + // otherwise fast biometric authentication wins the race and routes to + // /main before the payload has even been stored. + await initialDeepLinkReady; + if (superseded()) return; + + // NOTE: a pending deep link deliberately does NOT divert to /login + // here. The splash is the single place a biometric prompt is allowed + // to appear, so diverting would mean a deep-link launch never offers + // biometrics at all. continueAfterSuccessfulAuth below collects the + // parked payload and routes to the consent screen itself. + + // Fire biometric over the splash. This is the ONLY biometric prompt in + // the pre-app flow: /login is the PIN fallback and never prompts. That + // is what makes the placement deterministic — previously both screens + // could prompt, and whichever won the race decided which background + // the system dialog appeared over. + // + // On success we run the post-auth chores and route onward (no /login + // flash). On cancel/fail/unavailable we slide into /login for PIN + // entry, so a user without biometrics is never stuck on the splash. + let biometricAvailable = false; + try { + biometricAvailable = + !!globalState && + (await globalState.securityController.biometricSupport) && + (await checkStatus()).isAvailable; + } catch (error) { + console.error("Biometric availability check failed:", error); + } + if (superseded()) return; + + if (biometricAvailable && globalState) { + // Tell the deep-link handler that a prompt owns the screen. A URL + // arriving while the user's finger is on the sensor must park its + // payload and let continueAfterSuccessfulAuth route, instead of + // firing its own competing navigation. + beginAuthPrompt(); + try { + await authenticate( + "You must authenticate with PIN first", + authOpts, + ); + // Success — run the shared post-auth routine, which routes to + // the pending deep link if there is one and /main otherwise. + // NOTE: the prompt bracket stays OPEN here on purpose. + // continueAfterSuccessfulAuth closes it itself, at the exact + // point where it has collected any pending payload. Closing it + // here would leave that routine's awaits unbracketed and + // reopen the navigation race. + await continueAfterSuccessfulAuth(globalState); + return; + } catch (e) { + // Cancel/fail — fall through to /login for PIN entry. + console.warn("Biometric on splash failed", e); + } finally { + // Idempotent: a no-op on the success path, where + // continueAfterSuccessfulAuth has already released it. + endAuthPrompt(); + } + } + + await goto("/login"); +} Date: Wed, 16 Sep 2026 12:32:40 +0530 Subject: [PATCH 28/31] fix(eid-wallet): claim splash auth ownership at init, not 1.2s later MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two symptoms, one cause: the consent screen vanished on fast authentication, and the launch sometimes went straight to the PIN pad without offering biometrics at all. The ownership claim added in the previous commit ran far too late. It sat after the splash's 800ms + 400ms intro, the globalState poll and three store reads — well over a second after mount. But the deep link is delivered from the root layout's onMount, which runs inside that window. So on a cold start via deep link the handler saw no prompt in flight and no ownership claim, concluded nobody would route the payload, and navigated to /login. That unmounted the splash before it could prompt. Since /login is now PIN-only, the user got the PIN pad instead of biometrics, and the parked payload was left for a screen that no longer routes it. The claim is now written synchronously at component init, before any await, so it is in place before the layout's onMount can deliver anything. Released on every exit that does not authenticate: no PIN set, first-time user, and via onDestroy for any teardown. The success path still releases only after continueAfterSuccessfulAuth has awaited its goto, so the payload is always collected first. skipIntro (backward nav from /onboarding) never claims: it is an ordinary in-app navigation that does not authenticate. --- .../src/lib/utils/deepLinkFlow.spec.ts | 22 ++++++++ .../eid-wallet/src/routes/+page.svelte | 54 +++++++++++++------ 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts index 2a590366e..5673475a8 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts @@ -591,6 +591,28 @@ describe("single biometric prompt site", () => { expect(shouldRedirectToLogin()).toBe(true); }); + it("keeps the launch on the splash during its intro animation", () => { + // THE cold-start case, and the one the pathname check hid. The deep + // link is delivered from the root layout's onMount, which runs while + // the splash is still playing its ~1.2s intro — long before it reaches + // the biometric prompt. + // + // The splash therefore claims ownership at component INIT, not when it + // is finally ready to authenticate. Claiming late left a window of + // over a second in which the URL saw no owner, so the handler + // navigated to /login and unmounted the splash before it could prompt. + // Since /login is PIN-only, the user got the PIN pad instead of + // biometrics and the payload was left for a screen that never routes + // it. + claimSplashAuthOwnership(); + + // Delivery lands mid-intro: no prompt is on screen yet. + markDeepLinkPending(AUTH_PAYLOAD); + + expect(isAuthPromptInFlight()).toBe(false); + expect(shouldRedirectToLogin()).toBe(false); + }); + it("routes a URL re-delivered after the splash finished its handover", () => { // THE regression that made the consent screen vanish on fast // authentication, and the reason ownership cannot be a pathname check. diff --git a/infrastructure/eid-wallet/src/routes/+page.svelte b/infrastructure/eid-wallet/src/routes/+page.svelte index d15461f7d..4a3e7af83 100644 --- a/infrastructure/eid-wallet/src/routes/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/+page.svelte @@ -76,6 +76,33 @@ function superseded(): boolean { return shouldAbortStaleContinuation(destroyed, authenticatedAtStart); } +// Claim the pre-app auth prompt SYNCHRONOUSLY, at component init, before any +// await in onMount. +// +// The deep link is delivered from the root layout's onMount, which runs while +// this screen is still playing its 1.2s intro. Claiming ownership later (after +// the animation, the globalState poll and three store reads) left a window of +// well over a second in which a cold-start URL saw no owner at all: the +// handler concluded nobody would route it, navigated to /login, and unmounted +// the splash before it could ever prompt. Since /login is now PIN-only, the +// user got the PIN pad instead of biometrics and the parked payload was left +// for a screen that no longer routes it. +// +// Claiming at init closes that window completely: the claim is written before +// the layout's onMount can run, so every delivery sees an owner. +// +// `skipIntro` is backward-nav from /onboarding, which is a normal in-app +// navigation and never authenticates, so it must not claim. +if (browser && !skipIntro) { + claimSplashAuthOwnership(); +} + +// A splash that is torn down before it hands the user onward must not leave +// the claim behind, or the next deep link defers to an owner that is gone. +onDestroy(() => { + releaseSplashAuthOwnership(); +}); + onMount(async () => { if (skipIntro) { // Backward nav from /onboarding — already at state C, nothing to do. @@ -123,34 +150,29 @@ onMount(async () => { // If no PIN is set we bounce back to onboarding to recover; no // biometric prompt makes sense from that state. if (!pinHash) { + // Not authenticating here, so stop claiming the prompt before + // navigating away. + releaseSplashAuthOwnership(); await goto("/onboarding"); return; } - // From here this screen is the sole owner of pre-app authentication, - // and stays the owner until it has handed the user onward. Claimed - // BEFORE the await below, because a URL delivered during that await - // must already see an owner: otherwise the handler navigates to /login - // on its own and the splash's prompt is lost. - // - // This is a durable claim rather than a pathname check. SvelteKit - // navigation is async, so `location.pathname` is still "/" for a while - // after we call goto() — inferring ownership from that made the - // handler defer to an owner that had already finished, and the parked - // payload was never collected. - claimSplashAuthOwnership(); + // Ownership was claimed at component init (see above), so it is + // already held here. Release it on every exit path: success (the user + // has been handed onward), cancel, failure, or a superseded + // continuation. Leaving it set would make a later deep link defer to + // an owner that no longer exists. try { await runReturningUserAuth(globalState); } finally { - // Every exit path releases: success (already navigated), cancel, - // failure, or a superseded continuation. Leaving it set would make - // a later deep link defer to an owner that no longer exists. releaseSplashAuthOwnership(); } return; } - // First-time user — reveal the drawer with CTAs. + // First-time user — no authentication happens on this launch, so drop the + // claim. A deep link arriving now must be free to route itself. + releaseSplashAuthOwnership(); splashShowDrawer = true; }); From d45489d448af838b0045a9910e2b83f7958df5c7 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Wed, 16 Sep 2026 12:47:30 +0530 Subject: [PATCH 29/31] fix(eid-wallet): stop a finished login being re-offered after a slow return Approving a deep-link login, spending a while on the platform in the browser, then tapping Ok before the Activity restart landed re-opened the consent drawer on the login that had just been completed. The replay-suppression window was measured from the wrong instant. HANDLED_AT is stamped when the user APPROVES, which is before the openUrl handoff, the time spent on the platform, and the Activity restart on the way back. A leisurely round-trip outlives the 30s window, so when the plugin replayed the original intent, isDuplicateDelivery saw an expired marker, treated it as a genuine new request, and re-stored the payload. The logs show exactly that: "Deep link received" is followed by "Found deep link data" for the session that had already been consumed. Tapping Ok now also refreshes HANDLED_AT, so suppression is measured from the dismissal rather than the approval. The URL is unchanged across the round-trip, so refreshing the timestamp is enough to identify it; a genuinely new request carries a different `session`. Deliberately narrow, so the behaviours fixed earlier still hold: - Only the Ok handler passes acknowledged=true, so a request the user has NOT answered is untouched and a rebuilt webview can still reopen it. - The refresh is conditional on a handled URL already existing, so rendering a restored confirmation does not start suppressing anything. - Suppression still expires, so presenting the same offer URI later is still honoured as the new request it is. --- .../src/lib/utils/deepLinkFlow.spec.ts | 54 +++++++++++++++++++ .../eid-wallet/src/lib/utils/deepLinkFlow.ts | 18 ++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts index 5673475a8..cf8b3c390 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts @@ -724,6 +724,60 @@ describe("acknowledged confirmation", () => { } }); + it("does not re-offer a finished login after a slow browser round-trip", () => { + // Reported: approve in the wallet, spend a while on the platform in + // Chrome, come back, tap Ok before the Activity restart lands — and + // the consent drawer re-opened on the login just completed. + // + // HANDLED_AT is stamped at APPROVAL, before the openUrl handoff, the + // time on the platform, and the restart on the way back. A leisurely + // round-trip outlives the 30s replay window, so the replayed intent + // was read as a genuine new request and the payload re-stored. + vi.useFakeTimers(); + try { + const url = "w3ds://auth?session=21fcc8a5&platform=pictique"; + expect(isDuplicateDelivery(url)).toBe(false); + + // User approves and is handed off to the browser. + markDeepLinkHandled(); + markDeepLinkCompleted({ platform: "pictique" }); + + // A slow round-trip: longer than the replay window. + vi.advanceTimersByTime(45_000); + + // Back in the app, the user taps Ok on the confirmation. + takeCompletedDeepLink(true); + + // The Activity restart lands now and replays the original intent. + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("still honours a genuine retry long after the login was dismissed", () => { + // The counterweight: refreshing the window at Ok must not resurrect a + // permanent blacklist. The same offer URI is reused while its QR is on + // screen, so presenting it again later is a real request. + vi.useFakeTimers(); + try { + const url = "w3ds://auth?session=21fcc8a5&platform=pictique"; + isDuplicateDelivery(url); + markDeepLinkHandled(); + markDeepLinkCompleted({ platform: "pictique" }); + takeCompletedDeepLink(true); + + vi.advanceTimersByTime(31_000); + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it("forgets the acknowledgement on logout", () => { markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); takeCompletedDeepLink(true); diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts index 989c9dc5e..08828ec08 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts @@ -250,7 +250,23 @@ export function takeCompletedDeepLink( // Record the acknowledgement FIRST. On the restore path the confirmation // was already consumed when it was rendered, so by the time the user taps // Ok there is no record left and the early return below would skip this. - if (acknowledged) d.setItem(ACKNOWLEDGED_KEY, String(Date.now())); + if (acknowledged) { + d.setItem(ACKNOWLEDGED_KEY, String(Date.now())); + // Restart the replay-suppression window from the dismissal, not from + // the approval. + // + // HANDLED_AT is stamped when the user approves, which is BEFORE the + // openUrl handoff, the time spent on the platform in the browser, and + // the Activity restart on the way back. By the time the replayed + // intent finally arrives, that window may already have expired, so the + // finished login was treated as a genuine new request and the consent + // drawer re-opened on a login the user had just completed. + // + // The URL is unchanged, so refreshing the timestamp is enough; a + // genuinely new request carries a different `session`. + const handledUrl = d.getItem(HANDLED_URL_KEY); + if (handledUrl) d.setItem(HANDLED_AT_KEY, String(Date.now())); + } const value = d.getItem(COMPLETED_KEY); if (value === null) return null; d.removeItem(COMPLETED_KEY); From 70c9bdb9b876d4b416095ff396bad6df0e4ee054 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Wed, 16 Sep 2026 13:07:49 +0530 Subject: [PATCH 30/31] fix(eid-wallet): let a declined login be retried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declining the first Approve/Decline prompt made the next attempt at the same link fail the way the original bug did: no consent screen, straight to /main. Decline recorded the URL as handled DURABLY. That marker exists for one purpose: surviving the Activity restart that happens when approving hands off to the browser via openUrl. Declining never leaves the app, so no restart is coming and there is nothing to survive. The marker just outlived the decision and swallowed the retry for 30 seconds — and because platforms mint one `session` per offer rather than per launch, the retry URL is byte-identical, so isDuplicateDelivery dropped it and the payload was never stored. markDeepLinkHandled now takes a `durable` flag: - approve via openUrl keeps the durable marker (a real Activity restart follows, and the replay must be suppressed across webviews) - decline, and the scan path that POSTs from inside the app, mark session-scoped only, which still collapses Android's getCurrent() / onOpenUrl double delivery within the current webview Verified by probe against the real module: before, a decline followed by a fresh webview reported the retry as a duplicate; after, it is accepted. --- .../src/lib/utils/deepLinkFlow.spec.ts | 51 +++++++++++++++++++ .../eid-wallet/src/lib/utils/deepLinkFlow.ts | 33 ++++++++++-- .../src/routes/(app)/scan-qr/+page.svelte | 15 ++++-- .../src/routes/(app)/scan-qr/scanLogic.ts | 7 ++- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts index cf8b3c390..8d50b9350 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts @@ -68,6 +68,16 @@ beforeEach(() => { * (which happens when a deep-link login hands off to the browser via openUrl). * sessionStorage does not survive that; localStorage does. */ +/** + * Mirror of handleAuthDrawerDecline's completion call. Declining keeps the + * user in the app, so it must NOT write the durable cross-webview marker. + * Kept here as a single definition so that flipping the production call back + * to a durable mark fails these tests rather than passing silently. + */ +function declineDeepLink() { + markDeepLinkHandled(undefined, false); +} + function reloadWebview() { vi.stubGlobal("sessionStorage", new MemoryStorage()); } @@ -328,6 +338,47 @@ describe("duplicate delivery guard", () => { } }); + it("lets the user retry a login they just declined", () => { + // Reported: decline the first Approve/Decline prompt, then open the + // same login link again — and the consent screen never appeared, the + // app just went to /main. + // + // Decline recorded the URL as handled DURABLY, which is the marker + // built to survive an Activity restart. But declining keeps the user + // inside the app; no restart is coming. The durable marker simply + // outlived the decision and swallowed the retry, because platforms + // reuse one `session` per offer so the retry URL is identical. + const url = "w3ds://auth?session=21fcc8a5&platform=pictique"; + expect(isDuplicateDelivery(url)).toBe(false); + + // Drawer takes the payload, user taps Decline. Mirrors + // handleAuthDrawerDecline exactly: no openUrl, so nothing durable. + clearDeepLinkFlow(); + declineDeepLink(); + + // The user presents the same link again. + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(false); + }); + + it("still collapses the double delivery when a decline comes fast", () => { + // The half that must keep working: Android delivers a cold-start URL + // through both getCurrent() and onOpenUrl. A decline arriving before + // the duplicate must not let that second delivery re-open the drawer + // inside the SAME webview. + // + // Note this relies on the in-flight marker, which clearDeepLinkFlow + // releases when the drawer takes ownership — that release is + // deliberate, so a rebuilt webview can reopen an unanswered request. + const url = "w3ds://auth?session=21fcc8a5&platform=pictique"; + expect(isDuplicateDelivery(url)).toBe(false); + + markDeepLinkHandled(undefined, false); + + expect(isDuplicateDelivery(url)).toBe(true); + }); + it("does not strand a URL when the app dies mid-request", () => { // The user opens a link, the consent screen appears, and the app is // killed before they confirm. The request never completed, so nothing diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts index 08828ec08..1494bf52c 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts @@ -183,16 +183,39 @@ export function clearDeepLinkFlow(): void { * The user finished with this request: approved, declined, or it errored out. * * Only now is a further delivery of the same URL a stale replay worth - * dropping. Durable and timestamped, because the replay is delivered by an - * Activity that outlives the webview. + * dropping. + * + * `durable` distinguishes the two endings, and conflating them is what made a + * declined login impossible to retry: + * + * true the decision handed control to the BROWSER (approve calls openUrl). + * Returning from it restarts the Activity, so the plugin replays the + * original intent into a brand-new webview. Only a durable marker + * outlives that, so it has to be written to localStorage. + * + * false the decision kept the user inside the app (decline, or an error). + * No Activity restart is coming, so the only delivery still to + * suppress is Android's getCurrent()/onOpenUrl double-delivery within + * THIS webview. A durable marker here is actively harmful: platforms + * reuse one `session` per offer while its QR is on screen, so + * presenting the same link again is a legitimate retry — and a + * durable marker silently dropped it, leaving the user on /main with + * no consent screen at all. */ -export function markDeepLinkHandled(urlString?: string): void { +export function markDeepLinkHandled(urlString?: string, durable = true): void { const d = durableStore(); if (!d) return; const url = urlString ?? d.getItem(LAST_URL_KEY); if (!url) return; - d.setItem(HANDLED_URL_KEY, url); - d.setItem(HANDLED_AT_KEY, String(Date.now())); + + if (durable) { + d.setItem(HANDLED_URL_KEY, url); + d.setItem(HANDLED_AT_KEY, String(Date.now())); + } + + // Always clear the pointer to the request just finished. The session-scoped + // in-flight marker stays put, so this webview still collapses Android's + // double delivery of the very same URL. d.removeItem(LAST_URL_KEY); } diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte index 46481ec9c..02f494e01 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte @@ -132,10 +132,17 @@ $effect(() => { }); async function handleAuthDrawerDecline() { - // Cancel button always navigates to main - // The user decided. Record completion so an Activity recreate does not - // resurrect a request they just rejected. - markDeepLinkHandled(); + // The user decided. Record completion so Android's second delivery of the + // same cold-start URL does not immediately re-open the drawer they just + // dismissed. + // + // Session-scoped, NOT durable: declining keeps the user inside the app, so + // no Activity restart is coming and there is no cross-webview replay to + // suppress. A durable marker here blocked the retry instead — platforms + // reuse one `session` per offer, so tapping the same login link again is a + // legitimate new request, and it was being silently dropped, leaving the + // user on /main with no consent screen. + markDeepLinkHandled(undefined, false); setCodeScannedDrawerOpen(false); await goto("/main", { replaceState: true }); } diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts index 91c5168d1..e281bc22e 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts @@ -457,7 +457,12 @@ export function createScanLogic({ // The user's decision is complete; further deliveries of this // URL are stale replays. - markDeepLinkHandled(); + // + // Session-scoped: this branch POSTs from inside the app and + // never calls openUrl, so no Activity restart follows and + // there is no cross-webview replay to outlive. Marking it + // durably would block a legitimate retry of the same offer. + markDeepLinkHandled(undefined, false); codeScannedDrawerOpen.set(false); loggedInDrawerOpen.set(true); From 9fa8c8027005ab51ec5f6e9f12a04c6620d23787 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 08:07:01 +0530 Subject: [PATCH 31/31] docs(eid-wallet): document the deep-link login architecture Records the architecture before and after the cold-start deep-link work: what the pre-branch flow did, where it raced, what replaced it, how data moves through each version, and the reason behind every change on the branch. Also records the known limitations, including that the unit tests pin the deepLinkFlow module's semantics but cannot reach the Svelte call sites, so device testing remains the only real proof. --- .../docs/deep-link-login-architecture.md | 475 ++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 infrastructure/eid-wallet/docs/deep-link-login-architecture.md diff --git a/infrastructure/eid-wallet/docs/deep-link-login-architecture.md b/infrastructure/eid-wallet/docs/deep-link-login-architecture.md new file mode 100644 index 000000000..099023ee9 --- /dev/null +++ b/infrastructure/eid-wallet/docs/deep-link-login-architecture.md @@ -0,0 +1,475 @@ +# Deep-link login: architecture before and after + +Scope: the `w3ds://` third-party login flow in the eID wallet. This document +describes the architecture that existed at merge-base `b29340c5`, the +architecture that exists now on `fix/eid-wallet-cold-start-deeplink-race`, how +data moves through each, and the reason behind every change. + +Audience: whoever maintains this next. The intent is that you can reason about +the flow without re-deriving it from the diff. + +--- + +## 1. What the flow has to do + +A third-party site (Pictique, Blabsy, ...) shows a login QR. The user scans it +with the system camera or taps it, and Android hands the wallet a URL: + +``` +w3ds://auth?session=&redirect= +``` + +The wallet must: + +1. Receive the URL, whatever state the app is in (not running, backgrounded, + foregrounded, already on the scanner). +2. Make sure the user is authenticated (biometric, or PIN as fallback). +3. Show an **Approve / Decline** consent screen naming the requesting site. +4. On approve, POST the user's eName to the platform and open the platform in + the browser. +5. Show a confirmation card with an **Ok** button when the user comes back. + +Four properties make this harder than it looks, and every design decision below +traces back to one of them: + +- **P1 — Cold start is a race.** The URL arrives via an async plugin import + while the splash screen is independently deciding where to navigate. Either + can win. +- **P2 — Android delivers a cold-start URL twice.** Once through `getCurrent()` + and once through `onOpenUrl`. Both fire for a single user action. +- **P3 — `openUrl` can destroy the webview.** Handing off to the browser + backgrounds the app; Android may reload the webview, wiping `sessionStorage`. + The Activity is `singleTask`, so the deep-link plugin then **replays the + original intent** into a fresh webview that has no memory of it. +- **P4 — The URL is not unique per launch.** Platforms mint one `session` per + *offer*, and the login QR only refreshes every 60s. A user retrying a login + presents a byte-identical URL. "Seen this URL before" therefore cannot mean + "ignore forever". + +--- + +## 2. Architecture BEFORE (`b29340c5`) + +### 2.1 Components + +| Component | Responsibility | +|---|---| +| `routes/+layout.svelte` | Registers `onOpenUrl` + `getCurrent`, parses URL, decides route | +| `routes/+page.svelte` (splash) | Intro animation, then biometric prompt for returning users | +| `routes/(auth)/login/+page.svelte` | PIN pad **and its own biometric prompt** | +| `lib/utils/postLogin.ts` | Shared post-auth chores, then route to deep link or `/main` | +| `routes/(app)/+layout.svelte` | Auth guard: vault must exist or bounce to `/login` | +| `routes/(app)/scan-qr/scanLogic.ts` | Consent drawers, approve/decline, camera | + +State lived in three raw `sessionStorage` keys, written inline at each call +site with no shared module: + +- `pendingDeepLink` — payload parked because the user is not authenticated yet +- `deepLinkData` — payload ready for `/scan-qr` to consume +- `biometricAttemptedOnSplash` — handshake so `/login` could skip re-prompting + +### 2.2 Data flow + +```mermaid +flowchart TD + A["Android intent w3ds://"] --> B["root +layout onMount
onOpenUrl / getCurrent"] + B --> C["parse URL"] + C --> D{"isAuthenticatedRoute(path)
OR globalState ready?"} + D -- "no" --> E["sessionStorage: pendingDeepLink"] + E --> F["goto /login"] + D -- "yes" --> G["sessionStorage: deepLinkData"] + G --> H["dispatch deepLinkReceived"] + G --> I["goto /scan-qr"] + + S["splash +page onMount"] --> S1["800ms + 400ms intro"] + S1 --> S2["poll globalState, up to 5s"] + S2 --> S3{"pendingDeepLink set?"} + S3 -- "yes" --> F + S3 -- "no" --> S4["authenticate() on splash"] + S4 -- "ok" --> P["continueAfterSuccessfulAuth"] + S4 -- "fail" --> F + + F --> L["/login onMount"] + L --> L1{"biometricAttemptedOnSplash?"} + L1 -- "no" --> L2["authenticate() AGAIN here"] + L1 -- "yes" --> L3["PIN pad only"] + L2 -- "ok" --> P + L3 -- "pin ok" --> P + + P --> P1{"pendingDeepLink?"} + P1 -- "yes" --> P2["copy to deepLinkData"] --> I + P1 -- "no" --> P3["goto /main"] + + I --> M["scanLogic onMount"] + M --> M1{"deepLinkData or pendingDeepLink?"} + M1 -- "yes" --> M2["open consent drawer"] + M1 -- "no" --> M3["startScan camera"] +``` + +### 2.3 How it worked, and where it broke + +On the **warm path** it worked fine. App already open and authenticated: the +handler saw an authenticated route, wrote `deepLinkData`, dispatched the event, +and `/scan-qr` opened the drawer. That path was never broken and is essentially +unchanged today. + +The **cold path** was where it failed, and the failure was a genuine race +(P1). Two independent `onMount` routines: + +- The layout imports the deep-link plugin asynchronously, then discovers the URL. +- The splash sleeps 1.2s, polls for `globalState`, then prompts biometrics. + +The splash's guard against the collision was to check `pendingDeepLink` and +divert to `/login`, deferring to `/login` as the single authenticator. **That +guard depends on the layout winning the race.** With fast biometrics — a user +whose finger is already on the sensor — the ordering inverted: + +``` +splash: reads pendingDeepLink -> empty (layout still importing) +splash: authenticate() -> success in ~200ms +splash: continueAfterSuccessfulAuth -> no pendingDeepLink -> goto /main +layout: URL finally arrives, writes pendingDeepLink, goto /login +(app) guard / login: user is already authenticated -> /main +result: payload parked forever, consent screen never appears +``` + +That is the original bug. The payload is written *after* the only code that +would have read it. + +### 2.4 What the original did NOT have + +Worth stating plainly, because it explains why the branch grew so long: + +- **No dedupe of any kind.** P2's double delivery was handled accidentally: the + second delivery overwrote `deepLinkData` with an identical payload, and + `/scan-qr` was idempotent about opening an already-open drawer. +- **No durable storage.** Nothing survived the P3 webview teardown. Coming back + from the browser showed a bare scanner instead of a confirmation card. +- **No concept of "this login is finished".** +- **No suppression window**, so the 30s window did not exist, and declining + recorded nothing. A declined login could always be retried immediately. + +That last point matters: the retry-after-decline bug was **introduced by this +branch**, not fixed by it. See §4.8. + +--- + +## 3. Architecture AFTER + +### 3.1 The central change: one module owns the protocol + +All deep-link state moved into `lib/utils/deepLinkFlow.ts` (~560 lines, +heavily commented, 56 unit tests). Call sites no longer touch `sessionStorage` +directly. The module owns which store each fact lives in, and that distinction +is the core of the design: + +| Store | Survives | Holds | +|---|---|---| +| `sessionStorage` | SPA navigation only. Wiped by webview teardown. | `pendingDeepLink`, `deepLinkData`, `walletAuthenticated`, `walletAuthInFlight`, `splashOwnsAuthPrompt`, `deepLinkLastUrl` | +| `localStorage` | Webview teardown and Activity restart | `deepLinkHandledUrl`, `deepLinkHandledAt`, `deepLinkCompleted`, `deepLinkAcknowledgedAt` | + +The rule: **only facts needed to survive the P3 restart are durable.** +Emphatically *not* `walletAuthenticated` — making that durable would let a deep +link arriving after a full app kill skip authentication entirely. Being +forgotten on relaunch is the property that makes it safe. + +### 3.2 Data flow now + +```mermaid +flowchart TD + A["Android intent w3ds://"] --> B["root layout: onOpenUrl / getCurrent
started FIRST, runs concurrently"] + B --> DUP{"isDuplicateDelivery(url)?"} + DUP -- "yes" --> X["ignore"] + DUP -- "no" --> C["parse payload"] + C --> D{"authenticated route
OR isWalletAuthenticated()?"} + + D -- "no" --> E["markDeepLinkPending(payload)"] + E --> F{"shouldRedirectToLogin()
promptInFlight? splashOwns?"} + F -- "someone owns the prompt" --> W["DEFER: owner will route"] + F -- "nobody owns it" --> G["goto /login (PIN only)"] + + D -- "yes" --> H["markDeepLinkReady + dispatch event"] + H --> I["goto /scan-qr"] + + S["splash: claimSplashAuthOwnership()
SYNCHRONOUS at component init"] --> S1["intro + globalState poll"] + S1 --> S2["runReturningUserAuth"] + S2 --> S3["await initialDeepLinkReady"] + S3 --> S4["beginAuthPrompt + authenticate()"] + S4 -- "ok" --> P["continueAfterSuccessfulAuth"] + S4 -- "fail" --> G + + P --> P0["markWalletAuthenticated() BEFORE any await"] + P0 --> P1["async chores fire-and-forget"] + P1 --> P2["promotePendingDeepLink()
then endAuthPrompt()
then goto — all synchronous"] + P2 --> I + + I --> M["scanLogic onMount"] + M --> M0["await initialDeepLinkReady (3s cap)"] + M0 --> M1{"payload present?"} + M1 -- "yes" --> M2["consent drawer"] + M1 -- "no" --> M4{"takeCompletedDeepLink()?"} + M4 -- "yes" --> M5["restore confirmation card"] + M4 -- "no" --> M6{"wasDeepLinkJustAcknowledged()?"} + M6 -- "yes" --> M7["goto /main, no camera"] + M6 -- "no" --> M8["startScan camera"] + + M2 --> AP["Approve"] + M2 --> DEC["Decline"] + AP --> AP1["markDeepLinkHandled() DURABLE
markDeepLinkCompleted()"] + AP1 --> AP2["openUrl -> browser -> Activity restart"] + AP2 --> M + DEC --> DEC1["markDeepLinkHandled(undefined, false)
session-scoped only"] + DEC1 --> DEC2["goto /main; retry works"] +``` + +### 3.3 The three invariants everything else follows from + +**I1 — Exactly one screen prompts for biometrics: the splash.** +`/login` is now the PIN fallback and never calls `authenticate()`. Two prompt +sites made the system dialog's backdrop non-deterministic and let two post-auth +routines race to consume one payload. + +**I2 — Ownership of the auth prompt is an explicit claim, never inferred.** +Two flags, both in `sessionStorage`: +- `walletAuthInFlight` — a native prompt is on screen right now + (`beginAuthPrompt` / `endAuthPrompt`) +- `splashOwnsAuthPrompt` — the splash is mounted and will prompt, or is + mid-handover (`claimSplashAuthOwnership` / `releaseSplashAuthOwnership`) + +`shouldRedirectToLogin()` returns false if either is set. The handler parks the +payload and lets the owner route. + +**I3 — The handover from auth to consent is synchronous.** +In `continueAfterSuccessfulAuth`, everything from `promotePendingDeepLink()` +through `endAuthPrompt()` to `goto()` runs with no `await` between. Any await in +that window is a gap where a re-delivered URL sees no owner and fires a +competing navigation. + +--- + +## 4. Every change, and why + +### 4.1 Wait for deep-link discovery before deciding (`ac69802c`, `6fb30e58`) + +**Problem:** the original bug (§2.3) — the splash decided "no deep link" before +the layout had finished discovering one. + +**Change:** the layout exposes `initialDeepLinkReady`, a promise resolved once +`getCurrent()` and listener registration have both completed. It is provided via +Svelte context. The splash awaits it before choosing a destination; `/scan-qr` +awaits it too, capped at 3s so a plugin failure cannot leave a blank page. + +**Why a promise rather than a flag:** the splash needs to *wait*, not poll. A +flag would reintroduce the same race at a different granularity. + +### 4.2 Single biometric prompt site (`504903d7`) + +**Problem:** `/login` and the splash each prompted from their own `onMount`. +Whichever won decided whether the dialog appeared over the purple splash or a +half-painted PIN pad. Worse, both could run `continueAfterSuccessfulAuth`, and +two post-auth routines consuming one payload is how it got dropped. + +**Change:** `/login` no longer calls `authenticate()`. The splash no longer +diverts a deep-link launch to `/login`. The routing decision was extracted into +`shouldRedirectToLogin()` so it is unit-testable. Deleted the now-dead +`biometricAttemptedOnSplash` handshake. + +**Trade-off, stated honestly:** a user who cancels biometrics gets the PIN pad +with no way to retry biometrics without relaunching. That was true before for +deep-link launches; it is now true for all launches. + +### 4.3 Ownership as an explicit claim (`b9eb573b`, `c8cc4487`) + +**Problem:** `shouldRedirectToLogin` originally inferred "the splash owns the +prompt" from `currentPath === "/"`. Unsound in both directions. After the +handover released the bracket, a re-delivered URL still saw `"/"` until the +`goto` landed, so the handler deferred to an owner that no longer existed and +the payload was parked forever. + +**Change (`b9eb573b`):** replaced the path inference with the explicit +`splashOwnsAuthPrompt` claim, and removed the path parameter entirely. + +**That was not enough (`c8cc4487`).** The reported symptom after `b9eb573b` was +that the PIN pad sometimes appeared *instead of* biometrics, which was +diagnostic: the claim ran ~1.2s after mount, after the intro and the globalState +poll, but the deep link is delivered from the layout's `onMount` inside that +window. The handler saw no owner, did `goto("/login")`, and unmounted the splash +before it could prompt. + +Fixed by claiming **synchronously at component init**, before any await, with +release on every non-authenticating exit plus `onDestroy`. +`runReturningUserAuth()` was extracted so there is a single release point. + +**Principle:** a claim that is established after an await is not a claim, it is +a race with extra steps. + +### 4.4 Stale continuation guard (`d95c8398`, `0ccaae0c`, `a0b37b0f`) + +**Problem:** unmounting a Svelte component does not cancel an `onMount` parked +on an await. The splash's continuation would resume long after the user had +left and call `goto()`, tearing down an open consent drawer. + +**Change:** `shouldAbortStaleContinuation(destroyed, authenticatedAtStart)`. + +**The subtlety:** the first version tested `isWalletAuthenticated()` alone. That +broke `/login`, because arriving there already-authenticated (exactly what a +deep-link flow does) made a freshly mounted screen classify itself as stale, so +it returned before prompting. The question is "was this routine *superseded* +while it waited?", which is not "is the session authenticated?". Callers now +snapshot auth state at start and pass it back; only a *transition* counts. + +### 4.5 Dedupe with a bounded window (`78c9ce82`, `a380cb09`, `60cc2941`) + +**Problem:** P2 — Android delivers cold-start URLs through both `getCurrent()` +and `onOpenUrl`. + +**Change:** `isDuplicateDelivery(url)` compares against the last-seen URL. + +**Why bounded (`a380cb09`):** the first version was a permanent blacklist, which +collided with P4. Since platforms reuse one `session` per offer, a user retrying +a pending login presents an identical URL, and it was silently swallowed — the +approval screen simply never appeared again. Hence `REPLAY_WINDOW_MS = 30_000`: +long enough to cover the Activity restart, short enough that the same link +later reads as the new request it is. + +### 4.6 Surviving the Activity restart (`c9a7af2a`, `c3c80b7f`, `687807e0`) + +**Problem:** P3. Approving calls `openUrl`; Android reloads the backgrounded +webview and wipes `sessionStorage`; the plugin replays the original intent into +a fresh webview. Two symptoms: the finished login was re-offered, and the +confirmation card was gone. + +**Change:** dedupe markers moved to `localStorage`; `markDeepLinkCompleted()` +stores platform, hostname and redirect durably so the rebuilt webview can +reconstruct the confirmation card (`takeCompletedDeepLink()`). + +The hostname was added in `687807e0` because the app icon is resolved from it, +so restoring the name alone rendered the card with a blank logo. + +### 4.7 Acknowledgement, so Ok does not open the camera (`6feacb49`) + +**Problem:** tapping Ok before the Activity restart landed left no trace. The +rebuilt webview found no payload and started the camera — a scanner the user +never asked for. + +**Change:** an `acknowledged` flag on `takeCompletedDeepLink()`, a durable +`ACKNOWLEDGED_KEY`, and `wasDeepLinkJustAcknowledged()`. `/scan-qr` returns to +`/main` instead of scanning. `clearDeepLinkAcknowledged()` is called from the root +layout's `onNavigate` when the user deliberately taps Scan. A webview rebuild is +a fresh page load and never fires that hook, so clearing there cannot mask the +case the marker guards. + +**Load-bearing detail:** the acknowledgement is written *before* the single-use +read. Mutation testing proved that ordering matters — swapping them lets the +read consume the record before the flag is stamped. + +### 4.8 Decline must not block a retry (`70c9bdb9`) + +**Problem, and it was mine.** Declining recorded the URL as handled *durably*, +so retrying the same link within 30s was dropped as a duplicate and the user +landed on `/main` with no consent screen — the original symptom, different +cause. In the original code decline recorded nothing at all, so retry always +worked (§2.4). + +**Root cause:** I applied approve-shaped reasoning to decline without checking +the premise. The durable marker exists solely to survive the Activity restart +that `openUrl` causes. **Decline never calls `openUrl`**, so no restart is +coming and there is nothing to survive. The marker outlived the decision. + +**Change:** `markDeepLinkHandled(urlString?, durable = true)`. Decline and the +in-app QR-scan POST path pass `false` — session-scoped only, which still +collapses the P2 double delivery within the current webview. Approve stays +durable. + +### 4.9 Refresh the window on Ok (`d45489d4`) + +**Problem:** a duplicate pending-login prompt after a slow browser round-trip. + +**Investigation:** `HANDLED_AT` is stamped at approval — before `openUrl`, +before time spent on the platform, before the Activity restart. A slow +round-trip exhausted the 30s window, so the replay was no longer recognised as +one. A probe against the real module confirmed suppression works *within* the +window, which is what pointed at expiry. + +**Change:** tapping Ok refreshes `HANDLED_AT`, guarded so it fires only when +`acknowledged=true` and a handled URL already exists. + +### 4.10 Startup latency (`12983713`) + +**Problem:** user-reported slowness before biometrics. + +**Finding:** auditing the whole diff against `b29340c5` showed the 800ms/400ms +intro and the polling loops were all pre-existing. What I had added was +`await initialDeepLinkReady` at the end of a serial chain: `checkStatus()` → +`GlobalState.create()` → `import(deep-link)` → `onOpenUrl`/`getCurrent()`. + +**Change:** deep-link discovery starts first and runs concurrently. +`GlobalState.create()` no longer waits on `checkStatus()` — verified that +`runtime.biometry` is written but never read anywhere. + +--- + +## 5. Comparison + +| Concern | Before | After | +|---|---|---| +| Deep-link state | 3 raw keys, inline at call sites | `deepLinkFlow.ts`, 56 tests | +| Biometric prompt sites | 2 (splash + `/login`), racing | 1 (splash) | +| Prompt ownership | Inferred from pathname | Explicit claim, sync at init | +| Cold-start ordering | Unsynchronised race | `initialDeepLinkReady` promise | +| Double delivery (P2) | Accidentally idempotent | Explicit, 30s bounded | +| Webview teardown (P3) | Not handled; card lost | Durable markers + restore | +| Retry same URL (P4) | Worked (nothing recorded) | Works (window + non-durable decline) | +| Post-`openUrl` return | Bare scanner | Confirmation card, or `/main` if acked | + +--- + +## 6. Honest limitations + +**Test coverage does not reach the call sites.** vitest here is node-only and +mounts no Svelte components. The 56 tests pin the *semantics* of +`deepLinkFlow.ts` — mutating it kills tests reliably. But flipping the +production decline call in `+page.svelte` back to `markDeepLinkHandled()` +**passes the entire suite**. That one line is verified by reading it, not by a +test. The same applies to every other call site in `.svelte` files. + +**Device testing is the only real proof.** `pnpm build:apk`. The races here are +between native plugin delivery and Svelte lifecycle, and neither exists in node. + +**The 30s window is a heuristic.** It is not derived from a measured +distribution of Activity restart latency. §4.9 exists because it was too short +for a slow round-trip. If the duplicate-prompt symptom returns, the window is +the first suspect, and the right fix is probably to stop relying on wall-clock +time and key the marker to something restart-scoped instead. + +**Known-unfixed, deliberately left alone:** `(auth)/+layout.svelte` references +`bg-background` and `text-foreground-muted`, neither defined in the `@theme` +block in `app.css` (verified: 0 rules in the compiled output), so the loading +placeholder is transparent. Also the eVault 429 recovery-screen error. + +**This branch is 27 commits for one bug.** Most of the later ones fix +regressions introduced by earlier ones. The pattern in the failures was +consistent: asserting a timing or causal relationship without verifying it — +inferring ownership from a pathname, claiming ownership after an await, +assuming decline needed the same durability as approve. Checking `b29340c5` +first would have caught several of them immediately, since in most cases the +original code simply did not do the thing I was "preserving". + +--- + +## 7. Reference + +**Diagnostics in logcat:** +- `Deep link routing:` — prints `authPromptInFlight` and `splashOwnsAuth` +- `Deferring navigation: the auth prompt owner will route` +- `Duplicate deep link delivery ignored:` +- `Restoring post-login confirmation after app restart` +- `Deep link already acknowledged, returning to main` + +**Key files:** +- `src/lib/utils/deepLinkFlow.ts` — protocol, storage, all decisions +- `src/lib/utils/deepLinkFlow.spec.ts` — 56 tests +- `src/routes/+layout.svelte` — delivery, dedupe, routing +- `src/routes/+page.svelte` — splash, the only biometric prompt +- `src/lib/utils/postLogin.ts` — the synchronous handover +- `src/routes/(app)/scan-qr/scanLogic.ts` — consent, approve/decline, restore