From 932d29143488f2b29350ce175a5329947aaedd43 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 09:05:34 +0530 Subject: [PATCH 01/24] fix(eid-wallet): show the consent screen when a deep link races cold-start auth Opening a w3ds:// login link on a cold start could drop the user on /main with the Approve/Decline screen never shown. It reproduced when biometric authentication succeeded quickly. Showing the consent screen needs two independent things to finish, in an order nobody controls: the URL arriving (the layout imports the deep-link plugin asynchronously) and the user authenticating. The layout decided which had happened by asking whether window.location.pathname was an authenticated route. On a cold start that path is "/" for the splash no matter how the race went, so a user who had ALREADY authenticated was still classified as logged out. The payload was parked for a screen that had finished running, and nothing ever collected it. Make it a rendezvous where whoever finishes LAST does the routing, and record authentication explicitly instead of inferring it from the URL: - the layout parks the payload if the user is not authenticated yet, and routes to /scan-qr if they are - every authentication path funnels through continueAfterSuccessfulAuth, which records the fact before any await, then collects a parked payload and routes to /scan-qr rather than /main Either ordering now reaches the consent screen, because both sides test the same explicitly-recorded fact. Also here, because they follow from the above: - the splash no longer diverts a deep-link launch to /login. It is where biometrics are prompted, so diverting downgraded a returning user to the PIN pad for the flow most likely to be used in a hurry. - the splash's async onMount gets liveness checks. Unmounting does not cancel a continuation parked on an await, so it could wake after the consent drawer opened and navigate away from it. - logout clears the authenticated flag. goto("/") is an SPA navigation and leaves sessionStorage intact, so without this a later deep link would skip the authentication gate entirely. The three near-identical 105-line checkAuth blocks in the layout (auth, sign, reveal) collapse into one routeDeepLink function, which is most of the 376 deleted lines. --- .../src/lib/utils/deepLinkFlow.spec.ts | 174 ++++++++ .../eid-wallet/src/lib/utils/deepLinkFlow.ts | 114 +++++ .../eid-wallet/src/lib/utils/postLogin.ts | 29 +- .../src/routes/(app)/settings/+page.svelte | 6 + .../eid-wallet/src/routes/+layout.svelte | 402 +++--------------- .../eid-wallet/src/routes/+page.svelte | 33 +- 6 files changed, 382 insertions(+), 376 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..dce123903 --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + clearDeepLinkFlow, + isWalletAuthenticated, + markDeepLinkPending, + markDeepLinkReady, + markWalletAuthenticated, + peekDeepLinkPayload, + promotePendingDeepLink, + resetAuthSession, +} from "./deepLinkFlow"; + +/** Minimal sessionStorage stand-in; the module is deliberately storage-backed. */ +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, String(value)); + } +} + +beforeEach(() => { + vi.stubGlobal("sessionStorage", new MemoryStorage()); +}); + +const PAYLOAD = { + type: "auth", + session: "21fcc8a5", + platform: "pictique", + redirect: "https://pictique.example/api/auth", +}; + +/** + * The layout's routing decision, mirrored from routeDeepLink() in + * routes/+layout.svelte. Returns where the layout sends the user, or null when + * it parks the payload and routes nothing. + */ +function layoutRouteDeepLink(): "/scan-qr" | null { + if (!isWalletAuthenticated()) { + markDeepLinkPending(PAYLOAD); + return null; + } + markDeepLinkReady(PAYLOAD); + return "/scan-qr"; +} + +/** + * The tail of continueAfterSuccessfulAuth(), which every authentication path + * (biometric on the splash, PIN on /login) funnels through. + */ +function completeAuthentication(): "/scan-qr" | "/main" { + markWalletAuthenticated(); + return promotePendingDeepLink() ? "/scan-qr" : "/main"; +} + +/** What /scan-qr finds on mount: a payload to consent to, or nothing. */ +function scanQrSeesPayload(): boolean { + return peekDeepLinkPayload() !== null; +} + +describe("deep-link login rendezvous", () => { + /** + * THE ORIGINAL BUG. Biometrics succeed before the deep-link plugin has + * finished loading, so authentication completes first and the URL lands + * afterwards. The old code inferred auth from window.location.pathname, + * which is "/" on the splash either way, so this ordering was misread as + * "logged out": the payload was parked for a screen that had already + * finished and the user was dropped on /main. + */ + it("routes to consent when authentication WINS the race", () => { + const authDestination = completeAuthentication(); + expect(authDestination).toBe("/main"); + + const layoutDestination = layoutRouteDeepLink(); + + expect(layoutDestination).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + /** + * The slow-authentication ordering: the URL arrives while the user is + * still on the sensor. The layout parks it and routes nothing, then the + * authentication path collects it. + */ + it("routes to consent when the deep link WINS the race", () => { + const layoutDestination = layoutRouteDeepLink(); + expect(layoutDestination).toBeNull(); + + const authDestination = completeAuthentication(); + + expect(authDestination).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + it("sends a plain launch to /main, with no payload to consent to", () => { + expect(completeAuthentication()).toBe("/main"); + expect(scanQrSeesPayload()).toBe(false); + }); + + it("routes an already-authenticated user straight to consent", () => { + completeAuthentication(); + expect(layoutRouteDeepLink()).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + it("keeps the payload readable until the consent screen clears it", () => { + layoutRouteDeepLink(); + completeAuthentication(); + expect(scanQrSeesPayload()).toBe(true); + + clearDeepLinkFlow(); + expect(scanQrSeesPayload()).toBe(false); + }); + + it("promotes only once, so a second login does not resurrect it", () => { + layoutRouteDeepLink(); + expect(completeAuthentication()).toBe("/scan-qr"); + clearDeepLinkFlow(); + + expect(completeAuthentication()).toBe("/main"); + }); + + /** + * Retrying a login the user declined must work. Platforms mint one + * `session` per offer, so the retry URL is byte-identical; nothing here + * may treat a repeat as permanently spent. + */ + it("lets the same URL be presented again after it was dismissed", () => { + layoutRouteDeepLink(); + completeAuthentication(); + clearDeepLinkFlow(); + + expect(layoutRouteDeepLink()).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + /** + * Logout does an SPA navigation to "/", which leaves sessionStorage + * intact. Without resetAuthSession() the session would keep claiming the + * user is authenticated and the next deep link would skip the gate. + */ + it("forgets authentication on logout so the next link re-prompts", () => { + completeAuthentication(); + expect(isWalletAuthenticated()).toBe(true); + + resetAuthSession(); + + expect(isWalletAuthenticated()).toBe(false); + expect(layoutRouteDeepLink()).toBeNull(); + }); + + it("survives storage being unavailable without throwing", () => { + vi.stubGlobal("sessionStorage", undefined); + + expect(() => layoutRouteDeepLink()).not.toThrow(); + expect(() => completeAuthentication()).not.toThrow(); + expect(peekDeepLinkPayload()).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..d8c5ca929 --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts @@ -0,0 +1,114 @@ +/** + * Deep-link login: the rendezvous between URL delivery and authentication. + * + * A third-party site hands the wallet a `w3ds://auth?session=...` URL. Showing + * the Approve/Decline consent screen for it requires TWO independent things to + * finish, in an order nobody controls: + * + * 1. The URL arriving. The root layout imports the deep-link plugin + * asynchronously, then asks it for the launch URL. + * 2. The user authenticating. On a cold start the splash prompts for + * biometrics, which can succeed in ~200ms or take seconds. + * + * Whoever finishes LAST owns the routing. That is the whole design: + * + * - URL arrives while unauthenticated -> park it, route nothing. + * - Authentication completes -> check for a parked URL and route to it. + * - URL arrives while already authenticated -> route to it immediately. + * + * Both sides check the same two facts, so neither can act on a half-finished + * picture. The bug this replaces came from the layout inferring "is the user + * authenticated?" from `window.location.pathname` at the instant of delivery: + * on a cold start the path is "/" (the splash) no matter how the race went, so + * a user who had ALREADY authenticated was still classified as logged out. The + * payload was parked for a screen that had finished running, and the user + * landed on /main with the consent screen never shown. + * + * Authentication state is therefore recorded EXPLICITLY, by the code that + * performs the authentication, and never derived from the URL. + */ + +const PENDING_KEY = "pendingDeepLink"; +const DATA_KEY = "deepLinkData"; +const AUTHED_KEY = "walletAuthenticated"; + +function store(): Storage | null { + try { + return typeof sessionStorage === "undefined" ? null : sessionStorage; + } catch { + // Private mode / storage disabled: degrade to "nothing in flight" + // rather than throwing inside a deep-link callback. + return null; + } +} + +/** + * Record that the user is through the authentication gate. + * + * Deliberately sessionStorage, NOT localStorage. Being forgotten when the app + * is killed is exactly the property that makes this safe: a deep link arriving + * after a cold start must trigger a real authentication, not inherit one from + * a previous run. + */ +export function markWalletAuthenticated(): void { + store()?.setItem(AUTHED_KEY, "true"); +} + +export function isWalletAuthenticated(): boolean { + return store()?.getItem(AUTHED_KEY) === "true"; +} + +/** + * Park a payload that arrived before the user finished authenticating. + * Whoever completes authentication collects it. + */ +export function markDeepLinkPending(data: unknown): void { + store()?.setItem(PENDING_KEY, JSON.stringify(data)); +} + +/** Hand a payload directly to /scan-qr: the user is already authenticated. */ +export function markDeepLinkReady(data: unknown): void { + store()?.setItem(DATA_KEY, JSON.stringify(data)); +} + +/** + * Promote a parked payload to a ready one. Called at the end of every + * authentication path (biometric on the splash, PIN on /login). + * + * Returns true if there was something to promote, which is the caller's signal + * to route to /scan-qr instead of /main. + */ +export function promotePendingDeepLink(): boolean { + const s = store(); + const pending = s?.getItem(PENDING_KEY); + if (!pending) return false; + s?.setItem(DATA_KEY, pending); + s?.removeItem(PENDING_KEY); + return true; +} + +/** The payload /scan-qr should render, from either delivery path. */ +export function peekDeepLinkPayload(): string | null { + const s = store(); + return s?.getItem(DATA_KEY) ?? s?.getItem(PENDING_KEY) ?? null; +} + +/** Clear the payload once the consent screen has been shown. */ +export function clearDeepLinkFlow(): void { + const s = store(); + s?.removeItem(PENDING_KEY); + s?.removeItem(DATA_KEY); +} + +/** + * Wipe the session on logout. + * + * `walletAuthenticated` MUST be cleared here. Logout resets global state and + * does an SPA navigation to "/", which leaves sessionStorage intact, so + * without this the next deep link would route itself straight to the consent + * screen on the strength of a login that has already ended. + */ +export function resetAuthSession(): void { + clearDeepLinkFlow(); + store()?.removeItem(AUTHED_KEY); +} diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index d90085955..3931b4801 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -1,5 +1,9 @@ import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; +import { + markWalletAuthenticated, + promotePendingDeepLink, +} from "$lib/utils/deepLinkFlow"; /** * Shared post-authentication routine: fires the background eVault chores @@ -14,6 +18,13 @@ import type { GlobalState } from "$lib/global"; export async function continueAfterSuccessfulAuth( gs: GlobalState, ): Promise { + // This is the authentication HALF of the rendezvous (see deepLinkFlow.ts). + // + // Record the fact BEFORE any await. A deep link delivered while the chores + // below are in flight must be able to see that the user is already through + // the gate, so it routes itself to the consent screen instead of parking a + // payload that nobody is left to 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,18 +63,12 @@ export async function continueAfterSuccessfulAuth( console.error("Error reading vault during login:", error); } - const pendingDeepLink = sessionStorage.getItem("pendingDeepLink"); - if (pendingDeepLink) { - 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"); - } + // Collect a payload that arrived while the user was authenticating. If the + // deep link won the race it is already marked ready and this is a no-op; + // either way the destination below is correct. + if (promotePendingDeepLink()) { + await goto("/scan-qr"); + return; } await goto("/main"); diff --git a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte index 52621e2c0..d367c0480 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte @@ -10,6 +10,7 @@ import { import { clearAllNotifications } from "$lib/stores/notifications"; import { BottomSheet, ButtonAction } from "$lib/ui"; import { PinIcon, PrivacyIcon } from "$lib/ui/icons"; +import { resetAuthSession } from "$lib/utils/deepLinkFlow"; import { clearAllCachedPhotos } from "$lib/utils/photoCache"; import { isPermissionGranted } from "@choochmeque/tauri-plugin-notifications-api"; import { FaceIdIcon, Notification02Icon } from "@hugeicons/core-free-icons"; @@ -90,6 +91,11 @@ async function performLogout() { } const newGlobalState = await globalState.reset(); setGlobalState(newGlobalState); + // goto("/") is an SPA navigation, so sessionStorage survives it. Without + // this the session would keep claiming the user is authenticated, and a + // deep link arriving afterwards would route straight to the consent screen + // instead of prompting for authentication. + resetAuthSession(); goto("/"); } diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index fd1271a54..d3ddc8d5e 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -5,6 +5,11 @@ import "../app.css"; import { beforeNavigate, goto, onNavigate, preloadCode } from "$app/navigation"; import { page } from "$app/state"; import { GlobalState } from "$lib/global/state"; +import { + isWalletAuthenticated, + markDeepLinkPending, + markDeepLinkReady, +} from "$lib/utils/deepLinkFlow"; import { runtime } from "$lib/global/runtime.svelte"; import { swipedetect } from "$lib/utils"; @@ -188,23 +193,49 @@ onMount(async () => { console.error("Failed to initialize deep link listener:", error); } - // 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 - // top-level segments here. Any new (app)// folder must be - // added below or its deep-links will redirect to /login. - function isAuthenticatedRoute(pathname: string): boolean { - const appRouteSegments = [ - "main", - "scan-qr", - "settings", - "personal", - "notifications", - "social-bindings", - "ePassport", - ]; - const firstSegment = pathname.split("/")[1] ?? ""; - return appRouteSegments.includes(firstSegment); + /** + * Route a parsed deep-link payload. This is the layout's HALF of the + * rendezvous described in deepLinkFlow.ts. + * + * Two outcomes, decided by one explicitly-recorded fact: + * + * - Authenticated: hand the payload straight to the consent screen. + * - Not authenticated: PARK it and route nothing. The screen that + * completes authentication (splash after biometrics, or /login after + * PIN) collects it and routes. + * + * The previous version asked `isAuthenticatedRoute(window.location.pathname)` + * instead. That is unsound on a cold start: the path is "/" for the splash + * regardless of whether the user has authenticated, so a fast biometric + * success was still read as "logged out". The payload was parked for a + * screen that had already finished, and the consent screen never appeared. + * + * Note this no longer navigates to /login on the unauthenticated path. The + * splash is where biometrics are prompted, so steering away from it would + * downgrade a returning user to the PIN pad. The splash routes onward by + * itself in every exit path. + */ + function routeDeepLink(deepLinkData: Record) { + if (!isWalletAuthenticated()) { + console.log("Deep link parked: user has not authenticated yet"); + markDeepLinkPending(deepLinkData); + return; + } + + console.log("Deep link routed: user is already authenticated"); + markDeepLinkReady(deepLinkData); + + // The event covers an already-mounted /scan-qr; the stored payload + // covers the mount that the goto() below triggers. + window.dispatchEvent( + new CustomEvent("deepLinkReceived", { detail: deepLinkData }), + ); + + if (window.location.pathname !== "/scan-qr") { + goto("/scan-qr").catch((error) => { + console.error("Error navigating to scan-qr:", error); + }); + } } function handleDeepLink(urlString: string) { @@ -224,19 +255,6 @@ onMount(async () => { Object.fromEntries(params.entries()), ); - // Check if we're already on the scan-qr page - const currentPath = window.location.pathname; - const isOnScanPage = currentPath === "/scan-qr"; - const isOnAuthenticatedRoute = isAuthenticatedRoute(currentPath); - console.log( - "Current path:", - currentPath, - "Is on scan page:", - isOnScanPage, - "Is on authenticated route:", - isOnAuthenticatedRoute, - ); - // For w3ds:// URLs, we need to check the hostname instead of pathname // w3ds://auth becomes hostname: "auth", pathname: "" const action = url.hostname || path; @@ -267,113 +285,7 @@ onMount(async () => { redirect: redirect, }; - // Check if user is authenticated by checking if they're on an authenticated route - const checkAuth = async () => { - // First check if user is on an authenticated route - // If not, they need to login first regardless of vault existence - if (!isOnAuthenticatedRoute) { - console.log( - "User not on authenticated route, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - - try { - // Wait for globalState to be ready if it's not yet - if (!globalState) { - console.log( - "GlobalState not ready, waiting...", - ); - // Wait a bit and retry, or just redirect to login - let retries = 0; - const maxRetries = 10; - while (!globalState && retries < maxRetries) { - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - retries++; - } - - if (!globalState) { - console.log( - "GlobalState still not ready, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - } - - const vault = - await globalState.vaultController.vault; - if (vault) { - // User is authenticated, dispatch event and navigate to scan page - console.log( - "User authenticated, dispatching deep link event and navigating to scan-qr", - ); - - // Dispatch a custom event that the scan page can listen to - const deepLinkEvent = new CustomEvent( - "deepLinkReceived", - { - detail: deepLinkData, - }, - ); - window.dispatchEvent(deepLinkEvent); - - // Also store in sessionStorage as backup - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(deepLinkData), - ); - - goto("/scan-qr").catch((error) => { - console.error( - "Error navigating to scan-qr:", - error, - ); - }); - return; - } - } catch (error) { - console.log( - "User not authenticated, redirecting to login", - error, - ); - } - - // User not authenticated, store deep link data and redirect to login - console.log( - "User not authenticated, storing deep link data and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error("Error navigating to login:", error); - }); - }; - - checkAuth(); + routeDeepLink(deepLinkData); } else { console.log("Missing required auth parameters"); } @@ -401,113 +313,7 @@ onMount(async () => { redirect_uri: redirectUri, }; - // Check if user is authenticated by checking if they're on an authenticated route - const checkAuth = async () => { - // First check if user is on an authenticated route - // If not, they need to login first regardless of vault existence - if (!isOnAuthenticatedRoute) { - console.log( - "User not on authenticated route, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - - try { - // Wait for globalState to be ready if it's not yet - if (!globalState) { - console.log( - "GlobalState not ready, waiting...", - ); - // Wait a bit and retry, or just redirect to login - let retries = 0; - const maxRetries = 10; - while (!globalState && retries < maxRetries) { - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - retries++; - } - - if (!globalState) { - console.log( - "GlobalState still not ready, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - } - - const vault = - await globalState.vaultController.vault; - if (vault) { - // User is authenticated, dispatch event and navigate to scan page - console.log( - "User authenticated, dispatching deep link event and navigating to scan-qr", - ); - - // Dispatch a custom event that the scan page can listen to - const deepLinkEvent = new CustomEvent( - "deepLinkReceived", - { - detail: deepLinkData, - }, - ); - window.dispatchEvent(deepLinkEvent); - - // Also store in sessionStorage as backup - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(deepLinkData), - ); - - goto("/scan-qr").catch((error) => { - console.error( - "Error navigating to scan-qr:", - error, - ); - }); - return; - } - } catch (error) { - console.log( - "User not authenticated, redirecting to login", - error, - ); - } - - // User not authenticated, store deep link data and redirect to login - console.log( - "User not authenticated, storing deep link data and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error("Error navigating to login:", error); - }); - }; - - checkAuth(); + routeDeepLink(deepLinkData); } else { console.log("Missing required signing parameters"); } @@ -524,113 +330,7 @@ onMount(async () => { pollId: pollId, }; - // Check if user is authenticated by checking if they're on an authenticated route - const checkAuth = async () => { - // First check if user is on an authenticated route - // If not, they need to login first regardless of vault existence - if (!isOnAuthenticatedRoute) { - console.log( - "User not on authenticated route, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - - try { - // Wait for globalState to be ready if it's not yet - if (!globalState) { - console.log( - "GlobalState not ready, waiting...", - ); - // Wait a bit and retry, or just redirect to login - let retries = 0; - const maxRetries = 10; - while (!globalState && retries < maxRetries) { - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - retries++; - } - - if (!globalState) { - console.log( - "GlobalState still not ready, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - } - - const vault = - await globalState.vaultController.vault; - if (vault) { - // User is authenticated, dispatch event and navigate to scan page - console.log( - "User authenticated, dispatching deep link event and navigating to scan-qr for reveal", - ); - - // Dispatch a custom event that the scan page can listen to - const deepLinkEvent = new CustomEvent( - "deepLinkReceived", - { - detail: deepLinkData, - }, - ); - window.dispatchEvent(deepLinkEvent); - - // Also store in sessionStorage as backup - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(deepLinkData), - ); - - goto("/scan-qr").catch((error) => { - console.error( - "Error navigating to scan-qr:", - error, - ); - }); - return; - } - } catch (error) { - console.log( - "User not authenticated, redirecting to login", - error, - ); - } - - // User not authenticated, store deep link data and redirect to login - console.log( - "User not authenticated, storing reveal deep link data and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error("Error navigating to login:", error); - }); - }; - - checkAuth(); + routeDeepLink(deepLinkData); } else { console.log("Missing required reveal parameters"); } diff --git a/infrastructure/eid-wallet/src/routes/+page.svelte b/infrastructure/eid-wallet/src/routes/+page.svelte index 481057663..995d1fec4 100644 --- a/infrastructure/eid-wallet/src/routes/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/+page.svelte @@ -9,7 +9,7 @@ import { authenticate, checkStatus, } from "@tauri-apps/plugin-biometric"; -import { getContext, onMount } from "svelte"; +import { getContext, onDestroy, onMount } from "svelte"; const BIOMETRIC_ATTEMPTED_KEY = "biometricAttemptedOnSplash"; @@ -47,6 +47,17 @@ async function handleRestoreDigitalSelf() { await goto("/recover"); } +// Unmounting a Svelte component does NOT cancel an async onMount parked on an +// await: the continuation resumes later and calls goto() from a screen the user +// left long ago. This routine sleeps 1.2s and then polls for global state, so +// it can still be suspended while the user authenticates by PIN on /login and +// /scan-qr opens the consent drawer. Waking then would navigate away and take +// that drawer with it. +let destroyed = false; +onDestroy(() => { + destroyed = true; +}); + onMount(async () => { if (skipIntro) { // Backward nav from /onboarding — already at state C, nothing to do. @@ -69,6 +80,8 @@ onMount(async () => { retries++; } + if (destroyed) return; + let onboardingComplete = false; let userExists = false; if (globalState) { @@ -93,18 +106,11 @@ onMount(async () => { return; } - // 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. - if (sessionStorage.getItem("pendingDeepLink")) { - await goto("/login"); - return; - } + // NOTE: a pending deep link deliberately does NOT divert to /login. + // Biometrics are prompted here, so diverting would downgrade a + // returning user to the PIN pad for the one flow most likely to be + // used by someone in a hurry. continueAfterSuccessfulAuth collects the + // parked payload and routes to the consent screen itself. // 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 @@ -120,6 +126,7 @@ onMount(async () => { } catch (error) { console.error("Biometric availability check failed:", error); } + if (destroyed) return; if (biometricAvailable && globalState) { sessionStorage.setItem(BIOMETRIC_ATTEMPTED_KEY, "true"); From 9028542adb3eec531b2b3a7a0d19d71b519dcd0e Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 10:23:09 +0530 Subject: [PATCH 02/24] refactor(eid-wallet): move deep-link storage into lib/stores Splits the deep-link flow along the same seam the other stores use (see personalBinding: "the store is just state, actual writes live in lib/utils"). lib/stores/deepLink.ts owns the keys and the sessionStorage access; lib/utils/deepLinkFlow.ts keeps the routing decisions and is now storage-agnostic. Promotion moved into the store as a raw string copy. Doing it in the logic layer meant JSON.parse followed by re-stringify, which made that layer interpret a payload it has no business reading and would corrupt anything JSON does not round-trip exactly. No behaviour change. The four mutations still fail the suite: auth check always false (4 tests), promotion never collecting (2), logout not clearing (1), and recording auth as a no-op (4). --- .../eid-wallet/src/lib/stores/deepLink.ts | 84 +++++++++++++++++++ .../eid-wallet/src/lib/utils/deepLinkFlow.ts | 82 ++++++++---------- 2 files changed, 120 insertions(+), 46 deletions(-) create mode 100644 infrastructure/eid-wallet/src/lib/stores/deepLink.ts diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts new file mode 100644 index 000000000..e7b50ff5b --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -0,0 +1,84 @@ +/** + * Storage for the deep-link login flow. This module is just state; the + * routing decisions that use it live in lib/utils/deepLinkFlow.ts. + * + * Deliberately sessionStorage rather than a Svelte store or localStorage: + * + * - A Svelte store is in-memory, and this state has to survive the + * full-page navigations the wallet performs between the splash, /login + * and /scan-qr. An in-memory store would be empty on the other side. + * - localStorage would survive the app being killed, which is exactly wrong + * for `walletAuthenticated`: a deep link arriving after a cold start must + * trigger a real authentication, not inherit one from a previous run. + * Being forgotten on relaunch is the property that makes it safe. + * + * Every accessor degrades to "nothing stored" when storage is unavailable + * (private mode, storage disabled) rather than throwing, because these are + * called from deep-link callbacks where a throw is invisible to the user and + * strands the flow. + */ + +const PENDING_KEY = "pendingDeepLink"; +const DATA_KEY = "deepLinkData"; +const AUTHED_KEY = "walletAuthenticated"; + +function store(): Storage | null { + try { + return typeof sessionStorage === "undefined" ? null : sessionStorage; + } catch { + return null; + } +} + +/** Record that the user is through the authentication gate this session. */ +export function setAuthenticated(): void { + store()?.setItem(AUTHED_KEY, "true"); +} + +export function getAuthenticated(): boolean { + return store()?.getItem(AUTHED_KEY) === "true"; +} + +export function clearAuthenticated(): void { + store()?.removeItem(AUTHED_KEY); +} + +/** A payload that arrived before the user finished authenticating. */ +export function setPendingPayload(data: unknown): void { + store()?.setItem(PENDING_KEY, JSON.stringify(data)); +} + +export function getPendingPayload(): string | null { + return store()?.getItem(PENDING_KEY) ?? null; +} + +/** A payload the consent screen can render right now. */ +export function setReadyPayload(data: unknown): void { + store()?.setItem(DATA_KEY, JSON.stringify(data)); +} + +export function getReadyPayload(): string | null { + return store()?.getItem(DATA_KEY) ?? null; +} + +/** + * Move the parked payload to the ready slot verbatim. + * + * Deliberately a raw string copy: re-serialising would mean parsing a payload + * this layer has no business interpreting, and would corrupt anything JSON + * does not round-trip exactly. + */ +export function promotePayload(): boolean { + const s = store(); + const pending = s?.getItem(PENDING_KEY); + if (!pending) return false; + s?.setItem(DATA_KEY, pending); + s?.removeItem(PENDING_KEY); + return true; +} + +export function clearPayloads(): void { + const s = store(); + s?.removeItem(PENDING_KEY); + s?.removeItem(DATA_KEY); +} diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts index d8c5ca929..b45d71f3c 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts @@ -1,9 +1,9 @@ /** * Deep-link login: the rendezvous between URL delivery and authentication. * - * A third-party site hands the wallet a `w3ds://auth?session=...` URL. Showing - * the Approve/Decline consent screen for it requires TWO independent things to - * finish, in an order nobody controls: + * State lives in lib/stores/deepLink.ts; this module is the logic that uses + * it. Showing the Approve/Decline consent screen requires TWO independent + * things to finish, in an order nobody controls: * * 1. The URL arriving. The root layout imports the deep-link plugin * asynchronously, then asks it for the launch URL. @@ -19,43 +19,40 @@ * Both sides check the same two facts, so neither can act on a half-finished * picture. The bug this replaces came from the layout inferring "is the user * authenticated?" from `window.location.pathname` at the instant of delivery: - * on a cold start the path is "/" (the splash) no matter how the race went, so - * a user who had ALREADY authenticated was still classified as logged out. The - * payload was parked for a screen that had finished running, and the user - * landed on /main with the consent screen never shown. + * on a cold start the path is "/" for the splash regardless of how the race + * went, so a user who had ALREADY authenticated was still classified as + * logged out. The payload was parked for a screen that had finished running, + * and the user landed on /main with the consent screen never shown. * * Authentication state is therefore recorded EXPLICITLY, by the code that * performs the authentication, and never derived from the URL. */ -const PENDING_KEY = "pendingDeepLink"; -const DATA_KEY = "deepLinkData"; -const AUTHED_KEY = "walletAuthenticated"; - -function store(): Storage | null { - try { - return typeof sessionStorage === "undefined" ? null : sessionStorage; - } catch { - // Private mode / storage disabled: degrade to "nothing in flight" - // rather than throwing inside a deep-link callback. - return null; - } -} +import { + clearAuthenticated, + clearPayloads, + getAuthenticated, + getPendingPayload, + getReadyPayload, + promotePayload, + setAuthenticated, + setPendingPayload, + setReadyPayload, +} from "$lib/stores/deepLink"; /** * Record that the user is through the authentication gate. * - * Deliberately sessionStorage, NOT localStorage. Being forgotten when the app - * is killed is exactly the property that makes this safe: a deep link arriving - * after a cold start must trigger a real authentication, not inherit one from - * a previous run. + * Callers must do this BEFORE any await that precedes their navigation, so a + * deep link delivered mid-flight sees the user as authenticated and routes + * itself rather than parking a payload nobody is left to collect. */ export function markWalletAuthenticated(): void { - store()?.setItem(AUTHED_KEY, "true"); + setAuthenticated(); } export function isWalletAuthenticated(): boolean { - return store()?.getItem(AUTHED_KEY) === "true"; + return getAuthenticated(); } /** @@ -63,52 +60,45 @@ export function isWalletAuthenticated(): boolean { * Whoever completes authentication collects it. */ export function markDeepLinkPending(data: unknown): void { - store()?.setItem(PENDING_KEY, JSON.stringify(data)); + setPendingPayload(data); } /** Hand a payload directly to /scan-qr: the user is already authenticated. */ export function markDeepLinkReady(data: unknown): void { - store()?.setItem(DATA_KEY, JSON.stringify(data)); + setReadyPayload(data); } /** * Promote a parked payload to a ready one. Called at the end of every * authentication path (biometric on the splash, PIN on /login). * - * Returns true if there was something to promote, which is the caller's signal - * to route to /scan-qr instead of /main. + * Returns true if there was something to promote, which is the caller's + * signal to route to /scan-qr instead of /main. */ export function promotePendingDeepLink(): boolean { - const s = store(); - const pending = s?.getItem(PENDING_KEY); - if (!pending) return false; - s?.setItem(DATA_KEY, pending); - s?.removeItem(PENDING_KEY); - return true; + return promotePayload(); } /** The payload /scan-qr should render, from either delivery path. */ export function peekDeepLinkPayload(): string | null { - const s = store(); - return s?.getItem(DATA_KEY) ?? s?.getItem(PENDING_KEY) ?? null; + return getReadyPayload() ?? getPendingPayload(); } /** Clear the payload once the consent screen has been shown. */ export function clearDeepLinkFlow(): void { - const s = store(); - s?.removeItem(PENDING_KEY); - s?.removeItem(DATA_KEY); + clearPayloads(); } /** * Wipe the session on logout. * - * `walletAuthenticated` MUST be cleared here. Logout resets global state and + * The authenticated flag MUST be cleared here. Logout resets global state and * does an SPA navigation to "/", which leaves sessionStorage intact, so - * without this the next deep link would route itself straight to the consent - * screen on the strength of a login that has already ended. + * without this the session would keep claiming the user is authenticated and + * the next deep link would route itself straight to the consent screen on the + * strength of a login that has already ended. */ export function resetAuthSession(): void { - clearDeepLinkFlow(); - store()?.removeItem(AUTHED_KEY); + clearPayloads(); + clearAuthenticated(); } From 0ad2a8c4d5596b22bf2b6fe42dbbd0917e95e0ed Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 10:31:28 +0530 Subject: [PATCH 03/24] refactor(eid-wallet): collapse the deep-link payload to one slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were two slots, pendingDeepLink and deepLinkData, and a promote step that copied between them. The copy carried no information: the payload was byte-identical on both sides, and the only difference was a label meaning "the user may act on this now" — which is already answered by the authenticated flag that every consumer checks anyway. The duplication leaked outward. /scan-qr read one key, fell back to the other, then had to remember to clear both; the layout wrote whichever it guessed was right; /login read the pending key directly to decide whether to show its banner. Any of those forgetting a key was a silent bug. Now: store the payload, ask isWalletAuthenticated() for permission. The layout stores unconditionally and only routes when authenticated, and continueAfterSuccessfulAuth asks hasDeepLink() once it has recorded the authentication. All deep-link storage access now goes through lib/stores/deepLink.ts; no route touches sessionStorage for this flow directly. Five mutations fail the suite: auth check always false (4 tests), payload never stored (6), logout not clearing auth (1), recording auth as a no-op (4), and the consent screen's clear doing nothing (2). --- .../eid-wallet/src/lib/stores/deepLink.ts | 55 +++++---------- .../src/lib/utils/deepLinkFlow.spec.ts | 16 ++--- .../eid-wallet/src/lib/utils/deepLinkFlow.ts | 68 ++++++++----------- .../eid-wallet/src/lib/utils/postLogin.ts | 13 ++-- .../src/routes/(app)/scan-qr/scanLogic.ts | 19 +++--- .../src/routes/(auth)/login/+page.svelte | 4 +- .../eid-wallet/src/routes/+layout.svelte | 24 +++---- 7 files changed, 73 insertions(+), 126 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts index e7b50ff5b..bcc4b14f3 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -2,11 +2,17 @@ * Storage for the deep-link login flow. This module is just state; the * routing decisions that use it live in lib/utils/deepLinkFlow.ts. * + * ONE payload slot, not a pending/ready pair. A deep link is either present + * or it is not; whether it may be ACTED on is answered by the authenticated + * flag, which every consumer checks anyway. Two slots meant a copy step whose + * only job was to relabel a payload that had not changed, and readers that + * had to consult both and fall back. + * * Deliberately sessionStorage rather than a Svelte store or localStorage: * - * - A Svelte store is in-memory, and this state has to survive the - * full-page navigations the wallet performs between the splash, /login - * and /scan-qr. An in-memory store would be empty on the other side. + * - A Svelte store is in-memory, and this state has to survive the full-page + * navigations the wallet performs between the splash, /login and /scan-qr. + * An in-memory store would be empty on the other side. * - localStorage would survive the app being killed, which is exactly wrong * for `walletAuthenticated`: a deep link arriving after a cold start must * trigger a real authentication, not inherit one from a previous run. @@ -18,8 +24,7 @@ * strands the flow. */ -const PENDING_KEY = "pendingDeepLink"; -const DATA_KEY = "deepLinkData"; +const PAYLOAD_KEY = "deepLinkData"; const AUTHED_KEY = "walletAuthenticated"; function store(): Storage | null { @@ -43,42 +48,14 @@ export function clearAuthenticated(): void { store()?.removeItem(AUTHED_KEY); } -/** A payload that arrived before the user finished authenticating. */ -export function setPendingPayload(data: unknown): void { - store()?.setItem(PENDING_KEY, JSON.stringify(data)); -} - -export function getPendingPayload(): string | null { - return store()?.getItem(PENDING_KEY) ?? null; -} - -/** A payload the consent screen can render right now. */ -export function setReadyPayload(data: unknown): void { - store()?.setItem(DATA_KEY, JSON.stringify(data)); +export function setPayload(data: unknown): void { + store()?.setItem(PAYLOAD_KEY, JSON.stringify(data)); } -export function getReadyPayload(): string | null { - return store()?.getItem(DATA_KEY) ?? null; -} - -/** - * Move the parked payload to the ready slot verbatim. - * - * Deliberately a raw string copy: re-serialising would mean parsing a payload - * this layer has no business interpreting, and would corrupt anything JSON - * does not round-trip exactly. - */ -export function promotePayload(): boolean { - const s = store(); - const pending = s?.getItem(PENDING_KEY); - if (!pending) return false; - s?.setItem(DATA_KEY, pending); - s?.removeItem(PENDING_KEY); - return true; +export function getPayload(): string | null { + return store()?.getItem(PAYLOAD_KEY) ?? null; } -export function clearPayloads(): void { - const s = store(); - s?.removeItem(PENDING_KEY); - s?.removeItem(DATA_KEY); +export function clearPayload(): void { + store()?.removeItem(PAYLOAD_KEY); } diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts index dce123903..1ca41c270 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts @@ -2,13 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearDeepLinkFlow, + hasDeepLink, isWalletAuthenticated, - markDeepLinkPending, - markDeepLinkReady, markWalletAuthenticated, peekDeepLinkPayload, - promotePendingDeepLink, resetAuthSession, + storeDeepLink, } from "./deepLinkFlow"; /** Minimal sessionStorage stand-in; the module is deliberately storage-backed. */ @@ -51,11 +50,8 @@ const PAYLOAD = { * it parks the payload and routes nothing. */ function layoutRouteDeepLink(): "/scan-qr" | null { - if (!isWalletAuthenticated()) { - markDeepLinkPending(PAYLOAD); - return null; - } - markDeepLinkReady(PAYLOAD); + storeDeepLink(PAYLOAD); + if (!isWalletAuthenticated()) return null; return "/scan-qr"; } @@ -65,7 +61,7 @@ function layoutRouteDeepLink(): "/scan-qr" | null { */ function completeAuthentication(): "/scan-qr" | "/main" { markWalletAuthenticated(); - return promotePendingDeepLink() ? "/scan-qr" : "/main"; + return hasDeepLink() ? "/scan-qr" : "/main"; } /** What /scan-qr finds on mount: a payload to consent to, or nothing. */ @@ -127,7 +123,7 @@ describe("deep-link login rendezvous", () => { expect(scanQrSeesPayload()).toBe(false); }); - it("promotes only once, so a second login does not resurrect it", () => { + it("does not resurrect a payload the consent screen already consumed", () => { layoutRouteDeepLink(); expect(completeAuthentication()).toBe("/scan-qr"); clearDeepLinkFlow(); diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts index b45d71f3c..61cf7a0c9 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts @@ -10,18 +10,23 @@ * 2. The user authenticating. On a cold start the splash prompts for * biometrics, which can succeed in ~200ms or take seconds. * - * Whoever finishes LAST owns the routing. That is the whole design: + * Whoever finishes LAST owns the routing: * - * - URL arrives while unauthenticated -> park it, route nothing. - * - Authentication completes -> check for a parked URL and route to it. - * - URL arrives while already authenticated -> route to it immediately. + * - The URL arrives while unauthenticated -> store it, route nothing. The + * screen that completes authentication picks it up. + * - The URL arrives while authenticated -> route to the consent screen now. * - * Both sides check the same two facts, so neither can act on a half-finished - * picture. The bug this replaces came from the layout inferring "is the user + * There is ONE payload slot. Storing a deep link never implies permission to + * act on it: that is `isWalletAuthenticated()`, which both sides check. The + * earlier design had a pending slot and a ready slot, and "promoted" between + * them, but the copy carried no information — the payload was identical and + * every consumer had to read both slots anyway. + * + * The bug this replaces came from the layout inferring "is the user * authenticated?" from `window.location.pathname` at the instant of delivery: * on a cold start the path is "/" for the splash regardless of how the race * went, so a user who had ALREADY authenticated was still classified as - * logged out. The payload was parked for a screen that had finished running, + * logged out. The payload was stored for a screen that had finished running, * and the user landed on /main with the consent screen never shown. * * Authentication state is therefore recorded EXPLICITLY, by the code that @@ -30,14 +35,11 @@ import { clearAuthenticated, - clearPayloads, + clearPayload, getAuthenticated, - getPendingPayload, - getReadyPayload, - promotePayload, + getPayload, setAuthenticated, - setPendingPayload, - setReadyPayload, + setPayload, } from "$lib/stores/deepLink"; /** @@ -45,7 +47,7 @@ import { * * Callers must do this BEFORE any await that precedes their navigation, so a * deep link delivered mid-flight sees the user as authenticated and routes - * itself rather than parking a payload nobody is left to collect. + * itself rather than storing a payload nobody is left to collect. */ export function markWalletAuthenticated(): void { setAuthenticated(); @@ -55,38 +57,24 @@ export function isWalletAuthenticated(): boolean { return getAuthenticated(); } -/** - * Park a payload that arrived before the user finished authenticating. - * Whoever completes authentication collects it. - */ -export function markDeepLinkPending(data: unknown): void { - setPendingPayload(data); -} - -/** Hand a payload directly to /scan-qr: the user is already authenticated. */ -export function markDeepLinkReady(data: unknown): void { - setReadyPayload(data); +/** Store an incoming deep-link payload, whatever the authentication state. */ +export function storeDeepLink(data: unknown): void { + setPayload(data); } -/** - * Promote a parked payload to a ready one. Called at the end of every - * authentication path (biometric on the splash, PIN on /login). - * - * Returns true if there was something to promote, which is the caller's - * signal to route to /scan-qr instead of /main. - */ -export function promotePendingDeepLink(): boolean { - return promotePayload(); +/** The payload the consent screen should render, if any. */ +export function peekDeepLinkPayload(): string | null { + return getPayload(); } -/** The payload /scan-qr should render, from either delivery path. */ -export function peekDeepLinkPayload(): string | null { - return getReadyPayload() ?? getPendingPayload(); +/** Is there a deep link waiting to be consented to? */ +export function hasDeepLink(): boolean { + return getPayload() !== null; } -/** Clear the payload once the consent screen has been shown. */ +/** Clear the payload once the consent screen has shown it. */ export function clearDeepLinkFlow(): void { - clearPayloads(); + clearPayload(); } /** @@ -99,6 +87,6 @@ export function clearDeepLinkFlow(): void { * strength of a login that has already ended. */ export function resetAuthSession(): void { - clearPayloads(); + clearPayload(); clearAuthenticated(); } diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index 3931b4801..0e9fc85ee 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -1,9 +1,6 @@ import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; -import { - markWalletAuthenticated, - promotePendingDeepLink, -} from "$lib/utils/deepLinkFlow"; +import { hasDeepLink, markWalletAuthenticated } from "$lib/utils/deepLinkFlow"; /** * Shared post-authentication routine: fires the background eVault chores @@ -63,10 +60,10 @@ export async function continueAfterSuccessfulAuth( console.error("Error reading vault during login:", error); } - // Collect a payload that arrived while the user was authenticating. If the - // deep link won the race it is already marked ready and this is a no-op; - // either way the destination below is correct. - if (promotePendingDeepLink()) { + // A deep link may have arrived before or during authentication. Either + // way it is sitting in the one payload slot, and the user is now allowed + // to act on it. + if (hasDeepLink()) { await goto("/scan-qr"); return; } 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..9a4dbd2a6 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts @@ -1,3 +1,7 @@ +import { + clearDeepLinkFlow, + peekDeepLinkPayload, +} from "$lib/utils/deepLinkFlow"; import { Format, type PermissionState, @@ -428,10 +432,7 @@ export function createScanLogic({ // Close the auth drawer first codeScannedDrawerOpen.set(false); - let deepLinkData = sessionStorage.getItem("deepLinkData"); - if (!deepLinkData) { - deepLinkData = sessionStorage.getItem("pendingDeepLink"); - } + const deepLinkData = peekDeepLinkPayload(); if (deepLinkData) { try { @@ -946,7 +947,7 @@ export function createScanLogic({ } showSigningSuccess.set(true); - const deepLinkData = sessionStorage.getItem("deepLinkData"); + const deepLinkData = peekDeepLinkPayload(); if (deepLinkData) { try { const data = JSON.parse(deepLinkData) as DeepLinkData; @@ -1666,10 +1667,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 +1678,7 @@ export function createScanLogic({ } catch (error) { console.error("Error parsing deep link data:", error); } finally { - sessionStorage.removeItem("deepLinkData"); - sessionStorage.removeItem("pendingDeepLink"); + 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..502208740 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte @@ -4,6 +4,7 @@ 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 { hasDeepLink } from "$lib/utils/deepLinkFlow"; import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; import { type AuthOptions, @@ -100,8 +101,7 @@ onMount(async () => { } globalState = gs; - const pendingDeepLink = sessionStorage.getItem("pendingDeepLink"); - hasPendingDeepLink = !!pendingDeepLink; + hasPendingDeepLink = hasDeepLink(); // 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 diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index d3ddc8d5e..f288c32bd 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -5,11 +5,7 @@ import "../app.css"; import { beforeNavigate, goto, onNavigate, preloadCode } from "$app/navigation"; import { page } from "$app/state"; import { GlobalState } from "$lib/global/state"; -import { - isWalletAuthenticated, - markDeepLinkPending, - markDeepLinkReady, -} from "$lib/utils/deepLinkFlow"; +import { isWalletAuthenticated, storeDeepLink } from "$lib/utils/deepLinkFlow"; import { runtime } from "$lib/global/runtime.svelte"; import { swipedetect } from "$lib/utils"; @@ -153,10 +149,7 @@ onMount(async () => { console.log( "App not ready, storing deep link data for later", ); - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(customEvent.detail), - ); + storeDeepLink(customEvent.detail); return; } @@ -175,10 +168,7 @@ onMount(async () => { console.log( "Not on scan page, storing data and navigating", ); - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(customEvent.detail), - ); + storeDeepLink(customEvent.detail); goto("/scan-qr").catch((error) => { console.error("Error navigating to scan-qr:", error); }); @@ -216,14 +206,16 @@ onMount(async () => { * itself in every exit path. */ function routeDeepLink(deepLinkData: Record) { + // Store it either way: the payload is the same regardless of who + // ends up routing it. + storeDeepLink(deepLinkData); + if (!isWalletAuthenticated()) { - console.log("Deep link parked: user has not authenticated yet"); - markDeepLinkPending(deepLinkData); + console.log("Deep link stored: user has not authenticated yet"); return; } console.log("Deep link routed: user is already authenticated"); - markDeepLinkReady(deepLinkData); // The event covers an already-mounted /scan-qr; the stored payload // covers the mount that the goto() below triggers. From 2342963b0106770ee2a091552660685bb261c6e6 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 10:59:26 +0530 Subject: [PATCH 04/24] refactor(eid-wallet): drop the deep-link pass-through layer utils/deepLinkFlow.ts had seven exports, and six were one-line forwards to lib/stores/deepLink.ts: markWalletAuthenticated called setAuthenticated, storeDeepLink called setPayload, and so on. The only one doing anything was resetAuthSession, clearing two keys. A layer that renames its callees is a second vocabulary for the same concepts, not an abstraction. Merge it into the store, which now carries the rendezvous documentation alongside the state it describes. Names lose the prefixes that only existed to avoid collisions between the two layers: isWalletAuthenticated -> isAuthenticated, peekDeepLinkPayload -> peekDeepLink, clearDeepLinkFlow -> clearDeepLink. The spec moves next to the module it covers. The split was worth having when the logic layer held real decisions (shouldRedirectToLogin, ownership claims, replay windows). Those are gone, so the seam has nothing left on one side of it. Five mutations still fail the suite: auth check always false (4 tests), payload never stored (6), logout not clearing auth (1), recording auth as a no-op (4), and the consent screen's clear doing nothing (2). --- .../deepLink.spec.ts} | 28 +++--- .../eid-wallet/src/lib/stores/deepLink.ts | 81 ++++++++++++---- .../eid-wallet/src/lib/utils/deepLinkFlow.ts | 92 ------------------- .../eid-wallet/src/lib/utils/postLogin.ts | 6 +- .../src/routes/(app)/scan-qr/scanLogic.ts | 13 +-- .../src/routes/(app)/settings/+page.svelte | 2 +- .../src/routes/(auth)/login/+page.svelte | 2 +- .../eid-wallet/src/routes/+layout.svelte | 6 +- 8 files changed, 90 insertions(+), 140 deletions(-) rename infrastructure/eid-wallet/src/lib/{utils/deepLinkFlow.spec.ts => stores/deepLink.spec.ts} (91%) delete 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/stores/deepLink.spec.ts similarity index 91% rename from infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts rename to infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index 1ca41c270..b5c5f1616 100644 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -1,14 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { - clearDeepLinkFlow, + clearDeepLink, hasDeepLink, - isWalletAuthenticated, - markWalletAuthenticated, - peekDeepLinkPayload, + isAuthenticated, + markAuthenticated, + peekDeepLink, resetAuthSession, storeDeepLink, -} from "./deepLinkFlow"; +} from "./deepLink"; /** Minimal sessionStorage stand-in; the module is deliberately storage-backed. */ class MemoryStorage implements Storage { @@ -51,7 +51,7 @@ const PAYLOAD = { */ function layoutRouteDeepLink(): "/scan-qr" | null { storeDeepLink(PAYLOAD); - if (!isWalletAuthenticated()) return null; + if (!isAuthenticated()) return null; return "/scan-qr"; } @@ -60,13 +60,13 @@ function layoutRouteDeepLink(): "/scan-qr" | null { * (biometric on the splash, PIN on /login) funnels through. */ function completeAuthentication(): "/scan-qr" | "/main" { - markWalletAuthenticated(); + markAuthenticated(); return hasDeepLink() ? "/scan-qr" : "/main"; } /** What /scan-qr finds on mount: a payload to consent to, or nothing. */ function scanQrSeesPayload(): boolean { - return peekDeepLinkPayload() !== null; + return peekDeepLink() !== null; } describe("deep-link login rendezvous", () => { @@ -119,14 +119,14 @@ describe("deep-link login rendezvous", () => { completeAuthentication(); expect(scanQrSeesPayload()).toBe(true); - clearDeepLinkFlow(); + clearDeepLink(); expect(scanQrSeesPayload()).toBe(false); }); it("does not resurrect a payload the consent screen already consumed", () => { layoutRouteDeepLink(); expect(completeAuthentication()).toBe("/scan-qr"); - clearDeepLinkFlow(); + clearDeepLink(); expect(completeAuthentication()).toBe("/main"); }); @@ -139,7 +139,7 @@ describe("deep-link login rendezvous", () => { it("lets the same URL be presented again after it was dismissed", () => { layoutRouteDeepLink(); completeAuthentication(); - clearDeepLinkFlow(); + clearDeepLink(); expect(layoutRouteDeepLink()).toBe("/scan-qr"); expect(scanQrSeesPayload()).toBe(true); @@ -152,11 +152,11 @@ describe("deep-link login rendezvous", () => { */ it("forgets authentication on logout so the next link re-prompts", () => { completeAuthentication(); - expect(isWalletAuthenticated()).toBe(true); + expect(isAuthenticated()).toBe(true); resetAuthSession(); - expect(isWalletAuthenticated()).toBe(false); + expect(isAuthenticated()).toBe(false); expect(layoutRouteDeepLink()).toBeNull(); }); @@ -165,6 +165,6 @@ describe("deep-link login rendezvous", () => { expect(() => layoutRouteDeepLink()).not.toThrow(); expect(() => completeAuthentication()).not.toThrow(); - expect(peekDeepLinkPayload()).toBeNull(); + expect(peekDeepLink()).toBeNull(); }); }); diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts index bcc4b14f3..ca2b58eab 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -1,12 +1,32 @@ /** - * Storage for the deep-link login flow. This module is just state; the - * routing decisions that use it live in lib/utils/deepLinkFlow.ts. + * State for the deep-link login flow. * - * ONE payload slot, not a pending/ready pair. A deep link is either present - * or it is not; whether it may be ACTED on is answered by the authenticated - * flag, which every consumer checks anyway. Two slots meant a copy step whose - * only job was to relabel a payload that had not changed, and readers that - * had to consult both and fall back. + * Showing the Approve/Decline consent screen requires TWO independent things + * to finish, in an order nobody controls: + * + * 1. The URL arriving. The root layout imports the deep-link plugin + * asynchronously, then asks it for the launch URL. + * 2. The user authenticating. On a cold start the splash prompts for + * biometrics, which can succeed in ~200ms or take seconds. + * + * Whoever finishes LAST owns the routing: + * + * - The URL arrives while unauthenticated -> store it, route nothing. The + * screen that completes authentication picks it up. + * - The URL arrives while authenticated -> route to the consent screen now. + * + * There is ONE payload slot. Storing a deep link never implies permission to + * act on it: that is `isAuthenticated()`, which both sides check. + * + * The bug this replaces came from the layout inferring "is the user + * authenticated?" from `window.location.pathname` at the instant of delivery: + * on a cold start the path is "/" for the splash regardless of how the race + * went, so a user who had ALREADY authenticated was still classified as + * logged out. The payload was stored for a screen that had finished running, + * and the user landed on /main with the consent screen never shown. + * + * Authentication state is therefore recorded EXPLICITLY, by the code that + * performs the authentication, and never derived from the URL. * * Deliberately sessionStorage rather than a Svelte store or localStorage: * @@ -14,7 +34,7 @@ * navigations the wallet performs between the splash, /login and /scan-qr. * An in-memory store would be empty on the other side. * - localStorage would survive the app being killed, which is exactly wrong - * for `walletAuthenticated`: a deep link arriving after a cold start must + * for the authenticated flag: a deep link arriving after a cold start must * trigger a real authentication, not inherit one from a previous run. * Being forgotten on relaunch is the property that makes it safe. * @@ -35,27 +55,52 @@ function store(): Storage | null { } } -/** Record that the user is through the authentication gate this session. */ -export function setAuthenticated(): void { +/** + * Record that the user is through the authentication gate. + * + * Callers must do this BEFORE any await that precedes their navigation, so a + * deep link delivered mid-flight sees the user as authenticated and routes + * itself rather than storing a payload nobody is left to collect. + */ +export function markAuthenticated(): void { store()?.setItem(AUTHED_KEY, "true"); } -export function getAuthenticated(): boolean { +export function isAuthenticated(): boolean { return store()?.getItem(AUTHED_KEY) === "true"; } -export function clearAuthenticated(): void { - store()?.removeItem(AUTHED_KEY); -} - -export function setPayload(data: unknown): void { +/** Store an incoming deep-link payload, whatever the authentication state. */ +export function storeDeepLink(data: unknown): void { store()?.setItem(PAYLOAD_KEY, JSON.stringify(data)); } -export function getPayload(): string | null { +/** The payload the consent screen should render, if any. */ +export function peekDeepLink(): string | null { return store()?.getItem(PAYLOAD_KEY) ?? null; } -export function clearPayload(): void { +/** Is there a deep link waiting to be consented to? */ +export function hasDeepLink(): boolean { + return peekDeepLink() !== null; +} + +/** Clear the payload once the consent screen has shown it. */ +export function clearDeepLink(): void { store()?.removeItem(PAYLOAD_KEY); } + +/** + * Wipe the session on logout. + * + * The authenticated flag MUST be cleared here. Logout resets global state and + * does an SPA navigation to "/", which leaves sessionStorage intact, so + * without this the session would keep claiming the user is authenticated and + * the next deep link would route itself straight to the consent screen on the + * strength of a login that has already ended. + */ +export function resetAuthSession(): void { + const s = store(); + s?.removeItem(PAYLOAD_KEY); + s?.removeItem(AUTHED_KEY); +} diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts deleted file mode 100644 index 61cf7a0c9..000000000 --- a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Deep-link login: the rendezvous between URL delivery and authentication. - * - * State lives in lib/stores/deepLink.ts; this module is the logic that uses - * it. Showing the Approve/Decline consent screen requires TWO independent - * things to finish, in an order nobody controls: - * - * 1. The URL arriving. The root layout imports the deep-link plugin - * asynchronously, then asks it for the launch URL. - * 2. The user authenticating. On a cold start the splash prompts for - * biometrics, which can succeed in ~200ms or take seconds. - * - * Whoever finishes LAST owns the routing: - * - * - The URL arrives while unauthenticated -> store it, route nothing. The - * screen that completes authentication picks it up. - * - The URL arrives while authenticated -> route to the consent screen now. - * - * There is ONE payload slot. Storing a deep link never implies permission to - * act on it: that is `isWalletAuthenticated()`, which both sides check. The - * earlier design had a pending slot and a ready slot, and "promoted" between - * them, but the copy carried no information — the payload was identical and - * every consumer had to read both slots anyway. - * - * The bug this replaces came from the layout inferring "is the user - * authenticated?" from `window.location.pathname` at the instant of delivery: - * on a cold start the path is "/" for the splash regardless of how the race - * went, so a user who had ALREADY authenticated was still classified as - * logged out. The payload was stored for a screen that had finished running, - * and the user landed on /main with the consent screen never shown. - * - * Authentication state is therefore recorded EXPLICITLY, by the code that - * performs the authentication, and never derived from the URL. - */ - -import { - clearAuthenticated, - clearPayload, - getAuthenticated, - getPayload, - setAuthenticated, - setPayload, -} from "$lib/stores/deepLink"; - -/** - * Record that the user is through the authentication gate. - * - * Callers must do this BEFORE any await that precedes their navigation, so a - * deep link delivered mid-flight sees the user as authenticated and routes - * itself rather than storing a payload nobody is left to collect. - */ -export function markWalletAuthenticated(): void { - setAuthenticated(); -} - -export function isWalletAuthenticated(): boolean { - return getAuthenticated(); -} - -/** Store an incoming deep-link payload, whatever the authentication state. */ -export function storeDeepLink(data: unknown): void { - setPayload(data); -} - -/** The payload the consent screen should render, if any. */ -export function peekDeepLinkPayload(): string | null { - return getPayload(); -} - -/** Is there a deep link waiting to be consented to? */ -export function hasDeepLink(): boolean { - return getPayload() !== null; -} - -/** Clear the payload once the consent screen has shown it. */ -export function clearDeepLinkFlow(): void { - clearPayload(); -} - -/** - * Wipe the session on logout. - * - * The authenticated flag MUST be cleared here. Logout resets global state and - * does an SPA navigation to "/", which leaves sessionStorage intact, so - * without this the session would keep claiming the user is authenticated and - * the next deep link would route itself straight to the consent screen on the - * strength of a login that has already ended. - */ -export function resetAuthSession(): void { - clearPayload(); - clearAuthenticated(); -} diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index 0e9fc85ee..225b9b0cb 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -1,6 +1,6 @@ import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; -import { hasDeepLink, markWalletAuthenticated } from "$lib/utils/deepLinkFlow"; +import { hasDeepLink, markAuthenticated } from "$lib/stores/deepLink"; /** * Shared post-authentication routine: fires the background eVault chores @@ -15,13 +15,13 @@ import { hasDeepLink, markWalletAuthenticated } from "$lib/utils/deepLinkFlow"; export async function continueAfterSuccessfulAuth( gs: GlobalState, ): Promise { - // This is the authentication HALF of the rendezvous (see deepLinkFlow.ts). + // This is the authentication HALF of the rendezvous (see lib/stores/deepLink.ts). // // Record the fact BEFORE any await. A deep link delivered while the chores // below are in flight must be able to see that the user is already through // the gate, so it routes itself to the consent screen instead of parking a // payload that nobody is left to collect. - markWalletAuthenticated(); + markAuthenticated(); // 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. 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 9a4dbd2a6..ad9b007c7 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts @@ -1,7 +1,4 @@ -import { - clearDeepLinkFlow, - peekDeepLinkPayload, -} from "$lib/utils/deepLinkFlow"; +import { clearDeepLink, peekDeepLink } from "$lib/stores/deepLink"; import { Format, type PermissionState, @@ -432,7 +429,7 @@ export function createScanLogic({ // Close the auth drawer first codeScannedDrawerOpen.set(false); - const deepLinkData = peekDeepLinkPayload(); + const deepLinkData = peekDeepLink(); if (deepLinkData) { try { @@ -947,7 +944,7 @@ export function createScanLogic({ } showSigningSuccess.set(true); - const deepLinkData = peekDeepLinkPayload(); + const deepLinkData = peekDeepLink(); if (deepLinkData) { try { const data = JSON.parse(deepLinkData) as DeepLinkData; @@ -1667,7 +1664,7 @@ export function createScanLogic({ window.addEventListener("deepLinkAuth", authHandler); window.addEventListener("deepLinkSign", signHandler); - const deepLinkData = peekDeepLinkPayload(); + const deepLinkData = peekDeepLink(); if (deepLinkData) { console.log("Found deep link data:", deepLinkData); @@ -1678,7 +1675,7 @@ export function createScanLogic({ } catch (error) { console.error("Error parsing deep link data:", error); } finally { - clearDeepLinkFlow(); + clearDeepLink(); } } else { console.log("No deep link data found, starting normal scanning"); diff --git a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte index d367c0480..261cd31dc 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte @@ -3,6 +3,7 @@ import { goto } from "$app/navigation"; import { SettingsNavigationBtn } from "$lib/fragments"; import type { GlobalState } from "$lib/global"; import { runtime } from "$lib/global/runtime.svelte"; +import { resetAuthSession } from "$lib/stores/deepLink"; import { getCurrentLanguage, subscribe as subscribeLanguage, @@ -10,7 +11,6 @@ import { import { clearAllNotifications } from "$lib/stores/notifications"; import { BottomSheet, ButtonAction } from "$lib/ui"; import { PinIcon, PrivacyIcon } from "$lib/ui/icons"; -import { resetAuthSession } from "$lib/utils/deepLinkFlow"; import { clearAllCachedPhotos } from "$lib/utils/photoCache"; import { isPermissionGranted } from "@choochmeque/tauri-plugin-notifications-api"; import { FaceIdIcon, Notification02Icon } from "@hugeicons/core-free-icons"; diff --git a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte index 502208740..a5a4ce17e 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte @@ -2,9 +2,9 @@ import { goto } from "$app/navigation"; import { keyboardInset } from "$lib/actions/keyboardInset"; import type { GlobalState } from "$lib/global"; +import { hasDeepLink } from "$lib/stores/deepLink"; import { LoadingSheet, PinDots } from "$lib/ui"; import * as Button from "$lib/ui/Button"; -import { hasDeepLink } from "$lib/utils/deepLinkFlow"; import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; import { type AuthOptions, diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index f288c32bd..6ed963b6c 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -5,7 +5,7 @@ import "../app.css"; import { beforeNavigate, goto, onNavigate, preloadCode } from "$app/navigation"; import { page } from "$app/state"; import { GlobalState } from "$lib/global/state"; -import { isWalletAuthenticated, storeDeepLink } from "$lib/utils/deepLinkFlow"; +import { isAuthenticated, storeDeepLink } from "$lib/stores/deepLink"; import { runtime } from "$lib/global/runtime.svelte"; import { swipedetect } from "$lib/utils"; @@ -185,7 +185,7 @@ onMount(async () => { /** * Route a parsed deep-link payload. This is the layout's HALF of the - * rendezvous described in deepLinkFlow.ts. + * rendezvous described in lib/stores/deepLink.ts. * * Two outcomes, decided by one explicitly-recorded fact: * @@ -210,7 +210,7 @@ onMount(async () => { // ends up routing it. storeDeepLink(deepLinkData); - if (!isWalletAuthenticated()) { + if (!isAuthenticated()) { console.log("Deep link stored: user has not authenticated yet"); return; } From 220cd97dad64514137e603345bb862a8d36cd55e Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 11:04:29 +0530 Subject: [PATCH 05/24] docs(eid-wallet): move the deep-link architecture out of the source The rendezvous explanation, the storage-choice rationale and the history of the pathname-inference bug were living in a header comment on lib/stores/deepLink.ts, with fragments repeated in the layout and postLogin. Prose that describes a flow spanning four files does not belong to any one of them, and duplicating it guarantees the copies drift. Move it to docs/architecture/deepLink.md and leave each site a pointer. Comments that explain a local decision stay put: why markAuthenticated must precede any await, why resetAuthSession clears on logout. The doc also records what the code cannot say for itself: that the (app) guard checks enrolment rather than authentication, that PIN change and passphrase rotation are untraced, and that the Ok confirmation card after a deep-link login is still broken. --- .../eid-wallet/docs/architecture/deepLink.md | 165 ++++++++++++++++++ .../eid-wallet/src/lib/stores/deepLink.ts | 51 +----- .../eid-wallet/src/lib/utils/postLogin.ts | 14 +- .../eid-wallet/src/routes/+layout.svelte | 22 +-- 4 files changed, 175 insertions(+), 77 deletions(-) create mode 100644 infrastructure/eid-wallet/docs/architecture/deepLink.md diff --git a/infrastructure/eid-wallet/docs/architecture/deepLink.md b/infrastructure/eid-wallet/docs/architecture/deepLink.md new file mode 100644 index 000000000..90211342b --- /dev/null +++ b/infrastructure/eid-wallet/docs/architecture/deepLink.md @@ -0,0 +1,165 @@ +# Deep-link login + +How a `w3ds://` login request from a third-party site reaches the +Approve/Decline consent screen. + +## The problem + +A platform (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=&platform=&redirect= +``` + +Showing the consent screen for that URL requires two independent things to +finish, in an order nobody controls: + +1. **The URL arriving.** The root layout imports the deep-link plugin + asynchronously, then asks it for the launch URL. +2. **The user authenticating.** On a cold start the splash prompts for + biometrics, which can succeed in ~200ms or take several seconds. + +Neither reliably happens first. On a cold start with a fingerprint already on +the sensor, authentication wins. On a slower unlock, the URL wins. + +## The design: whoever finishes last routes + +Both sides check the same two explicitly-recorded facts, so neither can act on +a half-finished picture: + +- **The URL arrives while unauthenticated** — store it, route nothing. The + screen that completes authentication picks it up. +- **The URL arrives while already authenticated** — route to the consent screen + immediately. +- **Authentication completes** — if a payload is stored, go to `/scan-qr`; + otherwise `/main`. + +```mermaid +flowchart TD + A["Android intent w3ds://"] --> B["root +layout
onOpenUrl / getCurrent"] + B --> C["parse payload"] + C --> D["storeDeepLink(payload)"] + D --> E{"isAuthenticated()?"} + E -- "no" --> F["route nothing:
the auth path will collect it"] + E -- "yes" --> G["goto /scan-qr"] + + S["splash +page"] --> S1["intro, poll globalState"] + S1 --> S2["authenticate() biometric"] + S2 -- "ok" --> P["continueAfterSuccessfulAuth"] + S2 -- "cancel / unavailable" --> L["/login PIN pad"] + L -- "pin ok" --> P + + P --> P1["markAuthenticated()
BEFORE any await"] + P1 --> P2{"hasDeepLink()?"} + P2 -- "yes" --> G + P2 -- "no" --> M["goto /main"] + + G --> R["scanLogic: peekDeepLink()
open consent drawer"] + R --> R1["Approve -> POST + openUrl"] + R --> R2["Decline -> /main"] +``` + +## One payload slot + +Storing a deep link never implies permission to act on it. That is +`isAuthenticated()`, which every consumer checks anyway. + +An earlier version had two slots, `pendingDeepLink` and `deepLinkData`, and +"promoted" between them once the user authenticated. The copy carried no +information: the payload was byte-identical on both sides, and the only +difference was a label meaning "actionable now". The duplication leaked +outward — `/scan-qr` read one key and fell back to the other, then had to clear +both, and any reader forgetting a key was a silent bug. + +## Why authentication is recorded explicitly + +The original implementation asked +`isAuthenticatedRoute(window.location.pathname)` at the instant of delivery, as +a proxy for "has the user authenticated?". + +That is unsound on a cold start. The path is `/` (the splash) regardless of how +the race went, so a user who had **already** authenticated was still classified +as logged out. The payload was stored for a screen that had finished running, +nothing collected it, and the user landed on `/main` with the consent screen +never shown. That was the bug this design replaces. + +Authentication state is therefore written by the code that performs the +authentication, and never derived from the URL or the route. + +`markAuthenticated()` must be called **before any await** that precedes the +caller's navigation. A deep link delivered while post-login chores are in +flight has to see the user as authenticated, or it will store a payload nobody +is left to collect. + +## Storage choice + +`sessionStorage`, deliberately — not a Svelte store, not `localStorage`. + +- **A Svelte store is in-memory.** This state has to survive the full-page + navigations the wallet performs between the splash, `/login` and `/scan-qr`. + An in-memory store is empty on the other side. +- **`localStorage` would survive the app being killed**, which is exactly wrong + for the authenticated flag. A deep link arriving after a cold start must + trigger a real authentication rather than inheriting one from a previous run. + Being forgotten on relaunch is the property that makes it safe. + +Every accessor degrades to "nothing stored" when storage is unavailable +(private mode, storage disabled) rather than throwing, because these run inside +deep-link callbacks where a throw is invisible to the user and strands the flow. + +## Logout + +`resetAuthSession()` clears both keys. This is required, not defensive: logout +does `goto("/")`, an SPA navigation that leaves `sessionStorage` intact. Without +it the session would keep claiming the user is authenticated, and the next deep +link would route straight to the consent screen on the strength of a login that +had already ended. + +## The splash is the only biometric prompt + +`/login` is the PIN fallback. The splash prompts for biometrics and routes +onward itself; it does not divert a deep-link launch to `/login`, because doing +so would downgrade a returning user to the PIN pad for the flow most likely to +be used in a hurry. + +Two consequences worth knowing: + +- The splash's async `onMount` carries liveness checks (`destroyed`). Unmounting + a Svelte component does not cancel a continuation parked on an `await`, so it + could otherwise wake after the consent drawer opened and navigate away from it. +- `/login` still prompts biometrics when the splash did not, coordinated through + the `biometricAttemptedOnSplash` flag inherited from the original design. + +## What authentication actually means here + +Worth stating plainly, because the names are misleading. + +| Fact | Question it answers | Lifetime | +|---|---|---| +| `vaultController.vault` | Is an identity **enrolled** on this device? | Disk, survives reboot | +| `securityController.pinHash` | Is a PIN **configured**? | Disk | +| `walletAuthenticated` | Has the user authenticated **this session**? | sessionStorage | + +The `(app)` route guard checks the **vault**, i.e. enrolment, not +authentication. It stops a never-onboarded or logged-out user; it does not stop +an unauthenticated one, since a cold-start user has a vault sitting on disk. + +What actually forces authentication on a cold start is that the splash owns the +only normal path into `(app)`, plus the fact that `walletAuthenticated` dies +with the webview. A deep link is a **second door** into the app, which is why it +needs an explicit flag to consult rather than relying on that implicit +guarantee. + +## Known gaps + +- **Not covered by tests:** vitest here is node-only and mounts no Svelte + components, so the specs pin this module's semantics, not the call sites in + `.svelte` files. Device testing via `pnpm build:apk` is the only real proof. +- **PIN change and passphrase rotation** have not been traced. If either ends a + session without going through `globalState.reset()`, the authenticated flag + would survive when it should not. +- **The Ok confirmation card is not shown after a deep-link login.** Approving + runs `goto("/main")` before `openUrl`, which unmounts the page that owns the + drawer, and the subsequent Activity restart wipes `sessionStorage` anyway. + This is pre-existing behaviour, unrelated to the race, and still open. diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts index ca2b58eab..2b044d9da 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -1,47 +1,7 @@ /** * State for the deep-link login flow. * - * Showing the Approve/Decline consent screen requires TWO independent things - * to finish, in an order nobody controls: - * - * 1. The URL arriving. The root layout imports the deep-link plugin - * asynchronously, then asks it for the launch URL. - * 2. The user authenticating. On a cold start the splash prompts for - * biometrics, which can succeed in ~200ms or take seconds. - * - * Whoever finishes LAST owns the routing: - * - * - The URL arrives while unauthenticated -> store it, route nothing. The - * screen that completes authentication picks it up. - * - The URL arrives while authenticated -> route to the consent screen now. - * - * There is ONE payload slot. Storing a deep link never implies permission to - * act on it: that is `isAuthenticated()`, which both sides check. - * - * The bug this replaces came from the layout inferring "is the user - * authenticated?" from `window.location.pathname` at the instant of delivery: - * on a cold start the path is "/" for the splash regardless of how the race - * went, so a user who had ALREADY authenticated was still classified as - * logged out. The payload was stored for a screen that had finished running, - * and the user landed on /main with the consent screen never shown. - * - * Authentication state is therefore recorded EXPLICITLY, by the code that - * performs the authentication, and never derived from the URL. - * - * Deliberately sessionStorage rather than a Svelte store or localStorage: - * - * - A Svelte store is in-memory, and this state has to survive the full-page - * navigations the wallet performs between the splash, /login and /scan-qr. - * An in-memory store would be empty on the other side. - * - localStorage would survive the app being killed, which is exactly wrong - * for the authenticated flag: a deep link arriving after a cold start must - * trigger a real authentication, not inherit one from a previous run. - * Being forgotten on relaunch is the property that makes it safe. - * - * Every accessor degrades to "nothing stored" when storage is unavailable - * (private mode, storage disabled) rather than throwing, because these are - * called from deep-link callbacks where a throw is invisible to the user and - * strands the flow. + * See docs/architecture/deepLink.md for the flow this state serves. */ const PAYLOAD_KEY = "deepLinkData"; @@ -91,13 +51,8 @@ export function clearDeepLink(): void { } /** - * Wipe the session on logout. - * - * The authenticated flag MUST be cleared here. Logout resets global state and - * does an SPA navigation to "/", which leaves sessionStorage intact, so - * without this the session would keep claiming the user is authenticated and - * the next deep link would route itself straight to the consent screen on the - * strength of a login that has already ended. + * Wipe the session on logout. Clearing the authenticated flag is required: + * logout is an SPA navigation and leaves sessionStorage intact. */ export function resetAuthSession(): void { const s = store(); diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index 225b9b0cb..55a0411ae 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -15,12 +15,9 @@ import { hasDeepLink, markAuthenticated } from "$lib/stores/deepLink"; export async function continueAfterSuccessfulAuth( gs: GlobalState, ): Promise { - // This is the authentication HALF of the rendezvous (see lib/stores/deepLink.ts). - // - // Record the fact BEFORE any await. A deep link delivered while the chores - // below are in flight must be able to see that the user is already through - // the gate, so it routes itself to the consent screen instead of parking a - // payload that nobody is left to collect. + // Record the fact BEFORE any await: a deep link delivered while the chores + // below are in flight must see the user as already through the gate. + // See docs/architecture/deepLink.md. markAuthenticated(); // 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 @@ -60,9 +57,8 @@ export async function continueAfterSuccessfulAuth( console.error("Error reading vault during login:", error); } - // A deep link may have arrived before or during authentication. Either - // way it is sitting in the one payload slot, and the user is now allowed - // to act on it. + // A deep link may have arrived before or during authentication; either way + // it is stored, and the user is now allowed to act on it. if (hasDeepLink()) { await goto("/scan-qr"); return; diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index 6ed963b6c..663c2e458 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -184,26 +184,8 @@ onMount(async () => { } /** - * Route a parsed deep-link payload. This is the layout's HALF of the - * rendezvous described in lib/stores/deepLink.ts. - * - * Two outcomes, decided by one explicitly-recorded fact: - * - * - Authenticated: hand the payload straight to the consent screen. - * - Not authenticated: PARK it and route nothing. The screen that - * completes authentication (splash after biometrics, or /login after - * PIN) collects it and routes. - * - * The previous version asked `isAuthenticatedRoute(window.location.pathname)` - * instead. That is unsound on a cold start: the path is "/" for the splash - * regardless of whether the user has authenticated, so a fast biometric - * success was still read as "logged out". The payload was parked for a - * screen that had already finished, and the consent screen never appeared. - * - * Note this no longer navigates to /login on the unauthenticated path. The - * splash is where biometrics are prompted, so steering away from it would - * downgrade a returning user to the PIN pad. The splash routes onward by - * itself in every exit path. + * Route a parsed deep-link payload: the layout's half of the rendezvous. + * See docs/architecture/deepLink.md. */ function routeDeepLink(deepLinkData: Record) { // Store it either way: the payload is the same regardless of who From 775f12c7dc30608acabb600a274f6a776aeaa092 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 11:08:21 +0530 Subject: [PATCH 06/24] refactor(eid-wallet): name the deep-link auth accessors for their consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markAuthenticated / isAuthenticated / resetAuthSession read like the app-wide authentication gate when imported elsewhere, which they are not: the (app) route guard checks the vault (enrolment), and nothing but the deep-link flow reads this flag. Rename to markAuthenticatedForDeepLink, isAuthenticatedForDeepLink and resetDeepLinkAuthSession so a call site says which flow it belongs to. The suffix describes the consumer, not the scope — the underlying fact is the session's authentication state. Noted at the declaration and in the architecture doc so the names do not imply a deep-link-only concept that someone later duplicates for another flow. --- .../eid-wallet/docs/architecture/deepLink.md | 14 +++++++++----- .../eid-wallet/src/lib/stores/deepLink.spec.ts | 18 +++++++++--------- .../eid-wallet/src/lib/stores/deepLink.ts | 15 ++++++++++----- .../eid-wallet/src/lib/utils/postLogin.ts | 7 +++++-- .../src/routes/(app)/settings/+page.svelte | 4 ++-- .../eid-wallet/src/routes/+layout.svelte | 7 +++++-- 6 files changed, 40 insertions(+), 25 deletions(-) diff --git a/infrastructure/eid-wallet/docs/architecture/deepLink.md b/infrastructure/eid-wallet/docs/architecture/deepLink.md index 90211342b..6cb55c737 100644 --- a/infrastructure/eid-wallet/docs/architecture/deepLink.md +++ b/infrastructure/eid-wallet/docs/architecture/deepLink.md @@ -40,7 +40,7 @@ flowchart TD A["Android intent w3ds://"] --> B["root +layout
onOpenUrl / getCurrent"] B --> C["parse payload"] C --> D["storeDeepLink(payload)"] - D --> E{"isAuthenticated()?"} + D --> E{"isAuthenticatedForDeepLink()?"} E -- "no" --> F["route nothing:
the auth path will collect it"] E -- "yes" --> G["goto /scan-qr"] @@ -50,7 +50,7 @@ flowchart TD S2 -- "cancel / unavailable" --> L["/login PIN pad"] L -- "pin ok" --> P - P --> P1["markAuthenticated()
BEFORE any await"] + P --> P1["markAuthenticatedForDeepLink()
BEFORE any await"] P1 --> P2{"hasDeepLink()?"} P2 -- "yes" --> G P2 -- "no" --> M["goto /main"] @@ -63,7 +63,7 @@ flowchart TD ## One payload slot Storing a deep link never implies permission to act on it. That is -`isAuthenticated()`, which every consumer checks anyway. +`isAuthenticatedForDeepLink()`, which every consumer checks anyway. An earlier version had two slots, `pendingDeepLink` and `deepLinkData`, and "promoted" between them once the user authenticated. The copy carried no @@ -87,7 +87,7 @@ never shown. That was the bug this design replaces. Authentication state is therefore written by the code that performs the authentication, and never derived from the URL or the route. -`markAuthenticated()` must be called **before any await** that precedes the +`markAuthenticatedForDeepLink()` must be called **before any await** that precedes the caller's navigation. A deep link delivered while post-login chores are in flight has to see the user as authenticated, or it will store a payload nobody is left to collect. @@ -110,7 +110,7 @@ deep-link callbacks where a throw is invisible to the user and strands the flow. ## Logout -`resetAuthSession()` clears both keys. This is required, not defensive: logout +`resetDeepLinkAuthSession()` clears both keys. This is required, not defensive: logout does `goto("/")`, an SPA navigation that leaves `sessionStorage` intact. Without it the session would keep claiming the user is authenticated, and the next deep link would route straight to the consent screen on the strength of a login that @@ -141,6 +141,10 @@ Worth stating plainly, because the names are misleading. | `securityController.pinHash` | Is a PIN **configured**? | Disk | | `walletAuthenticated` | Has the user authenticated **this session**? | sessionStorage | +The accessors are named `...ForDeepLink` because the deep-link flow is their +only consumer, not because the underlying fact is deep-link specific. It is the +session's authentication state; nothing else reads it today. + The `(app)` route guard checks the **vault**, i.e. enrolment, not authentication. It stops a never-onboarded or logged-out user; it does not stop an unauthenticated one, since a cold-start user has a vault sitting on disk. diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index b5c5f1616..ef56d79ba 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -3,10 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearDeepLink, hasDeepLink, - isAuthenticated, - markAuthenticated, + isAuthenticatedForDeepLink, + markAuthenticatedForDeepLink, peekDeepLink, - resetAuthSession, + resetDeepLinkAuthSession, storeDeepLink, } from "./deepLink"; @@ -51,7 +51,7 @@ const PAYLOAD = { */ function layoutRouteDeepLink(): "/scan-qr" | null { storeDeepLink(PAYLOAD); - if (!isAuthenticated()) return null; + if (!isAuthenticatedForDeepLink()) return null; return "/scan-qr"; } @@ -60,7 +60,7 @@ function layoutRouteDeepLink(): "/scan-qr" | null { * (biometric on the splash, PIN on /login) funnels through. */ function completeAuthentication(): "/scan-qr" | "/main" { - markAuthenticated(); + markAuthenticatedForDeepLink(); return hasDeepLink() ? "/scan-qr" : "/main"; } @@ -147,16 +147,16 @@ describe("deep-link login rendezvous", () => { /** * Logout does an SPA navigation to "/", which leaves sessionStorage - * intact. Without resetAuthSession() the session would keep claiming the + * intact. Without resetDeepLinkAuthSession() the session would keep claiming the * user is authenticated and the next deep link would skip the gate. */ it("forgets authentication on logout so the next link re-prompts", () => { completeAuthentication(); - expect(isAuthenticated()).toBe(true); + expect(isAuthenticatedForDeepLink()).toBe(true); - resetAuthSession(); + resetDeepLinkAuthSession(); - expect(isAuthenticated()).toBe(false); + expect(isAuthenticatedForDeepLink()).toBe(false); expect(layoutRouteDeepLink()).toBeNull(); }); diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts index 2b044d9da..8338dd254 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -18,15 +18,20 @@ function store(): Storage | null { /** * Record that the user is through the authentication gate. * + * The "ForDeepLink" suffix describes the CONSUMER, not the scope: this is the + * session's authentication state, and the deep-link flow is currently its only + * reader. The (app) route guard checks the vault (enrolment) instead, so do not + * read this as "the app's auth gate lives here". + * * Callers must do this BEFORE any await that precedes their navigation, so a * deep link delivered mid-flight sees the user as authenticated and routes * itself rather than storing a payload nobody is left to collect. */ -export function markAuthenticated(): void { +export function markAuthenticatedForDeepLink(): void { store()?.setItem(AUTHED_KEY, "true"); } -export function isAuthenticated(): boolean { +export function isAuthenticatedForDeepLink(): boolean { return store()?.getItem(AUTHED_KEY) === "true"; } @@ -51,10 +56,10 @@ export function clearDeepLink(): void { } /** - * Wipe the session on logout. Clearing the authenticated flag is required: - * logout is an SPA navigation and leaves sessionStorage intact. + * Wipe the deep-link session on logout. Clearing the authenticated flag is + * required: logout is an SPA navigation and leaves sessionStorage intact. */ -export function resetAuthSession(): void { +export function resetDeepLinkAuthSession(): void { const s = store(); s?.removeItem(PAYLOAD_KEY); s?.removeItem(AUTHED_KEY); diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index 55a0411ae..051714929 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -1,6 +1,9 @@ import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; -import { hasDeepLink, markAuthenticated } from "$lib/stores/deepLink"; +import { + hasDeepLink, + markAuthenticatedForDeepLink, +} from "$lib/stores/deepLink"; /** * Shared post-authentication routine: fires the background eVault chores @@ -18,7 +21,7 @@ export async function continueAfterSuccessfulAuth( // Record the fact BEFORE any await: a deep link delivered while the chores // below are in flight must see the user as already through the gate. // See docs/architecture/deepLink.md. - markAuthenticated(); + markAuthenticatedForDeepLink(); // 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. diff --git a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte index 261cd31dc..6f1d050eb 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte @@ -3,7 +3,7 @@ import { goto } from "$app/navigation"; import { SettingsNavigationBtn } from "$lib/fragments"; import type { GlobalState } from "$lib/global"; import { runtime } from "$lib/global/runtime.svelte"; -import { resetAuthSession } from "$lib/stores/deepLink"; +import { resetDeepLinkAuthSession } from "$lib/stores/deepLink"; import { getCurrentLanguage, subscribe as subscribeLanguage, @@ -95,7 +95,7 @@ async function performLogout() { // this the session would keep claiming the user is authenticated, and a // deep link arriving afterwards would route straight to the consent screen // instead of prompting for authentication. - resetAuthSession(); + resetDeepLinkAuthSession(); goto("/"); } diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index 663c2e458..69dfe9c71 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -5,7 +5,10 @@ import "../app.css"; import { beforeNavigate, goto, onNavigate, preloadCode } from "$app/navigation"; import { page } from "$app/state"; import { GlobalState } from "$lib/global/state"; -import { isAuthenticated, storeDeepLink } from "$lib/stores/deepLink"; +import { + isAuthenticatedForDeepLink, + storeDeepLink, +} from "$lib/stores/deepLink"; import { runtime } from "$lib/global/runtime.svelte"; import { swipedetect } from "$lib/utils"; @@ -192,7 +195,7 @@ onMount(async () => { // ends up routing it. storeDeepLink(deepLinkData); - if (!isAuthenticated()) { + if (!isAuthenticatedForDeepLink()) { console.log("Deep link stored: user has not authenticated yet"); return; } From c8197ee8ddcca0c4496686a61158681cead80aaf Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 11:21:44 +0530 Subject: [PATCH 07/24] refactor(eid-wallet): move session auth onto a SessionController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Has the user authenticated this run" was a pair of functions in lib/stores/deepLink.ts, which read as a deep-link concept. It is not: it is the session's state, and the deep-link flow is only its first reader. Add GlobalState.sessionController alongside the other controllers. Logout now clears it automatically, because GlobalState.reset() already calls clear() on each controller — settings no longer needs its own call for the flag, only for the parked payload. SessionController deliberately takes no Store, unlike its siblings. The flag must NOT survive the app being killed, or a deep link on a cold start would inherit a previous run's login and skip the prompt. It must survive the WEBVIEW being rebuilt, which is a different event: Android can reload the webview while the app is backgrounded by openUrl, and the approve path does a document navigation to the platform's redirect. Neither is a new run, and the flow cannot re-prompt mid-handoff, so an in-memory field would strand the user. sessionStorage is exactly that lifetime. A test pins it: a fresh SessionController reading the same storage is what a rebuilt webview sees. Swapping the implementation to an in-memory field fails that test and the logout test. lib/stores/deepLink.ts is now payload-only. --- .../eid-wallet/docs/architecture/deepLink.md | 27 ++++--- .../src/lib/global/controllers/session.ts | 72 +++++++++++++++++++ .../eid-wallet/src/lib/global/state.ts | 4 ++ .../src/lib/stores/deepLink.spec.ts | 40 ++++++++--- .../eid-wallet/src/lib/stores/deepLink.ts | 47 ++++-------- .../eid-wallet/src/lib/utils/postLogin.ts | 7 +- .../src/routes/(app)/settings/+page.svelte | 11 ++- .../eid-wallet/src/routes/+layout.svelte | 7 +- 8 files changed, 147 insertions(+), 68 deletions(-) create mode 100644 infrastructure/eid-wallet/src/lib/global/controllers/session.ts diff --git a/infrastructure/eid-wallet/docs/architecture/deepLink.md b/infrastructure/eid-wallet/docs/architecture/deepLink.md index 6cb55c737..c4b9c4506 100644 --- a/infrastructure/eid-wallet/docs/architecture/deepLink.md +++ b/infrastructure/eid-wallet/docs/architecture/deepLink.md @@ -40,7 +40,7 @@ flowchart TD A["Android intent w3ds://"] --> B["root +layout
onOpenUrl / getCurrent"] B --> C["parse payload"] C --> D["storeDeepLink(payload)"] - D --> E{"isAuthenticatedForDeepLink()?"} + D --> E{"globalState.sessionController.isAuthenticated?"} E -- "no" --> F["route nothing:
the auth path will collect it"] E -- "yes" --> G["goto /scan-qr"] @@ -50,7 +50,7 @@ flowchart TD S2 -- "cancel / unavailable" --> L["/login PIN pad"] L -- "pin ok" --> P - P --> P1["markAuthenticatedForDeepLink()
BEFORE any await"] + P --> P1["sessionController.markAuthenticated()
BEFORE any await"] P1 --> P2{"hasDeepLink()?"} P2 -- "yes" --> G P2 -- "no" --> M["goto /main"] @@ -63,7 +63,7 @@ flowchart TD ## One payload slot Storing a deep link never implies permission to act on it. That is -`isAuthenticatedForDeepLink()`, which every consumer checks anyway. +`globalState.sessionController.isAuthenticated`, which every consumer checks anyway. An earlier version had two slots, `pendingDeepLink` and `deepLinkData`, and "promoted" between them once the user authenticated. The copy carried no @@ -87,14 +87,15 @@ never shown. That was the bug this design replaces. Authentication state is therefore written by the code that performs the authentication, and never derived from the URL or the route. -`markAuthenticatedForDeepLink()` must be called **before any await** that precedes the +`sessionController.markAuthenticated()` must be called **before any await** that precedes the caller's navigation. A deep link delivered while post-login chores are in flight has to see the user as authenticated, or it will store a payload nobody is left to collect. ## Storage choice -`sessionStorage`, deliberately — not a Svelte store, not `localStorage`. +`sessionStorage`, deliberately — not a Svelte store, not `localStorage`, and +not an in-memory field on the controller. - **A Svelte store is in-memory.** This state has to survive the full-page navigations the wallet performs between the splash, `/login` and `/scan-qr`. @@ -103,6 +104,13 @@ is left to collect. for the authenticated flag. A deep link arriving after a cold start must trigger a real authentication rather than inheriting one from a previous run. Being forgotten on relaunch is the property that makes it safe. +- **An in-memory field would not survive the WEBVIEW being rebuilt**, which is + a different event from the app being killed. Android may reload the webview + while the app is backgrounded by `openUrl`, and the approve path does a + document navigation to the platform's redirect. Neither is a new run of the + app, and the flow has no way to re-prompt mid-handoff, so the user would be + stranded. `SessionController` therefore takes no store and reads + sessionStorage directly. Every accessor degrades to "nothing stored" when storage is unavailable (private mode, storage disabled) rather than throwing, because these run inside @@ -110,7 +118,7 @@ deep-link callbacks where a throw is invisible to the user and strands the flow. ## Logout -`resetDeepLinkAuthSession()` clears both keys. This is required, not defensive: logout +`GlobalState.reset()` clears both keys. This is required, not defensive: logout does `goto("/")`, an SPA navigation that leaves `sessionStorage` intact. Without it the session would keep claiming the user is authenticated, and the next deep link would route straight to the consent screen on the strength of a login that @@ -141,9 +149,10 @@ Worth stating plainly, because the names are misleading. | `securityController.pinHash` | Is a PIN **configured**? | Disk | | `walletAuthenticated` | Has the user authenticated **this session**? | sessionStorage | -The accessors are named `...ForDeepLink` because the deep-link flow is their -only consumer, not because the underlying fact is deep-link specific. It is the -session's authentication state; nothing else reads it today. +`walletAuthenticated` is owned by `GlobalState.sessionController`, alongside the +other controllers. It is the session's authentication state, not a deep-link +concept; the deep-link flow is simply its only reader today. `lib/stores/deepLink.ts` +owns only the pending payload. The `(app)` route guard checks the **vault**, i.e. enrolment, not authentication. It stops a never-onboarded or logged-out user; it does not stop diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/session.ts b/infrastructure/eid-wallet/src/lib/global/controllers/session.ts new file mode 100644 index 000000000..3d675afc7 --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/global/controllers/session.ts @@ -0,0 +1,72 @@ +/** + * Runtime state for the current run of the app, as opposed to the persisted + * configuration the other controllers own. + * + * Every other controller wraps the Tauri store, so everything they hold + * survives the app being killed: is a PIN set, is an identity enrolled, are + * biometrics enabled. Those are configuration questions. + * + * "Has the user authenticated?" is not one of them. It must be forgotten when + * the app is killed, or a deep link arriving on a cold start would inherit a + * login from a previous run and skip the prompt entirely. + * + * It must equally SURVIVE the webview being rebuilt, which is not the same + * event. Approving a deep-link login hands off to the browser via openUrl, and + * Android is free to reload the backgrounded webview; the approve path also + * does a document navigation to the platform's redirect. Neither is a new run + * of the app, and the current flow has no way to re-prompt in the middle of + * one, so an in-memory field would strand the user. + * + * sessionStorage is exactly that lifetime: dies with the tab/app, survives a + * reload. Hence a controller that takes the Store like its siblings but + * deliberately does not use it. + * + * See docs/architecture/deepLink.md. + */ +export class SessionController { + static readonly #AUTHENTICATED_KEY = "walletAuthenticated"; + + #storage(): Storage | null { + try { + return typeof sessionStorage === "undefined" + ? null + : sessionStorage; + } catch { + // Private mode / storage disabled. Degrade to "not authenticated" + // rather than throwing inside a deep-link callback, where a throw + // is invisible to the user and strands the flow. + return null; + } + } + + /** + * Record that the user is through the authentication gate. + * + * Callers must do this BEFORE any await that precedes their navigation, so + * a deep link delivered mid-flight sees the user as authenticated and + * routes itself rather than storing a payload nobody is left to collect. + */ + markAuthenticated(): void { + this.#storage()?.setItem(SessionController.#AUTHENTICATED_KEY, "true"); + } + + get isAuthenticated(): boolean { + return ( + this.#storage()?.getItem(SessionController.#AUTHENTICATED_KEY) === + "true" + ); + } + + /** + * Called by GlobalState.reset() on logout. + * + * Required, not defensive: logout does an SPA navigation to "/", which + * leaves sessionStorage intact. Without this the session would keep + * claiming the user is authenticated, and the next deep link would route + * itself straight to the consent screen on the strength of a login that + * has already ended. + */ + async clear(): Promise { + this.#storage()?.removeItem(SessionController.#AUTHENTICATED_KEY); + } +} diff --git a/infrastructure/eid-wallet/src/lib/global/state.ts b/infrastructure/eid-wallet/src/lib/global/state.ts index 477e3b4e3..27af96640 100644 --- a/infrastructure/eid-wallet/src/lib/global/state.ts +++ b/infrastructure/eid-wallet/src/lib/global/state.ts @@ -5,6 +5,7 @@ import { createKeyServiceCryptoAdapter } from "../wallet-sdk-adapter"; import { VaultController } from "./controllers/evault"; import { KeyService } from "./controllers/key"; import { SecurityController } from "./controllers/security"; +import { SessionController } from "./controllers/session"; import { UserController } from "./controllers/user"; /** * @author SoSweetHam @@ -28,6 +29,7 @@ export class GlobalState { #store: Store; #walletSdkAdapter: CryptoAdapter; securityController: SecurityController; + sessionController: SessionController; userController: UserController; vaultController: VaultController; notificationService: NotificationService; @@ -41,6 +43,7 @@ export class GlobalState { this.#store = store; this.#walletSdkAdapter = createKeyServiceCryptoAdapter(keyService); this.securityController = new SecurityController(store); + this.sessionController = new SessionController(); this.userController = new UserController(store); this.keyService = keyService; this.vaultController = new VaultController( @@ -158,6 +161,7 @@ export class GlobalState { async reset() { try { await this.securityController.clear(); + await this.sessionController.clear(); await this.userController.clear(); await this.vaultController.clear(); await this.keyService.clear(); diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index ef56d79ba..b04adbdcc 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -1,12 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionController } from "$lib/global/controllers/session"; import { clearDeepLink, hasDeepLink, - isAuthenticatedForDeepLink, - markAuthenticatedForDeepLink, peekDeepLink, - resetDeepLinkAuthSession, storeDeepLink, } from "./deepLink"; @@ -33,8 +31,11 @@ class MemoryStorage implements Storage { } } +let session: SessionController; + beforeEach(() => { vi.stubGlobal("sessionStorage", new MemoryStorage()); + session = new SessionController(); }); const PAYLOAD = { @@ -51,7 +52,7 @@ const PAYLOAD = { */ function layoutRouteDeepLink(): "/scan-qr" | null { storeDeepLink(PAYLOAD); - if (!isAuthenticatedForDeepLink()) return null; + if (!session.isAuthenticated) return null; return "/scan-qr"; } @@ -60,7 +61,7 @@ function layoutRouteDeepLink(): "/scan-qr" | null { * (biometric on the splash, PIN on /login) funnels through. */ function completeAuthentication(): "/scan-qr" | "/main" { - markAuthenticatedForDeepLink(); + session.markAuthenticated(); return hasDeepLink() ? "/scan-qr" : "/main"; } @@ -150,16 +151,37 @@ describe("deep-link login rendezvous", () => { * intact. Without resetDeepLinkAuthSession() the session would keep claiming the * user is authenticated and the next deep link would skip the gate. */ - it("forgets authentication on logout so the next link re-prompts", () => { + it("forgets authentication on logout so the next link re-prompts", async () => { completeAuthentication(); - expect(isAuthenticatedForDeepLink()).toBe(true); + expect(session.isAuthenticated).toBe(true); - resetDeepLinkAuthSession(); + // What GlobalState.reset() does on logout. + await session.clear(); + clearDeepLink(); - expect(isAuthenticatedForDeepLink()).toBe(false); + expect(session.isAuthenticated).toBe(false); expect(layoutRouteDeepLink()).toBeNull(); }); + /** + * The webview can be rebuilt without the app being killed: Android may + * reload it while the app is backgrounded by openUrl, and the approve path + * does a document navigation to the platform's redirect. The flow has no + * way to re-prompt mid-handoff, so authentication must survive that. + * + * A fresh SessionController reading the same sessionStorage is exactly + * what a rebuilt webview sees. An in-memory field would fail this. + */ + it("keeps the user authenticated across a webview rebuild", () => { + completeAuthentication(); + layoutRouteDeepLink(); + + const rebuilt = new SessionController(); + + expect(rebuilt.isAuthenticated).toBe(true); + expect(hasDeepLink()).toBe(true); + }); + it("survives storage being unavailable without throwing", () => { vi.stubGlobal("sessionStorage", undefined); diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts index 8338dd254..5c77d4b83 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -1,40 +1,29 @@ /** - * State for the deep-link login flow. + * The pending deep-link payload. * - * See docs/architecture/deepLink.md for the flow this state serves. + * Authentication state lives on GlobalState.sessionController, not here: it is + * the session's business, and this module only owns the payload waiting to be + * consented to. + * + * sessionStorage because the payload has to survive the full-page navigations + * the wallet performs between the splash, /login and /scan-qr, and the webview + * rebuild Android may perform while the app is backgrounded by openUrl. + * + * See docs/architecture/deepLink.md. */ const PAYLOAD_KEY = "deepLinkData"; -const AUTHED_KEY = "walletAuthenticated"; function store(): Storage | null { try { return typeof sessionStorage === "undefined" ? null : sessionStorage; } catch { + // Private mode / storage disabled. Degrade to "no deep link" rather + // than throwing inside a deep-link callback. return null; } } -/** - * Record that the user is through the authentication gate. - * - * The "ForDeepLink" suffix describes the CONSUMER, not the scope: this is the - * session's authentication state, and the deep-link flow is currently its only - * reader. The (app) route guard checks the vault (enrolment) instead, so do not - * read this as "the app's auth gate lives here". - * - * Callers must do this BEFORE any await that precedes their navigation, so a - * deep link delivered mid-flight sees the user as authenticated and routes - * itself rather than storing a payload nobody is left to collect. - */ -export function markAuthenticatedForDeepLink(): void { - store()?.setItem(AUTHED_KEY, "true"); -} - -export function isAuthenticatedForDeepLink(): boolean { - return store()?.getItem(AUTHED_KEY) === "true"; -} - /** Store an incoming deep-link payload, whatever the authentication state. */ export function storeDeepLink(data: unknown): void { store()?.setItem(PAYLOAD_KEY, JSON.stringify(data)); @@ -50,17 +39,7 @@ export function hasDeepLink(): boolean { return peekDeepLink() !== null; } -/** Clear the payload once the consent screen has shown it. */ +/** Clear the payload once the consent screen has shown it, or on logout. */ export function clearDeepLink(): void { store()?.removeItem(PAYLOAD_KEY); } - -/** - * Wipe the deep-link session on logout. Clearing the authenticated flag is - * required: logout is an SPA navigation and leaves sessionStorage intact. - */ -export function resetDeepLinkAuthSession(): void { - const s = store(); - s?.removeItem(PAYLOAD_KEY); - s?.removeItem(AUTHED_KEY); -} diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index 051714929..6c82f3ce5 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -1,9 +1,6 @@ import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; -import { - hasDeepLink, - markAuthenticatedForDeepLink, -} from "$lib/stores/deepLink"; +import { hasDeepLink } from "$lib/stores/deepLink"; /** * Shared post-authentication routine: fires the background eVault chores @@ -21,7 +18,7 @@ export async function continueAfterSuccessfulAuth( // Record the fact BEFORE any await: a deep link delivered while the chores // below are in flight must see the user as already through the gate. // See docs/architecture/deepLink.md. - markAuthenticatedForDeepLink(); + gs.sessionController.markAuthenticated(); // 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. diff --git a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte index 6f1d050eb..2a4bfa5f3 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte @@ -3,7 +3,7 @@ import { goto } from "$app/navigation"; import { SettingsNavigationBtn } from "$lib/fragments"; import type { GlobalState } from "$lib/global"; import { runtime } from "$lib/global/runtime.svelte"; -import { resetDeepLinkAuthSession } from "$lib/stores/deepLink"; +import { clearDeepLink } from "$lib/stores/deepLink"; import { getCurrentLanguage, subscribe as subscribeLanguage, @@ -91,11 +91,10 @@ async function performLogout() { } const newGlobalState = await globalState.reset(); setGlobalState(newGlobalState); - // goto("/") is an SPA navigation, so sessionStorage survives it. Without - // this the session would keep claiming the user is authenticated, and a - // deep link arriving afterwards would route straight to the consent screen - // instead of prompting for authentication. - resetDeepLinkAuthSession(); + // The authenticated flag is cleared by reset() via sessionController. + // A deep link parked before logout must go too: goto("/") is an SPA + // navigation, so sessionStorage survives it. + clearDeepLink(); goto("/"); } diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index 69dfe9c71..67252ed4f 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -5,10 +5,7 @@ import "../app.css"; import { beforeNavigate, goto, onNavigate, preloadCode } from "$app/navigation"; import { page } from "$app/state"; import { GlobalState } from "$lib/global/state"; -import { - isAuthenticatedForDeepLink, - storeDeepLink, -} from "$lib/stores/deepLink"; +import { storeDeepLink } from "$lib/stores/deepLink"; import { runtime } from "$lib/global/runtime.svelte"; import { swipedetect } from "$lib/utils"; @@ -195,7 +192,7 @@ onMount(async () => { // ends up routing it. storeDeepLink(deepLinkData); - if (!isAuthenticatedForDeepLink()) { + if (!globalState?.sessionController.isAuthenticated) { console.log("Deep link stored: user has not authenticated yet"); return; } From 721a1b8dd5f409e547e585a7780aeec568dd1234 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 11:29:35 +0530 Subject: [PATCH 08/24] chore(eid-wallet): drop a comment restating what logout already shows The line said reset() clears the authenticated flag via sessionController, which is visible at the reset() call directly above it. What is not obvious stays: why the parked payload needs a separate clear. --- .../eid-wallet/src/routes/(app)/settings/+page.svelte | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte index 2a4bfa5f3..9de9dcdde 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte @@ -91,9 +91,7 @@ async function performLogout() { } const newGlobalState = await globalState.reset(); setGlobalState(newGlobalState); - // The authenticated flag is cleared by reset() via sessionController. - // A deep link parked before logout must go too: goto("/") is an SPA - // navigation, so sessionStorage survives it. + // goto("/") is an SPA navigation, so sessionStorage survives it. clearDeepLink(); goto("/"); } From a64538a892e8f4e76e90da041d4f7524a997fa13 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 11:32:35 +0530 Subject: [PATCH 09/24] chore(eid-wallet): drop the splash comment about not diverting to /login It explained the absence of a redirect that no longer exists in the file, so it described history rather than the code. The deep-link routing rules are in docs/architecture/deepLink.md. --- infrastructure/eid-wallet/src/routes/+page.svelte | 6 ------ 1 file changed, 6 deletions(-) diff --git a/infrastructure/eid-wallet/src/routes/+page.svelte b/infrastructure/eid-wallet/src/routes/+page.svelte index 995d1fec4..3954822bd 100644 --- a/infrastructure/eid-wallet/src/routes/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/+page.svelte @@ -106,12 +106,6 @@ onMount(async () => { return; } - // NOTE: a pending deep link deliberately does NOT divert to /login. - // Biometrics are prompted here, so diverting would downgrade a - // returning user to the PIN pad for the one flow most likely to be - // used by someone in a hurry. continueAfterSuccessfulAuth collects the - // parked payload and routes to the consent screen itself. - // 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 From c5523cc35e2dd4132a49e21f10d94683e65b1fe1 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 11:33:26 +0530 Subject: [PATCH 10/24] chore(eid-wallet): drop the pointer to where auth state lives A module saying what it does not contain is noise; the file exports four payload functions and nothing about authentication. --- infrastructure/eid-wallet/src/lib/stores/deepLink.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts index 5c77d4b83..50cec063a 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -1,10 +1,6 @@ /** * The pending deep-link payload. * - * Authentication state lives on GlobalState.sessionController, not here: it is - * the session's business, and this module only owns the payload waiting to be - * consented to. - * * sessionStorage because the payload has to survive the full-page navigations * the wallet performs between the splash, /login and /scan-qr, and the webview * rebuild Android may perform while the app is backgrounded by openUrl. From 452a2770732d0fc940d34423d6d5f76f66bf0f19 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 11:41:02 +0530 Subject: [PATCH 11/24] fix(eid-wallet): prompt biometrics only on the splash /login had its own authenticate() call, suppressed by a biometricAttemptedOnSplash handshake flag. The suppression was racy: the splash wrote the flag only after two awaits resolved biometricAvailable, while /login read it behind a globalState poll of up to five seconds. Anything routing to /login inside that window found no flag and prompted a second time, over a half-painted PIN pad, with a second post-auth routine able to consume the same deep-link payload. Delete the prompt from /login rather than coordinate it. The screen is the PIN fallback by definition: the splash routes here only once the prompt was declined, failed, or was unavailable. With it go the flag, the authOpts block and the biometric imports. The splash is now the only file importing authenticate() from @tauri-apps/plugin-biometric; every other importer takes checkStatus for availability. A single prompt site is structural, so there is no longer a flag to get wrong. Trade-off: cancelling the prompt leaves the user on the PIN pad with no way to retry biometrics without relaunching. That is what a fallback screen means, and it matches the behaviour deep-link launches already had. --- .../eid-wallet/docs/architecture/deepLink.md | 26 ++++++--- .../src/routes/(auth)/login/+page.svelte | 53 ++----------------- .../eid-wallet/src/routes/+page.svelte | 18 ++----- 3 files changed, 29 insertions(+), 68 deletions(-) diff --git a/infrastructure/eid-wallet/docs/architecture/deepLink.md b/infrastructure/eid-wallet/docs/architecture/deepLink.md index c4b9c4506..1886fb202 100644 --- a/infrastructure/eid-wallet/docs/architecture/deepLink.md +++ b/infrastructure/eid-wallet/docs/architecture/deepLink.md @@ -131,13 +131,25 @@ onward itself; it does not divert a deep-link launch to `/login`, because doing so would downgrade a returning user to the PIN pad for the flow most likely to be used in a hurry. -Two consequences worth knowing: - -- The splash's async `onMount` carries liveness checks (`destroyed`). Unmounting - a Svelte component does not cancel a continuation parked on an `await`, so it - could otherwise wake after the consent drawer opened and navigate away from it. -- `/login` still prompts biometrics when the splash did not, coordinated through - the `biometricAttemptedOnSplash` flag inherited from the original design. +`/login` contains no call to `authenticate()` at all, and the splash is the only +file in the app that imports it from `@tauri-apps/plugin-biometric`. Everything +else imports `checkStatus` to read availability. The guarantee is therefore +structural rather than coordinated: there is no flag to get wrong. + +An earlier design had both screens prompt, suppressed by a +`biometricAttemptedOnSplash` handshake. It was racy — the splash wrote the flag +only after two awaits resolved `biometricAvailable`, while `/login` read it +behind a globalState poll of up to five seconds, so anything routing to `/login` +inside that window got a second prompt. The flag is gone. + +One consequence worth knowing: the splash's async `onMount` carries liveness +checks (`destroyed`). Unmounting a Svelte component does not cancel a +continuation parked on an `await`, so it could otherwise wake after the consent +drawer opened and navigate away from it. + +The trade-off is deliberate: cancelling the biometric prompt leaves the user on +the PIN pad with no way to retry biometrics without relaunching. `/login` is +defined as the fallback, so that is the intended behaviour. ## What authentication actually means here diff --git a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte index a5a4ce17e..f004670a6 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte @@ -6,18 +6,9 @@ import { hasDeepLink } from "$lib/stores/deepLink"; import { LoadingSheet, PinDots } from "$lib/ui"; import * as Button from "$lib/ui/Button"; import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; -import { - type AuthOptions, - authenticate, - checkStatus, -} from "@tauri-apps/plugin-biometric"; import { getContext, onMount } from "svelte"; import StepHeader from "../onboarding/steps/StepHeader.svelte"; -// Splash sets this when it has already tried biometric over its own screen. -// /login then skips re-prompting and just shows the PIN UI. -const BIOMETRIC_ATTEMPTED_KEY = "biometricAttemptedOnSplash"; - let pin = $state(""); let isError = $state(false); let isPostAuthLoading = $state(false); @@ -37,17 +28,6 @@ function handleBackgroundClick(e: MouseEvent) { const getGlobalState = getContext<() => GlobalState | undefined>("globalState"); let globalState: GlobalState | undefined = $state(undefined); -const authOpts: AuthOptions = { - allowDeviceCredential: false, - cancelTitle: "Cancel", - // iOS - fallbackTitle: "Please enter your PIN", - // Android - title: "Login", - subtitle: "Please authenticate to continue", - confirmationRequired: true, -}; - async function clearPin() { if (isPostAuthLoading) return; pin = ""; @@ -84,6 +64,11 @@ $effect(() => { if (pin.length === 4) verifyAndAdvance(pin); }); +// This screen is the PIN fallback and deliberately never calls authenticate(). +// Biometrics are prompted from exactly one place, the splash, which routes +// here only once that prompt was declined, failed, or was unavailable. A +// second prompt site made the dialog's placement non-deterministic and let two +// post-auth routines race to consume one deep-link payload. onMount(async () => { // Root +layout creates globalState in its own onMount (which runs after // children). Poll until it's available — same pattern as (app)/+layout. @@ -102,34 +87,6 @@ onMount(async () => { globalState = gs; hasPendingDeepLink = hasDeepLink(); - - // 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 - // route transition but is single-use. - const biometricHandledBySplash = - sessionStorage.getItem(BIOMETRIC_ATTEMPTED_KEY) === "true"; - if (biometricHandledBySplash) { - sessionStorage.removeItem(BIOMETRIC_ATTEMPTED_KEY); - return; - } - - // Try biometric first if available. - if ( - (await gs.securityController.biometricSupport) && - (await checkStatus()).isAvailable - ) { - try { - await authenticate( - "You must authenticate with PIN first", - authOpts, - ); - isPostAuthLoading = true; - await continueAfterSuccessfulAuth(gs); - } catch (e) { - console.error("Biometric authentication failed", e); - isPostAuthLoading = false; - } - } }); diff --git a/infrastructure/eid-wallet/src/routes/+page.svelte b/infrastructure/eid-wallet/src/routes/+page.svelte index 3954822bd..e1967888e 100644 --- a/infrastructure/eid-wallet/src/routes/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/+page.svelte @@ -11,8 +11,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", @@ -106,11 +104,10 @@ onMount(async () => { 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. + // The ONLY biometric prompt in the app. /login is the PIN fallback and + // never prompts, so the dialog always appears over this screen. On + // success we run the post-auth chores and route onward with no /login + // flash; on cancel, failure or unavailability we slide into /login. let biometricAvailable = false; try { biometricAvailable = @@ -123,20 +120,15 @@ onMount(async () => { if (destroyed) return; if (biometricAvailable && globalState) { - sessionStorage.setItem(BIOMETRIC_ATTEMPTED_KEY, "true"); try { await authenticate( "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); 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 — slide into /login for PIN entry. console.warn("Biometric on splash failed", e); } } From da8cf882dea9398d877abc6337c6add7de001d6a Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 16:54:00 +0530 Subject: [PATCH 12/24] test(eid-wallet): exercise the real routing code, not a copy of it The deep-link spec reimplemented both halves of the rendezvous as local helpers and asserted against those. The tests therefore described the design rather than the app, and would have kept passing if the shipped routing had been deleted. The earlier mutation runs hid this because every mutation happened to target the two modules the spec did import. Extract routeDeepLink() from +layout.svelte into lib/utils so it can be imported, and have the spec call it and continueAfterSuccessfulAuth() directly with goto() mocked, asserting on where the code navigated. The routing logic itself is moved unchanged. Add a test for the ordering that markAuthenticated() must precede the vault await: a deep link arriving mid-login has to observe the user as authenticated, otherwise the layout parks the payload for a screen that has already finished, which is the original bug. Mutation-tested: dropping the auth gate, never routing, not storing the payload, always routing to /main, and marking authentication after the await each fail the suite. The last four of those survived beforehand. --- .../src/lib/stores/deepLink.spec.ts | 148 +++++++++++++----- .../eid-wallet/src/lib/utils/routeDeepLink.ts | 41 +++++ .../eid-wallet/src/routes/+layout.svelte | 36 +---- 3 files changed, 156 insertions(+), 69 deletions(-) create mode 100644 infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index b04adbdcc..1a517b69a 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { goto } from "$app/navigation"; +import type { GlobalState } from "$lib/global"; import { SessionController } from "$lib/global/controllers/session"; -import { - clearDeepLink, - hasDeepLink, - peekDeepLink, - storeDeepLink, -} from "./deepLink"; +import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; +import { routeDeepLink } from "$lib/utils/routeDeepLink"; +import { clearDeepLink, hasDeepLink, peekDeepLink } from "./deepLink"; + +vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) })); /** Minimal sessionStorage stand-in; the module is deliberately storage-backed. */ class MemoryStorage implements Storage { @@ -32,10 +33,55 @@ class MemoryStorage implements Storage { } let session: SessionController; +let globalState: GlobalState; + +/** + * The slice of GlobalState the two functions under test actually touch: + * the session gate, plus the post-login chores, which are fire-and-forget and + * must not influence routing. The vault rejects to prove that. + */ +function makeGlobalState( + session: SessionController, + onVaultRead: () => Promise = async () => { + throw new Error("no vault in tests"); + }, +): GlobalState { + return { + sessionController: session, + vaultController: { + get vault() { + return onVaultRead(); + }, + }, + } as unknown as GlobalState; +} + +/** Events routeDeepLink() broadcast for an already-mounted /scan-qr. */ +let dispatched: string[]; beforeEach(() => { vi.stubGlobal("sessionStorage", new MemoryStorage()); + vi.mocked(goto).mockClear(); + dispatched = []; + // The node environment has no DOM; routeDeepLink() notifies a mounted + // /scan-qr through window and reads the current path off window.location. + vi.stubGlobal("window", { + dispatchEvent: (event: Event) => dispatched.push(event.type), + location: { pathname: "/" }, + }); + vi.stubGlobal( + "CustomEvent", + class { + type: string; + detail: unknown; + constructor(type: string, init?: { detail?: unknown }) { + this.type = type; + this.detail = init?.detail; + } + }, + ); session = new SessionController(); + globalState = makeGlobalState(session); }); const PAYLOAD = { @@ -46,23 +92,30 @@ const PAYLOAD = { }; /** - * The layout's routing decision, mirrored from routeDeepLink() in - * routes/+layout.svelte. Returns where the layout sends the user, or null when - * it parks the payload and routes nothing. + * The layout's half of the rendezvous, calling the shipped routeDeepLink(). + * Returns where the layout sent the user, or null when it parked the payload + * and routed nothing. */ -function layoutRouteDeepLink(): "/scan-qr" | null { - storeDeepLink(PAYLOAD); - if (!session.isAuthenticated) return null; - return "/scan-qr"; +function layoutRouteDeepLink(): string | null { + vi.mocked(goto).mockClear(); + routeDeepLink(globalState, PAYLOAD); + return destinationFromGoto(); } /** - * The tail of continueAfterSuccessfulAuth(), which every authentication path + * The shipped continueAfterSuccessfulAuth(), which every authentication path * (biometric on the splash, PIN on /login) funnels through. */ -function completeAuthentication(): "/scan-qr" | "/main" { - session.markAuthenticated(); - return hasDeepLink() ? "/scan-qr" : "/main"; +async function completeAuthentication(): Promise { + vi.mocked(goto).mockClear(); + await continueAfterSuccessfulAuth(globalState); + return destinationFromGoto(); +} + +/** Where the code under test navigated, if it navigated at all. */ +function destinationFromGoto(): string | null { + const calls = vi.mocked(goto).mock.calls; + return calls.length ? String(calls[calls.length - 1][0]) : null; } /** What /scan-qr finds on mount: a payload to consent to, or nothing. */ @@ -79,8 +132,8 @@ describe("deep-link login rendezvous", () => { * "logged out": the payload was parked for a screen that had already * finished and the user was dropped on /main. */ - it("routes to consent when authentication WINS the race", () => { - const authDestination = completeAuthentication(); + it("routes to consent when authentication WINS the race", async () => { + const authDestination = await completeAuthentication(); expect(authDestination).toBe("/main"); const layoutDestination = layoutRouteDeepLink(); @@ -94,42 +147,42 @@ describe("deep-link login rendezvous", () => { * still on the sensor. The layout parks it and routes nothing, then the * authentication path collects it. */ - it("routes to consent when the deep link WINS the race", () => { + it("routes to consent when the deep link WINS the race", async () => { const layoutDestination = layoutRouteDeepLink(); expect(layoutDestination).toBeNull(); - const authDestination = completeAuthentication(); + const authDestination = await completeAuthentication(); expect(authDestination).toBe("/scan-qr"); expect(scanQrSeesPayload()).toBe(true); }); - it("sends a plain launch to /main, with no payload to consent to", () => { - expect(completeAuthentication()).toBe("/main"); + it("sends a plain launch to /main, with no payload to consent to", async () => { + expect(await completeAuthentication()).toBe("/main"); expect(scanQrSeesPayload()).toBe(false); }); - it("routes an already-authenticated user straight to consent", () => { - completeAuthentication(); + it("routes an already-authenticated user straight to consent", async () => { + await completeAuthentication(); expect(layoutRouteDeepLink()).toBe("/scan-qr"); expect(scanQrSeesPayload()).toBe(true); }); - it("keeps the payload readable until the consent screen clears it", () => { + it("keeps the payload readable until the consent screen clears it", async () => { layoutRouteDeepLink(); - completeAuthentication(); + await completeAuthentication(); expect(scanQrSeesPayload()).toBe(true); clearDeepLink(); expect(scanQrSeesPayload()).toBe(false); }); - it("does not resurrect a payload the consent screen already consumed", () => { + it("does not resurrect a payload the consent screen already consumed", async () => { layoutRouteDeepLink(); - expect(completeAuthentication()).toBe("/scan-qr"); + expect(await completeAuthentication()).toBe("/scan-qr"); clearDeepLink(); - expect(completeAuthentication()).toBe("/main"); + expect(await completeAuthentication()).toBe("/main"); }); /** @@ -137,9 +190,9 @@ describe("deep-link login rendezvous", () => { * `session` per offer, so the retry URL is byte-identical; nothing here * may treat a repeat as permanently spent. */ - it("lets the same URL be presented again after it was dismissed", () => { + it("lets the same URL be presented again after it was dismissed", async () => { layoutRouteDeepLink(); - completeAuthentication(); + await completeAuthentication(); clearDeepLink(); expect(layoutRouteDeepLink()).toBe("/scan-qr"); @@ -152,7 +205,7 @@ describe("deep-link login rendezvous", () => { * user is authenticated and the next deep link would skip the gate. */ it("forgets authentication on logout so the next link re-prompts", async () => { - completeAuthentication(); + await completeAuthentication(); expect(session.isAuthenticated).toBe(true); // What GlobalState.reset() does on logout. @@ -172,8 +225,8 @@ describe("deep-link login rendezvous", () => { * A fresh SessionController reading the same sessionStorage is exactly * what a rebuilt webview sees. An in-memory field would fail this. */ - it("keeps the user authenticated across a webview rebuild", () => { - completeAuthentication(); + it("keeps the user authenticated across a webview rebuild", async () => { + await completeAuthentication(); layoutRouteDeepLink(); const rebuilt = new SessionController(); @@ -182,11 +235,32 @@ describe("deep-link login rendezvous", () => { expect(hasDeepLink()).toBe(true); }); - it("survives storage being unavailable without throwing", () => { + /** + * continueAfterSuccessfulAuth() awaits the vault before it routes. A deep + * link delivered inside that window must find the user already through the + * gate, which is why markAuthenticated() runs before the first await. + * Marking it afterwards puts the layout back to reading a stale "logged + * out" and parking the payload for a screen that has already finished. + */ + it("is authenticated for a link arriving mid-login, before routing", async () => { + let seenByLayout: string | null = "never ran"; + globalState = makeGlobalState(session, async () => { + // The deep link lands while the post-login chores are in flight. + seenByLayout = layoutRouteDeepLink(); + throw new Error("no vault in tests"); + }); + + const authDestination = await completeAuthentication(); + + expect(seenByLayout).toBe("/scan-qr"); + expect(authDestination).toBe("/scan-qr"); + }); + + it("survives storage being unavailable without throwing", async () => { vi.stubGlobal("sessionStorage", undefined); expect(() => layoutRouteDeepLink()).not.toThrow(); - expect(() => completeAuthentication()).not.toThrow(); + await expect(completeAuthentication()).resolves.not.toThrow(); expect(peekDeepLink()).toBeNull(); }); }); diff --git a/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts b/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts new file mode 100644 index 000000000..9508ba616 --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts @@ -0,0 +1,41 @@ +import { goto } from "$app/navigation"; +import type { GlobalState } from "$lib/global"; +import { storeDeepLink } from "$lib/stores/deepLink"; + +/** + * Route a parsed deep-link payload: the layout's half of the rendezvous. + * + * The payload is stored whatever the authentication state, so that whichever + * side of the race finishes second can pick it up. Only an authenticated user + * is routed onward; otherwise the payload is parked for + * continueAfterSuccessfulAuth() to collect. + * + * See docs/architecture/deepLink.md. + */ +export function routeDeepLink( + gs: GlobalState | undefined, + deepLinkData: Record, +): void { + // Store it either way: the payload is the same regardless of who + // ends up routing it. + storeDeepLink(deepLinkData); + + if (!gs?.sessionController.isAuthenticated) { + console.log("Deep link stored: user has not authenticated yet"); + return; + } + + console.log("Deep link routed: user is already authenticated"); + + // The event covers an already-mounted /scan-qr; the stored payload + // covers the mount that the goto() below triggers. + window.dispatchEvent( + new CustomEvent("deepLinkReceived", { detail: deepLinkData }), + ); + + if (window.location.pathname !== "/scan-qr") { + goto("/scan-qr").catch((error) => { + console.error("Error navigating to scan-qr:", error); + }); + } +} diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index 67252ed4f..eb654df56 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import { storeDeepLink } from "$lib/stores/deepLink"; import { runtime } from "$lib/global/runtime.svelte"; import { swipedetect } from "$lib/utils"; +import { routeDeepLink } from "$lib/utils/routeDeepLink"; import { installTerminalConsoleBridge } from "$lib/utils/terminalConsole"; import { type Status, checkStatus } from "@tauri-apps/plugin-biometric"; @@ -183,35 +184,6 @@ onMount(async () => { console.error("Failed to initialize deep link listener:", error); } - /** - * Route a parsed deep-link payload: the layout's half of the rendezvous. - * See docs/architecture/deepLink.md. - */ - function routeDeepLink(deepLinkData: Record) { - // Store it either way: the payload is the same regardless of who - // ends up routing it. - storeDeepLink(deepLinkData); - - if (!globalState?.sessionController.isAuthenticated) { - console.log("Deep link stored: user has not authenticated yet"); - return; - } - - console.log("Deep link routed: user is already authenticated"); - - // The event covers an already-mounted /scan-qr; the stored payload - // covers the mount that the goto() below triggers. - window.dispatchEvent( - new CustomEvent("deepLinkReceived", { detail: deepLinkData }), - ); - - if (window.location.pathname !== "/scan-qr") { - goto("/scan-qr").catch((error) => { - console.error("Error navigating to scan-qr:", error); - }); - } - } - function handleDeepLink(urlString: string) { console.log("Deep link received:", urlString); @@ -259,7 +231,7 @@ onMount(async () => { redirect: redirect, }; - routeDeepLink(deepLinkData); + routeDeepLink(globalState, deepLinkData); } else { console.log("Missing required auth parameters"); } @@ -287,7 +259,7 @@ onMount(async () => { redirect_uri: redirectUri, }; - routeDeepLink(deepLinkData); + routeDeepLink(globalState, deepLinkData); } else { console.log("Missing required signing parameters"); } @@ -304,7 +276,7 @@ onMount(async () => { pollId: pollId, }; - routeDeepLink(deepLinkData); + routeDeepLink(globalState, deepLinkData); } else { console.log("Missing required reveal parameters"); } From 7002676e69359b808b8e0bd16f5487a9d8fc9980 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 16:56:55 +0530 Subject: [PATCH 13/24] docs(eid-wallet): correct four comments that outlived the code Each described a shape the code had before a later commit changed it: - deepLink.md said reset() clears both keys. It clears the sign-in flag via SessionController.clear(); performLogout() clears the payload. - session.ts said the controller "takes the Store like its siblings". It takes no arguments and reads sessionStorage directly, which is the point the rest of that comment argues for. - deepLink.spec.ts named resetDeepLinkAuthSession(), removed when session handling moved onto SessionController. - postLogin.ts said /login runs a fallback biometric prompt. The splash is the only biometric prompt now. Comments only; no behaviour change. --- .../eid-wallet/docs/architecture/deepLink.md | 11 ++++++----- .../eid-wallet/src/lib/global/controllers/session.ts | 4 ++-- .../eid-wallet/src/lib/stores/deepLink.spec.ts | 4 ++-- infrastructure/eid-wallet/src/lib/utils/postLogin.ts | 5 ++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/infrastructure/eid-wallet/docs/architecture/deepLink.md b/infrastructure/eid-wallet/docs/architecture/deepLink.md index 1886fb202..c1c1c45a8 100644 --- a/infrastructure/eid-wallet/docs/architecture/deepLink.md +++ b/infrastructure/eid-wallet/docs/architecture/deepLink.md @@ -118,11 +118,12 @@ deep-link callbacks where a throw is invisible to the user and strands the flow. ## Logout -`GlobalState.reset()` clears both keys. This is required, not defensive: logout -does `goto("/")`, an SPA navigation that leaves `sessionStorage` intact. Without -it the session would keep claiming the user is authenticated, and the next deep -link would route straight to the consent screen on the strength of a login that -had already ended. +`GlobalState.reset()` clears the sign-in flag, via `SessionController.clear()`; +`performLogout()` clears the pending payload alongside it. This is required, not +defensive: logout does `goto("/")`, an SPA navigation that leaves +`sessionStorage` intact. Without it the session would keep claiming the user is +authenticated, and the next deep link would route straight to the consent screen +on the strength of a login that had already ended. ## The splash is the only biometric prompt diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/session.ts b/infrastructure/eid-wallet/src/lib/global/controllers/session.ts index 3d675afc7..19104c621 100644 --- a/infrastructure/eid-wallet/src/lib/global/controllers/session.ts +++ b/infrastructure/eid-wallet/src/lib/global/controllers/session.ts @@ -18,8 +18,8 @@ * one, so an in-memory field would strand the user. * * sessionStorage is exactly that lifetime: dies with the tab/app, survives a - * reload. Hence a controller that takes the Store like its siblings but - * deliberately does not use it. + * reload. Hence a controller that, unlike its siblings, takes no Store and + * reads sessionStorage directly. * * See docs/architecture/deepLink.md. */ diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index 1a517b69a..a3b203a52 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -201,8 +201,8 @@ describe("deep-link login rendezvous", () => { /** * Logout does an SPA navigation to "/", which leaves sessionStorage - * intact. Without resetDeepLinkAuthSession() the session would keep claiming the - * user is authenticated and the next deep link would skip the gate. + * intact. Without SessionController.clear() the session would keep claiming + * the user is authenticated and the next deep link would skip the gate. */ it("forgets authentication on logout so the next link re-prompts", async () => { await completeAuthentication(); diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index 6c82f3ce5..dc3ccd5a3 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -8,9 +8,8 @@ import { hasDeepLink } from "$lib/stores/deepLink"; * either to the deep-link target waiting in sessionStorage or to /main. * * Called from both the splash (when biometric auth succeeds over the - * splash screen) and from /login (after PIN or fallback biometric). - * Keeping the logic here means we don't have to flash the user through - * /login on biometric success. + * splash screen) and from /login (after PIN entry). Keeping the logic here + * means we don't have to flash the user through /login on biometric success. */ export async function continueAfterSuccessfulAuth( gs: GlobalState, From 98ce3cc395e247bcae9a05e06f6077ae4aba9c9c Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 17:21:22 +0530 Subject: [PATCH 14/24] fix(eid-wallet): stop the layout dispatching the event it listens for globalDeepLinkHandler is registered for deepLinkReceived, and on the /scan-qr branch it dispatched a new deepLinkReceived. dispatchEvent is synchronous, so the handler re-entered itself and recursed until the stack overflowed, roughly 3270 frames in. The surrounding try/catch swallowed the RangeError, so it failed silently while /scan-qr's own handler ran thousands of times. The re-dispatch was never needed: scanLogic.ts registers its own deepLinkReceived listener, so an already-mounted /scan-qr receives the original event directly. Both are on the same window and event name, and grep confirms these are the only two listeners. It only triggered when a second deep link arrived while the consent screen was already open, which is why it survived since #337. Extract the handler as handleDeepLinkEvent() so it can be tested: it now stores the payload in every case and navigates only when /scan-qr is not the current route. Storing on the /scan-qr branch also covers a route that is mid-navigation and has not mounted its listener yet. Mutation-tested: restoring the self-dispatch fails the new test with the RangeError itself, not a proxy for it. --- .../src/lib/stores/deepLink.spec.ts | 61 ++++++++++++++++++- .../eid-wallet/src/lib/utils/routeDeepLink.ts | 31 ++++++++++ .../eid-wallet/src/routes/+layout.svelte | 36 ++--------- 3 files changed, 95 insertions(+), 33 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index a3b203a52..15906211b 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -4,7 +4,7 @@ import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; import { SessionController } from "$lib/global/controllers/session"; import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; -import { routeDeepLink } from "$lib/utils/routeDeepLink"; +import { handleDeepLinkEvent, routeDeepLink } from "$lib/utils/routeDeepLink"; import { clearDeepLink, hasDeepLink, peekDeepLink } from "./deepLink"; vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) })); @@ -58,15 +58,26 @@ function makeGlobalState( /** Events routeDeepLink() broadcast for an already-mounted /scan-qr. */ let dispatched: string[]; +/** Listeners registered on the stubbed window, as the real one would hold. */ +let listeners: ((event: Event) => void)[]; +/** Stands in for goto() in handleDeepLinkEvent. */ +let navigate: ReturnType; beforeEach(() => { vi.stubGlobal("sessionStorage", new MemoryStorage()); vi.mocked(goto).mockClear(); dispatched = []; + listeners = []; + navigate = vi.fn(async () => {}); // The node environment has no DOM; routeDeepLink() notifies a mounted // /scan-qr through window and reads the current path off window.location. vi.stubGlobal("window", { - dispatchEvent: (event: Event) => dispatched.push(event.type), + // Record the event, then deliver it to every listener, so a handler + // that dispatches the event it listens for really does re-enter. + dispatchEvent: (event: Event) => { + dispatched.push(event.type); + for (const listener of listeners) listener(event); + }, location: { pathname: "/" }, }); vi.stubGlobal( @@ -256,6 +267,52 @@ describe("deep-link login rendezvous", () => { expect(authDestination).toBe("/scan-qr"); }); + /** + * The layout's deepLinkReceived handler is itself registered for + * deepLinkReceived. It must never dispatch that event: doing so re-enters + * the handler synchronously and recurses until the stack overflows. When + * /scan-qr is the current route it has already received the original event + * through its own listener, so there is nothing left to deliver. + */ + it("does not re-dispatch the event it is itself listening for", () => { + window.location.pathname = "/scan-qr"; + + // Wire the handler up exactly as the layout does. + const handler = (event: Event) => + handleDeepLinkEvent((event as CustomEvent).detail, true, navigate); + listeners.push(handler); + + expect(() => + window.dispatchEvent( + new CustomEvent("deepLinkReceived", { detail: PAYLOAD }), + ), + ).not.toThrow(); + + // One delivery, not thousands, and no navigation to a route we are on. + expect(dispatched).toEqual(["deepLinkReceived"]); + expect(navigate).not.toHaveBeenCalled(); + // The payload is still there for the mount that may not have listened. + expect(scanQrSeesPayload()).toBe(true); + }); + + it("stores and navigates when the consent screen is not open", () => { + window.location.pathname = "/main"; + + handleDeepLinkEvent(PAYLOAD, true, navigate); + + expect(navigate).toHaveBeenCalledWith("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + it("parks the payload when the app has not finished starting", () => { + window.location.pathname = "/"; + + handleDeepLinkEvent(PAYLOAD, false, navigate); + + expect(navigate).not.toHaveBeenCalled(); + expect(scanQrSeesPayload()).toBe(true); + }); + it("survives storage being unavailable without throwing", async () => { vi.stubGlobal("sessionStorage", undefined); diff --git a/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts b/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts index 9508ba616..2ce72d70d 100644 --- a/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts +++ b/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts @@ -39,3 +39,34 @@ export function routeDeepLink( }); } } + +/** + * Handle a `deepLinkReceived` event in the root layout: the payload has + * already been parsed, and may have arrived while the app was still starting. + * + * `/scan-qr` registers its own `deepLinkReceived` listener, so when it is the + * current route it has already received this same event directly. This + * function must therefore never re-dispatch `deepLinkReceived`: the layout + * listens for that event itself, so a re-dispatch re-enters this handler + * synchronously and recurses until the stack overflows. + * + * The payload is stored in every case, which covers both a page that has not + * mounted its listener yet and one that is about to be navigated to. + */ +export function handleDeepLinkEvent( + detail: unknown, + isReady: boolean, + navigate: (path: string) => Promise = goto, +): void { + storeDeepLink(detail); + + // Not ready, or already where the payload is consented to: the mount reads + // the stored payload, so there is nothing left to do. + if (!isReady || window.location.pathname === "/scan-qr") { + return; + } + + navigate("/scan-qr").catch((error) => { + console.error("Error navigating to scan-qr:", error); + }); +} diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index eb654df56..45f892de4 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -5,11 +5,10 @@ import "../app.css"; import { beforeNavigate, goto, onNavigate, preloadCode } from "$app/navigation"; import { page } from "$app/state"; import { GlobalState } from "$lib/global/state"; -import { storeDeepLink } from "$lib/stores/deepLink"; import { runtime } from "$lib/global/runtime.svelte"; import { swipedetect } from "$lib/utils"; -import { routeDeepLink } from "$lib/utils/routeDeepLink"; +import { handleDeepLinkEvent, routeDeepLink } from "$lib/utils/routeDeepLink"; import { installTerminalConsoleBridge } from "$lib/utils/terminalConsole"; import { type Status, checkStatus } from "@tauri-apps/plugin-biometric"; @@ -145,35 +144,10 @@ onMount(async () => { "Global deep link event received:", customEvent.detail, ); - - if (!isAppReady || !globalState) { - console.log( - "App not ready, storing deep link data for later", - ); - storeDeepLink(customEvent.detail); - return; - } - - // Check if we're already on the scan page - if (window.location.pathname === "/scan-qr") { - // We're already on the scan page, dispatch the event directly - console.log( - "Already on scan page, dispatching event directly", - ); - const directEvent = new CustomEvent("deepLinkReceived", { - detail: customEvent.detail, - }); - window.dispatchEvent(directEvent); - } else { - // Store the deep link data and navigate to scan page - console.log( - "Not on scan page, storing data and navigating", - ); - storeDeepLink(customEvent.detail); - goto("/scan-qr").catch((error) => { - console.error("Error navigating to scan-qr:", error); - }); - } + handleDeepLinkEvent( + customEvent.detail, + isAppReady && !!globalState, + ); } catch (error) { console.error("Error in globalDeepLinkHandler:", error); } From 032bcea1b5e346367144e70cc0cd190804d4c619 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 17 Sep 2026 17:30:35 +0530 Subject: [PATCH 15/24] fix(eid-wallet): treat finishing onboarding as being signed in A deep link that arrived during onboarding did nothing: the user landed on /main and the consent screen only surfaced later, whenever something next mounted /scan-qr. A regression from this branch. The old gate asked whether a vault existed. Onboarding persists the vault immediately before routing, so that check passed. The rendezvous gate asks whether the user signed in this session, which only the splash and /login recorded, so a user who had just created their identity was classified as logged out. That is the same mistake the branch set out to fix, asking a question the cold-start state cannot answer, in a new place. Creating or restoring an identity is proving it. Add completeOnboarding() beside continueAfterSuccessfulAuth(), marking the session and honouring a waiting deep link the same way, and route all four onboarding and recovery exits through it. Those exits each repeated the same trio of calls; isOnboardingComplete now has one writer. Mutation-tested: dropping markAuthenticated() from the helper, or making it ignore a waiting payload, each fail the new tests. --- .../src/lib/stores/deepLink.spec.ts | 41 ++++++++++++++++++- .../eid-wallet/src/lib/utils/postLogin.ts | 24 +++++++++++ .../src/routes/(auth)/onboarding/+page.svelte | 14 +++---- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index 15906211b..b126ac884 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; import { SessionController } from "$lib/global/controllers/session"; -import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; +import { + completeOnboarding, + continueAfterSuccessfulAuth, +} from "$lib/utils/postLogin"; import { handleDeepLinkEvent, routeDeepLink } from "$lib/utils/routeDeepLink"; import { clearDeepLink, hasDeepLink, peekDeepLink } from "./deepLink"; @@ -47,6 +50,7 @@ function makeGlobalState( }, ): GlobalState { return { + isOnboardingComplete: false, sessionController: session, vaultController: { get vault() { @@ -313,6 +317,41 @@ describe("deep-link login rendezvous", () => { expect(scanQrSeesPayload()).toBe(true); }); + /** + * A user who has just onboarded or recovered has never passed through the + * splash or /login, so nothing else marks them signed in. Creating or + * restoring an identity IS proving it, and a deep link that arrived during + * onboarding has to be actionable the moment they land; otherwise the + * layout parks it and the consent screen ambushes them later, whenever + * something next mounts /scan-qr. + */ + it("treats finishing onboarding as being signed in", async () => { + layoutRouteDeepLink(); + expect(session.isAuthenticated).toBe(false); + + vi.mocked(goto).mockClear(); + await completeOnboarding(globalState); + + expect(session.isAuthenticated).toBe(true); + expect(destinationFromGoto()).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + it("sends a plain onboarding finish to /main", async () => { + vi.mocked(goto).mockClear(); + await completeOnboarding(globalState); + + expect(destinationFromGoto()).toBe("/main"); + expect(globalState.isOnboardingComplete).toBe(true); + }); + + /** A link arriving after onboarding must route immediately, not be parked. */ + it("routes a link that arrives just after onboarding", async () => { + await completeOnboarding(globalState); + + expect(layoutRouteDeepLink()).toBe("/scan-qr"); + }); + it("survives storage being unavailable without throwing", async () => { vi.stubGlobal("sessionStorage", undefined); diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index dc3ccd5a3..a8fe5e01a 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -65,3 +65,27 @@ export async function continueAfterSuccessfulAuth( await goto("/main"); } + +/** + * Finish onboarding or recovery: the user has just proved their identity by + * creating or restoring it, so they are signed in for this session. + * + * Marking the session is what lets a deep link that arrived during onboarding + * be acted on straight away. Without it the layout's gate sees a user who has + * never been through the splash or /login, parks the payload, and the consent + * screen surfaces at some unrelated later moment instead. + * + * Mirrors continueAfterSuccessfulAuth()'s routing so both ways into the app + * honour a waiting deep link. + */ +export async function completeOnboarding(gs: GlobalState): Promise { + gs.isOnboardingComplete = true; + gs.sessionController.markAuthenticated(); + + if (hasDeepLink()) { + await goto("/scan-qr", { replaceState: true }); + return; + } + + await goto("/main", { replaceState: true }); +} diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index 293f9d0ad..65d1d2fd1 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -11,6 +11,7 @@ import { GlobalState } from "$lib/global"; import { pendingRecovery } from "$lib/stores/pendingRecovery"; import { ButtonAction, CopyableEName, LoadingSheet } from "$lib/ui"; import { capitalize, getCanonicalBindingDocString } from "$lib/utils"; +import { completeOnboarding } from "$lib/utils/postLogin"; import axios from "axios"; import { GraphQLClient } from "graphql-request"; import { getContext, onMount, tick } from "svelte"; @@ -264,9 +265,8 @@ const completeRecovery = async () => { ename: recovery.ename, }); pendingRecovery.set(null); - globalState.isOnboardingComplete = true; loadingPhase = null; - await goto("/main", { replaceState: true }); + await completeOnboarding(globalState); } catch (err) { console.error("[onboarding] recovery completion failed:", err); recoveryError = @@ -378,12 +378,10 @@ const handleNameComplete = async (enteredName: string) => { // Persist user + vault, then mark onboarding done. globalState.userController.user = { name: enteredName }; await globalState.vaultController.setVaultAndPersist({ uri, ename }); - globalState.isOnboardingComplete = true; - // Land on /main; the WelcomeTour there draws the animated lines // around the identity card (replaces the old /review + /e-passport). loadingPhase = null; - await goto("/main", { replaceState: true }); + await completeOnboarding(globalState); } catch (err) { console.error("Failed to provision eVault:", err); nameError = @@ -662,9 +660,8 @@ const handleProvision = async () => { uri: result.uri, ename: result.w3id, }); - globalState.isOnboardingComplete = true; loadingPhase = null; - await goto("/main", { replaceState: true }); + await completeOnboarding(globalState); } catch (err) { console.error("Provisioning failed:", err); error = @@ -804,9 +801,8 @@ const handleAnonymousSubmit = async () => { }; await globalState.vaultController.setVaultAndPersist({ uri, ename }); - globalState.isOnboardingComplete = true; loadingPhase = null; - await goto("/main", { replaceState: true }); + await completeOnboarding(globalState); } catch (err) { console.error("Anonymous provisioning failed:", err); error = From 7ea74e93769069330c0cf75a8be7b33f74bcf6d2 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 15 Sep 2026 17:14:53 +0300 Subject: [PATCH 16/24] 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 1360fdd877dad888c77e8234d5c77c9e351f71eb Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 15 Sep 2026 17:16:13 +0300 Subject: [PATCH 17/24] 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 3dc6907c547f6e207faa3d879d4e02482209f4c4 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Thu, 17 Sep 2026 22:11:21 +0300 Subject: [PATCH 18/24] chore(eid-wallet): bump Android versionCode to 31 --- infrastructure/eid-wallet/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/eid-wallet/src-tauri/tauri.conf.json b/infrastructure/eid-wallet/src-tauri/tauri.conf.json index b457ff127..197629bef 100644 --- a/infrastructure/eid-wallet/src-tauri/tauri.conf.json +++ b/infrastructure/eid-wallet/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ "active": true, "targets": "all", "android": { - "versionCode": 30 + "versionCode": 31 }, "icon": [ "icons/32x32.png", From e165d351b656454c311f516b78f885c74e827efe Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Thu, 17 Sep 2026 23:33:04 +0300 Subject: [PATCH 19/24] chore(eid-wallet): set iOS build number to 1.1.1.1 --- .../gen/apple/eid-wallet.xcodeproj/project.pbxproj | 8 ++++---- .../src-tauri/gen/apple/eid-wallet_iOS/Info.plist | 4 ++-- 2 files changed, 6 insertions(+), 6 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 6dea3982b..8aa1c5de3 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.1.1.0; + CURRENT_PROJECT_VERSION = 1.1.1.1; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -416,7 +416,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); MARKETING_VERSION = 1.1.1; - PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; + PRODUCT_BUNDLE_IDENTIFIER = "foundation.metastate.eid-wallet"; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -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.1.1.0; + CURRENT_PROJECT_VERSION = 1.1.1.1; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -464,7 +464,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); MARKETING_VERSION = 1.1.1; - PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; + PRODUCT_BUNDLE_IDENTIFIER = "foundation.metastate.eid-wallet"; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 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 74f93a253..65eb46a89 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 @@ -28,7 +28,7 @@ CFBundleVersion - 1.1.1.0 + 1.1.1.1 LSRequiresIPhoneOS NSAppTransportSecurity @@ -61,4 +61,4 @@ UIInterfaceOrientationLandscapeRight - \ No newline at end of file + From e1a55ffb066fc48e57437cba64a6011093571aaf Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Mon, 21 Sep 2026 21:27:04 +0530 Subject: [PATCH 20/24] fix(eid-wallet): guard each sessionStorage operation, not just access The typeof guard in SessionController and deepLink.ts only proves the storage object is reachable. Reaching it can succeed while every operation on it throws: quota exhausted, or a private mode where setItem always throws. Each call site then failed in a way its own comment says it avoids. markAuthenticated() is called from the splash inside the try that wraps the biometric prompt, whose catch means "biometrics failed" and routes to /login. A throw there sent a user who had just authenticated back to the PIN screen. isAuthenticated backs the layout's deep-link gate, where a throw drops the payload. clear() runs inside the single try block in GlobalState.reset(), so a throw abandoned the rest of logout. storeDeepLink/peekDeepLink/clearDeepLink had the same gap: the first two run inside the deep-link callback where the file's own comment says a throw is invisible to the user, and clearDeepLink runs in performLogout outside any try, ahead of the navigation that ends the session. Wrap each operation and keep the documented fallback: writes are best-effort, reads degrade to "nothing stored", deletes never reject. Mutation-tested: removing any one of the six guards fails the suite. --- .../src/lib/global/controllers/session.ts | 40 ++++++++++++--- .../src/lib/stores/deepLink.spec.ts | 51 +++++++++++++++++++ .../eid-wallet/src/lib/stores/deepLink.ts | 24 +++++++-- 3 files changed, 106 insertions(+), 9 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/session.ts b/infrastructure/eid-wallet/src/lib/global/controllers/session.ts index 19104c621..f21cb7910 100644 --- a/infrastructure/eid-wallet/src/lib/global/controllers/session.ts +++ b/infrastructure/eid-wallet/src/lib/global/controllers/session.ts @@ -47,14 +47,35 @@ export class SessionController { * routes itself rather than storing a payload nobody is left to collect. */ markAuthenticated(): void { - this.#storage()?.setItem(SessionController.#AUTHENTICATED_KEY, "true"); + try { + this.#storage()?.setItem( + SessionController.#AUTHENTICATED_KEY, + "true", + ); + } catch (error) { + // Reaching the storage object can succeed while writing to it + // fails: quota exhausted, or Safari-style private mode where + // setItem always throws. The caller has already authenticated, and + // its catch treats a throw as failed authentication, so letting + // this escape would bounce a signed-in user back to the PIN screen. + console.warn("Could not persist the session marker:", error); + } } get isAuthenticated(): boolean { - return ( - this.#storage()?.getItem(SessionController.#AUTHENTICATED_KEY) === - "true" - ); + try { + return ( + this.#storage()?.getItem( + SessionController.#AUTHENTICATED_KEY, + ) === "true" + ); + } catch (error) { + // Read failures fall back to "not authenticated", which costs the + // user a prompt. The alternative is throwing inside the layout's + // deep-link gate, which would drop the payload entirely. + console.warn("Could not read the session marker:", error); + return false; + } } /** @@ -67,6 +88,13 @@ export class SessionController { * has already ended. */ async clear(): Promise { - this.#storage()?.removeItem(SessionController.#AUTHENTICATED_KEY); + try { + this.#storage()?.removeItem(SessionController.#AUTHENTICATED_KEY); + } catch (error) { + // GlobalState.reset() runs every controller's clear() in one try + // block, so rejecting here would skip the rest of logout and leave + // the vault and keys behind. + console.warn("Could not clear the session marker:", error); + } } } diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index b126ac884..d1a5710ac 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -352,6 +352,57 @@ describe("deep-link login rendezvous", () => { expect(layoutRouteDeepLink()).toBe("/scan-qr"); }); + /** + * sessionStorage can be reachable but refuse every operation: quota + * exhausted, or a private mode where setItem always throws. The typeof + * guard cannot see that, so each operation carries its own fallback. + * + * The cost of a failed write is one extra prompt. The cost of letting it + * escape is worse at every call site: the splash treats a throw as failed + * biometrics and bounces an authenticated user to /login, and + * GlobalState.reset() runs every clear() in one try block, so a throw + * there would abandon the rest of logout. + */ + describe("when storage rejects every operation", () => { + beforeEach(() => { + const throwing = new MemoryStorage(); + const boom = () => { + throw new DOMException("QuotaExceededError"); + }; + throwing.setItem = boom; + throwing.getItem = boom; + throwing.removeItem = boom; + vi.stubGlobal("sessionStorage", throwing); + session = new SessionController(); + globalState = makeGlobalState(session); + }); + + it("still completes authentication when the write fails", async () => { + expect(() => session.markAuthenticated()).not.toThrow(); + }); + + it("reports not-authenticated when the read fails", () => { + expect(session.isAuthenticated).toBe(false); + }); + + it("does not reject logout when the delete fails", async () => { + await expect(session.clear()).resolves.toBeUndefined(); + }); + + /** + * performLogout() calls clearDeepLink() between reset() and the + * goto("/") that ends the session, outside any try block. + */ + it("does not stop logout when the payload delete fails", () => { + expect(() => clearDeepLink()).not.toThrow(); + }); + + it("keeps the deep-link gate from throwing at the caller", async () => { + expect(() => layoutRouteDeepLink()).not.toThrow(); + await expect(completeAuthentication()).resolves.not.toThrow(); + }); + }); + it("survives storage being unavailable without throwing", async () => { vi.stubGlobal("sessionStorage", undefined); diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts index 50cec063a..6d1d2cd17 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -22,12 +22,24 @@ function store(): Storage | null { /** Store an incoming deep-link payload, whatever the authentication state. */ export function storeDeepLink(data: unknown): void { - store()?.setItem(PAYLOAD_KEY, JSON.stringify(data)); + try { + store()?.setItem(PAYLOAD_KEY, JSON.stringify(data)); + } catch (error) { + // Reaching storage can succeed while writing to it fails. This runs + // inside the deep-link callback the comment above describes, so a + // throw here is invisible and strands the flow. + console.warn("Could not store the deep-link payload:", error); + } } /** The payload the consent screen should render, if any. */ export function peekDeepLink(): string | null { - return store()?.getItem(PAYLOAD_KEY) ?? null; + try { + return store()?.getItem(PAYLOAD_KEY) ?? null; + } catch (error) { + console.warn("Could not read the deep-link payload:", error); + return null; + } } /** Is there a deep link waiting to be consented to? */ @@ -37,5 +49,11 @@ export function hasDeepLink(): boolean { /** Clear the payload once the consent screen has shown it, or on logout. */ export function clearDeepLink(): void { - store()?.removeItem(PAYLOAD_KEY); + try { + store()?.removeItem(PAYLOAD_KEY); + } catch (error) { + // Called from performLogout() alongside GlobalState.reset(); a throw + // here would skip the navigation that ends the session. + console.warn("Could not clear the deep-link payload:", error); + } } From c65536d391c2713279ed0262198a55b26a776440 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Mon, 21 Sep 2026 23:12:51 +0530 Subject: [PATCH 21/24] Do not store a deep-link payload that cannot be serialised JSON.stringify returns undefined for undefined, a function or a symbol. setItem coerces that to the literal string "undefined", so hasDeepLink() reports a payload waiting that the consent screen then fails to parse, routing the user to /scan-qr to look at nothing. A deepLinkReceived event dispatched without a detail reaches storeDeepLink() exactly that way. Skip the write instead, leaving any previously stored payload intact. --- .../src/lib/stores/deepLink.spec.ts | 29 ++++++++++++++++++- .../eid-wallet/src/lib/stores/deepLink.ts | 13 ++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index d1a5710ac..5872a6e0f 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -8,7 +8,12 @@ import { continueAfterSuccessfulAuth, } from "$lib/utils/postLogin"; import { handleDeepLinkEvent, routeDeepLink } from "$lib/utils/routeDeepLink"; -import { clearDeepLink, hasDeepLink, peekDeepLink } from "./deepLink"; +import { + clearDeepLink, + hasDeepLink, + peekDeepLink, + storeDeepLink, +} from "./deepLink"; vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) })); @@ -410,4 +415,26 @@ describe("deep-link login rendezvous", () => { await expect(completeAuthentication()).resolves.not.toThrow(); expect(peekDeepLink()).toBeNull(); }); + + /** + * A `deepLinkReceived` event dispatched with no detail reaches + * storeDeepLink() as undefined, which JSON.stringify serialises to + * undefined rather than to a string. setItem would coerce that to the + * literal "undefined", so hasDeepLink() would announce a waiting payload + * that the consent screen cannot parse, sending the user to /scan-qr to + * look at nothing. + */ + it("does not record an unserialisable payload as a waiting deep link", () => { + storeDeepLink(undefined); + + expect(peekDeepLink()).toBeNull(); + expect(hasDeepLink()).toBe(false); + }); + + it("leaves an already-stored payload intact", () => { + storeDeepLink({ type: "auth" }); + storeDeepLink(undefined); + + expect(peekDeepLink()).toBe(JSON.stringify({ type: "auth" })); + }); }); diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts index 6d1d2cd17..3f69ee1c7 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -23,7 +23,18 @@ function store(): Storage | null { /** Store an incoming deep-link payload, whatever the authentication state. */ export function storeDeepLink(data: unknown): void { try { - store()?.setItem(PAYLOAD_KEY, JSON.stringify(data)); + const serialized = JSON.stringify(data); + + // JSON.stringify returns undefined for undefined, a function or a + // symbol. setItem would coerce that to the string "undefined", which + // hasDeepLink() reports as a waiting payload and the consent screen + // then fails to JSON.parse. Storing nothing is the honest answer. + if (serialized === undefined) { + console.warn("Ignoring a deep-link payload that cannot be stored"); + return; + } + + store()?.setItem(PAYLOAD_KEY, serialized); } catch (error) { // Reaching storage can succeed while writing to it fails. This runs // inside the deep-link callback the comment above describes, so a From 23a010db4a1d1f3f4a690e2d52c204b1c6e02a72 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Mon, 21 Sep 2026 23:24:20 +0530 Subject: [PATCH 22/24] Dispatch the deep-link event only to an already-mounted consent screen The root layout registers its own deepLinkReceived listener, and that handler navigates to /scan-qr. Dispatching the event from routeDeepLink() when the user is somewhere else therefore woke the layout handler as well as running routeDeepLink's own goto(), entering the route twice for a single link. Off /scan-qr the stored payload is what the mount reads, so the event delivers nothing the navigation does not. Dispatch only when /scan-qr is already the current route, where it has a live listener and no mount is coming, and navigate otherwise. --- .../src/lib/stores/deepLink.spec.ts | 30 +++++++++++++++++++ .../eid-wallet/src/lib/utils/routeDeepLink.ts | 17 ++++++----- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts index 5872a6e0f..45d99ce9d 100644 --- a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -408,6 +408,36 @@ describe("deep-link login rendezvous", () => { }); }); + /** + * The layout listens for deepLinkReceived itself, and its handler + * navigates to /scan-qr. So when routeDeepLink() is called from anywhere + * else, dispatching the event wakes that handler in addition to + * routeDeepLink's own goto(), and one deep link enters the route twice. + * Off /scan-qr the stored payload is what the mount reads, so the event + * carries nothing the navigation does not already deliver. + */ + it("navigates once when a link arrives away from the consent screen", () => { + window.location.pathname = "/main"; + session.markAuthenticated(); + + // Wire the layout's handler up exactly as +layout.svelte does. + listeners.push((event: Event) => + handleDeepLinkEvent((event as CustomEvent).detail, true, navigate), + ); + + vi.mocked(goto).mockClear(); + routeDeepLink(globalState, PAYLOAD); + + // One navigation total, across both the direct goto and the handler + // the dispatch would have woken. + expect( + vi.mocked(goto).mock.calls.length + navigate.mock.calls.length, + ).toBe(1); + expect(destinationFromGoto()).toBe("/scan-qr"); + // The mount still has the payload waiting for it. + expect(scanQrSeesPayload()).toBe(true); + }); + it("survives storage being unavailable without throwing", async () => { vi.stubGlobal("sessionStorage", undefined); diff --git a/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts b/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts index 2ce72d70d..a6e8b2d0f 100644 --- a/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts +++ b/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts @@ -27,13 +27,16 @@ export function routeDeepLink( console.log("Deep link routed: user is already authenticated"); - // The event covers an already-mounted /scan-qr; the stored payload - // covers the mount that the goto() below triggers. - window.dispatchEvent( - new CustomEvent("deepLinkReceived", { detail: deepLinkData }), - ); - - if (window.location.pathname !== "/scan-qr") { + // Only an already-mounted /scan-qr needs the event: it has its own + // listener and no mount is coming. Anywhere else the stored payload is + // what the mount reads, and dispatching would additionally wake the + // layout's own deepLinkReceived handler, which navigates too, so the + // route would be entered twice for one link. + if (window.location.pathname === "/scan-qr") { + window.dispatchEvent( + new CustomEvent("deepLinkReceived", { detail: deepLinkData }), + ); + } else { goto("/scan-qr").catch((error) => { console.error("Error navigating to scan-qr:", error); }); From 8dd6f131dd34bcbccc367ab2b2aa086a2d5b922a Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 22 Sep 2026 08:40:57 +0300 Subject: [PATCH 23/24] chore(eid-wallet): sync Cargo and iOS spec versions to 1.1.1 The 1.1.1 bump updated package.json and tauri.conf.json but left the Rust crate and the xcodegen spec on 1.0.1. Cosmetic for the built artifacts, which take their version from tauri.conf.json, but the spec would revert the iOS bundle version if the project were regenerated. --- infrastructure/eid-wallet/src-tauri/Cargo.lock | 2 +- infrastructure/eid-wallet/src-tauri/Cargo.toml | 2 +- infrastructure/eid-wallet/src-tauri/gen/apple/project.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/infrastructure/eid-wallet/src-tauri/Cargo.lock b/infrastructure/eid-wallet/src-tauri/Cargo.lock index a507b4f94..0ab44311f 100644 --- a/infrastructure/eid-wallet/src-tauri/Cargo.lock +++ b/infrastructure/eid-wallet/src-tauri/Cargo.lock @@ -948,7 +948,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "eid-wallet" -version = "1.0.1" +version = "1.1.1" dependencies = [ "argon2", "rand_core 0.6.4", diff --git a/infrastructure/eid-wallet/src-tauri/Cargo.toml b/infrastructure/eid-wallet/src-tauri/Cargo.toml index 9e2e54058..ad02aaf23 100644 --- a/infrastructure/eid-wallet/src-tauri/Cargo.toml +++ b/infrastructure/eid-wallet/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eid-wallet" -version = "1.0.1" +version = "1.1.1" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml b/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml index 08b82d5ff..347be53a8 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml @@ -51,8 +51,8 @@ targets: - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - CFBundleShortVersionString: 1.0.1 - CFBundleVersion: 1.0.1 + CFBundleShortVersionString: 1.1.1 + CFBundleVersion: 1.1.1.1 entitlements: path: eid-wallet_iOS/eid-wallet_iOS.entitlements scheme: From 6434dd6023ac0524598c054a5dce48a74523aab4 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 22 Sep 2026 08:40:57 +0300 Subject: [PATCH 24/24] fix(eid-wallet): report the real app version instead of hardcoded strings Authentication sent appVersion "0.4.0" from two separate literals, and the Settings header showed "App Version 1.0.0". All three were stale. Auth now reads getVersion() once and uses it on both the POST and deeplink paths. Settings cannot use it: the subtitle is captured synchronously at layout init to avoid a flash mid-transition, and getVersion() is async, so it reads __APP_VERSION__ instead. Both resolve to the tauri.conf.json version, so the value shown and the value sent cannot drift apart. --- infrastructure/eid-wallet/src/env.d.ts | 3 +++ .../eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts | 6 ++++-- .../eid-wallet/src/routes/(app)/settings/+layout.svelte | 2 +- infrastructure/eid-wallet/vite.config.js | 8 ++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/infrastructure/eid-wallet/src/env.d.ts b/infrastructure/eid-wallet/src/env.d.ts index c5ef3788d..e1a1f7b55 100644 --- a/infrastructure/eid-wallet/src/env.d.ts +++ b/infrastructure/eid-wallet/src/env.d.ts @@ -10,3 +10,6 @@ declare module "$env/static/public" { export const PUBLIC_PICTIQUE_BASE_URL: string; export const PUBLIC_BLABSY_BASE_URL: string; } + +/** App version from package.json, injected by vite.config.js at build time. */ +declare const __APP_VERSION__: string; 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 ad9b007c7..894ef6b9f 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts @@ -1,4 +1,5 @@ import { clearDeepLink, peekDeepLink } from "$lib/stores/deepLink"; +import { getVersion } from "@tauri-apps/api/app"; import { Format, type PermissionState, @@ -369,6 +370,7 @@ export function createScanLogic({ ); } + const appVersion = await getVersion(); const fromScan = get(isFromScan); if (fromScan) { @@ -377,7 +379,7 @@ export function createScanLogic({ ename: vault.ename, session: get(session) as string, signature: signature, - appVersion: "0.4.0", + appVersion, }; console.log(`📤 Making POST request to: ${redirectUrl}`); @@ -411,7 +413,7 @@ export function createScanLogic({ loginUrl.searchParams.set("ename", vault.ename); loginUrl.searchParams.set("session", get(session) as string); loginUrl.searchParams.set("signature", signature); - loginUrl.searchParams.set("appVersion", "0.4.0"); + loginUrl.searchParams.set("appVersion", appVersion); console.log(`🔗 Opening login URL: ${loginUrl.toString()}`); diff --git a/infrastructure/eid-wallet/src/routes/(app)/settings/+layout.svelte b/infrastructure/eid-wallet/src/routes/(app)/settings/+layout.svelte index b22538476..50767eb08 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/settings/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/settings/+layout.svelte @@ -11,7 +11,7 @@ const { children } = $props(); // captured value through the entire slide-out transition — without this we // were routing subtitle through shared $state and the OLD AppNav would // re-render mid- or post-transition, producing a visible flash. -const VERSION = "1.0.0"; +const VERSION = __APP_VERSION__; const subtitleAtMount = page.url.pathname === "/settings" ? `App Version ${VERSION}` : undefined; diff --git a/infrastructure/eid-wallet/vite.config.js b/infrastructure/eid-wallet/vite.config.js index 094211553..95eb32f57 100644 --- a/infrastructure/eid-wallet/vite.config.js +++ b/infrastructure/eid-wallet/vite.config.js @@ -2,6 +2,13 @@ import { defineConfig } from "vite"; import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; import { nodePolyfills } from "vite-plugin-node-polyfills"; +import { readFileSync } from "node:fs"; + +// Same source getVersion() reports at runtime, so the version shown in +// Settings and the one sent during auth cannot drift apart. +const appVersion = JSON.parse( + readFileSync("./src-tauri/tauri.conf.json", "utf8"), +).version; const host = process.env.TAURI_DEV_HOST; @@ -29,6 +36,7 @@ export default defineConfig(async () => ({ // Environment variables define: { + __APP_VERSION__: JSON.stringify(appVersion), 'process.env.NEXT_PUBLIC_EVOTING_BASE_URL': JSON.stringify(process.env.NEXT_PUBLIC_EVOTING_BASE_URL || 'http://localhost:3001'), 'process.env.NEXT_PUBLIC_EID_WALLET_URL': JSON.stringify(process.env.NEXT_PUBLIC_EID_WALLET_URL || 'w3ds://'), },