diff --git a/src/routes/idaa/(idaa)/+layout.svelte b/src/routes/idaa/(idaa)/+layout.svelte index daca62fc..ed12b434 100644 --- a/src/routes/idaa/(idaa)/+layout.svelte +++ b/src/routes/idaa/(idaa)/+layout.svelte @@ -4,6 +4,7 @@ let log_lvl: number = 0; // *** Import Svelte specific import { browser } from '$app/environment'; +import { page } from '$app/stores'; // *** Import Aether specific variables and functions import { ae_util } from '$lib/ae_utils/ae_utils'; @@ -26,30 +27,38 @@ interface Props { let { data, children }: Props = $props(); -// UUID is set by Novi when loading the iframe — fixed for this page's lifetime. -// Impersonation causes a full iframe reload (new page load), not a SvelteKit navigation, -// so reading this once is correct and avoids reactive noise from client-side navigation. -// NOTE: If Novi ever adds dynamic impersonation (no full reload), this needs revisiting — -// reintroduce $derived.by on data.url and the UUID-change guards removed in this commit. -const url_uuid = browser ? new URLSearchParams(window.location.search).get('uuid') : null; +// Reactive UUID — derived from $page.url so it updates on every SvelteKit client-side +// navigation, not just full page reloads. +// WHY: The original impl read window.location.search once (const), assuming UUID changes +// always cause a full iframe reload (Novi impersonation behavior). But manual URL edits +// in the same SvelteKit session keep the layout mounted and leave url_uuid stale — the +// TTL cache then hits for the OLD valid UUID, granting access under the wrong identity. +// $page.url.searchParams is always current, so any UUID change is caught and re-verified. +const url_uuid = $derived(browser ? ($page.url.searchParams.get('uuid') ?? null) : null); // True while the Novi API call is in flight. // Pre-initialized to true when a UUID is present to prevent an "Access Denied" flash // before the effect has a chance to run on first render. -let novi_verifying: boolean = $state(!!url_uuid); +// WHY direct window.location.search here (not url_uuid derived): $state initializers are +// not reactive — we want a one-time snapshot of "is there a UUID on load?". Using the +// $derived here would only capture its initial value anyway, and Svelte warns about it. +let novi_verifying: boolean = $state( + browser ? !!new URLSearchParams(window.location.search).get('uuid') : false +); // Concurrency guard — separate from novi_verifying (the UI spinner). // Do NOT use novi_verifying as a concurrency guard: it is pre-initialized to true, // which would cause the guard to fire immediately and skip verification entirely. let verify_in_flight = false; -// Failure latch — set to true when verification definitively fails (e.g. Novi returns -// 200 with empty data for a non-existent UUID, or a 4xx response). -// WHY: Without this, writes to $idaa_loc in the catch block trigger svelte-persisted-store -// storage events, which cause $ae_loc to re-notify its subscribers, re-running Effect 2 in -// an infinite loop. The UUID is fixed for this page load (read once from window.location.search), -// so retrying will always produce the same result. User must reload to retry. -let verify_failed = false; +// Failure latch — stores the UUID that definitively failed (Novi 200+empty, 4xx, network error). +// WHY (loop prevention): writes to $idaa_loc in the catch block trigger svelte-persisted-store +// storage events → $ae_loc re-notifies subscribers → Effect 2 re-runs. Without the latch, +// the same failing UUID would be re-verified infinitely. +// WHY (UUID-keyed, not boolean): when the URL UUID changes (SvelteKit navigation), the new +// UUID must be verified fresh. A plain boolean would wrongly block verification of the new UUID. +// Storing the failed UUID means only that exact UUID is skipped; any other UUID is a clean slate. +let verify_failed_for_uuid: string | null = null; // Show a manual reset button if the spinner is still visible after this many ms. // Handles the case where site_cfg_json loads without novi_idaa_api_key (stale cache) @@ -83,10 +92,10 @@ $effect(() => { }); // Effect 2: Novi UUID verification -// The only reactive dependency is $ae_loc.site_cfg_json — the API key arrives async -// via SWR background fetch and may not be populated on first render. Reading it outside -// untrack() ensures the effect re-runs when the config loads. -// The UUID is not reactive (read once above via window.location.search). +// Reactive dependencies (read outside untrack): +// - $ae_loc.site_cfg_json: API key arrives async via SWR; re-run when config loads. +// - url_uuid ($derived from $page.url): re-run when URL UUID changes (SvelteKit navigation). +// Everything else is inside untrack() to prevent reactive loops from store writes. $effect(() => { if (!browser) return; @@ -97,8 +106,12 @@ $effect(() => { const trusted_li: string[] = site_cfg.novi_trusted_li ?? []; const ttl_ms: number = site_cfg.novi_verified_ttl_ms ?? VERIFIED_TTL_MS_DEFAULT; + // Read url_uuid here (outside untrack) to create a reactive dependency. + // Effect 2 re-runs whenever the UUID in the URL changes. + const current_uuid = url_uuid; + untrack(() => { - if (!url_uuid) { + if (!current_uuid) { // No UUID in URL — non-Novi path (user/pass or shared passcode sign-in). $idaa_loc.novi_verified = false; novi_verifying = false; @@ -107,10 +120,10 @@ $effect(() => { if (verify_in_flight) return; - if (verify_failed) { - // Verification definitively failed for this UUID — do not retry. - // See comment on verify_failed declaration for the full explanation. - if (log_lvl) console.log(`IDAA Layout: skipping re-verification — already failed for ${url_uuid}`); + if (verify_failed_for_uuid === current_uuid) { + // This exact UUID already definitively failed — do not retry. + // A different UUID is a clean slate; see verify_failed_for_uuid declaration. + if (log_lvl) console.log(`IDAA Layout: skipping re-verification — already failed for ${current_uuid}`); novi_verifying = false; return; } @@ -120,11 +133,11 @@ $effect(() => { const now = Date.now(); if ( $idaa_loc.novi_verified && - $idaa_loc.novi_uuid === url_uuid && + $idaa_loc.novi_uuid === current_uuid && $idaa_loc.novi_verified_ts && now - $idaa_loc.novi_verified_ts < ttl_ms ) { - if (log_lvl) console.log(`IDAA Layout: cached verification valid for ${url_uuid}`); + if (log_lvl) console.log(`IDAA Layout: cached verification valid for ${current_uuid}`); novi_verifying = false; return; } @@ -145,11 +158,11 @@ $effect(() => { $ae_loc.trusted_access || ($ae_loc.authenticated_access && $idaa_loc.novi_verified && - $idaa_loc.novi_uuid === url_uuid); + $idaa_loc.novi_uuid === current_uuid); if (!has_valid_session) { novi_verifying = true; } - verify_novi_uuid(url_uuid, api_key, api_root); + verify_novi_uuid(current_uuid, api_key, api_root); }); }); @@ -271,7 +284,7 @@ async function verify_novi_uuid( `IDAA Layout: Novi UUID verification failed for ${uuid}:`, error ); - verify_failed = true; // Latch — stop the Effect 2 retry loop. See declaration comment. + verify_failed_for_uuid = uuid; // Latch — stop retry loop for this UUID. See declaration comment. $idaa_loc.novi_uuid = null; $idaa_loc.novi_email = null; $idaa_loc.novi_full_name = null;