fix(idaa): re-verify UUID on SvelteKit navigation, not just full reloads

Root cause: url_uuid was read once from window.location.search (const),
assuming UUID changes always cause a full iframe reload (Novi impersonation).
Manual URL edits within the same SvelteKit session keep the layout mounted,
leaving url_uuid stale — the TTL cache then hit for the OLD valid UUID,
granting access under the wrong identity without re-verifying.

Fix:
- url_uuid is now $derived from $page.url.searchParams, updated on every
  SvelteKit navigation
- url_uuid is read outside untrack() in Effect 2 so UUID changes trigger
  a fresh verification run
- verify_failed (boolean) replaced with verify_failed_for_uuid (string|null)
  so the retry-loop latch is keyed to the specific failed UUID — a different
  UUID in the URL is always a clean slate that gets verified fresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-30 19:43:12 -04:00
parent 525ce1db79
commit d8ce04304b

View File

@@ -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;