Files
OSIT-AE-App-Svelte/src/routes/idaa/(idaa)/+layout.svelte
Scott Idem 19d0145d00 fix(idaa): fix Novi UUID verification — stuck spinner, repeat calls, impersonation
Critical bugs fixed:
- $derived(() => {}) stored the function itself; uuid/api_key were always
  undefined so verification never fired. Fixed to $derived.by(() => {}).
- novi_verifying pre-initialized to true (flash prevention) was also used as
  the concurrency guard — guard saw it as in-flight and exited immediately,
  leaving the spinner stuck forever. Split into separate verify_in_flight flag.
- $idaa_loc reads in dedupe snapshot (outside untrack) subscribed the effect
  to idaa_loc writes, causing needless re-runs post-verification.
- Rate limit was not UUID-aware: 429 on one UUID blocked impersonation
  (new UUID). TTL and rate-limit guards now both bypass when UUID changes.

Also includes: store defaults for novi_verified_ts + novi_rate_limited_until,
docs update, iframe template g_uuid param (prior agent changes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 13:38:42 -04:00

428 lines
17 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte';
let log_lvl: number = 0;
// *** Import Svelte specific
import { browser } from '$app/environment';
// *** Import Aether specific variables and functions
import { ae_util } from '$lib/ae_utils/ae_utils';
import {
ae_snip,
ae_loc,
ae_sess,
ae_api,
ae_trig,
slct,
slct_trigger
} from '$lib/stores/ae_stores';
import { idaa_loc, idaa_sess, idaa_slct } from '$lib/stores/ae_idaa_stores';
interface Props {
/** @type {import('./$types').LayoutData} */
data: any;
children?: import('svelte').Snippet;
}
let { data, children }: Props = $props();
// Small derived containing only fields that should trigger verification.
// $derived.by() evaluates the body reactively; $derived(() => {}) would store the
// function itself (never called) and verification would never fire.
let verify_dep = $derived.by(() => {
const uuid = data.url.searchParams.get('uuid');
const site_cfg = $ae_loc.site_cfg_json || {};
return {
uuid,
api_key: site_cfg.novi_idaa_api_key ?? null,
api_root: site_cfg.novi_api_root_url ?? 'https://www.idaa.org/api',
admin_li: site_cfg.novi_admin_li ?? [],
trusted_li: site_cfg.novi_trusted_li ?? [],
ttl_ms: site_cfg.novi_verified_ttl_ms ?? null
};
});
// True while verification is in flight OR while waiting for site config to load.
// Pre-initialized to true if a UUID is present so there is no flash of "Access Denied"
// on first render before the effect has a chance to run.
let novi_verifying: boolean = $state(
typeof window !== 'undefined' &&
!!new URLSearchParams(window.location.search).get('uuid')
);
// Cache / rate-limit/backoff configuration
const VERIFIED_TTL_MS_DEFAULT = 5 * 60 * 1000; // 5 minutes by default
let verify_backoff_attempts: Record<string, number> = {};
// Concurrency guard — separate from novi_verifying (the UI spinner).
// novi_verifying is pre-initialized to true when a UUID is in the URL to prevent
// an "Access Denied" flash before the effect runs. Using novi_verifying as the
// in-flight guard would cause the effect to see it as "already running" and
// exit immediately, leaving the spinner stuck forever.
let verify_in_flight = false;
// Short-window dedupe to avoid noisy re-runs from unrelated store churn
let last_effect_ts: number = 0;
let last_effect_uuid: string | null = null;
let last_effect_snapshot: string | null = null;
// Effect 1: Set URL origin and params (unchanged from original)
$effect(() => {
untrack(() => {
$ae_loc.url_origin = data.url.origin;
$ae_loc.params = data.params;
});
if (log_lvl > 1) {
console.log(`+layout.svelte data:`, data);
}
});
// Effect 2: Novi UUID verification
// Only fires when a uuid is present in the URL (i.e. the Novi iframe path).
// Non-Novi sign-in paths (User/Pass, shared passcode) will never have a uuid param,
// so this block won't run for them — their permissions are unaffected.
$effect(() => {
if (!browser) return;
// Track only the small derived object so unrelated changes in `$ae_loc`
// don't re-run this effect.
const { uuid, api_key, api_root, admin_li, trusted_li, ttl_ms } = verify_dep;
// NOTE: avoid reading `$ae_loc` here (it would register as a dependency
// and cause noisy re-runs). We'll log a concise message after the
// short-window dedupe so repeated pre-dedupe triggers are not shown.
// Short-window dedupe: if the effect re-runs within 500ms and the uuid
// and a small snapshot of relevant flags haven't changed, skip.
try {
const now = Date.now();
const has_api_key = api_key ? '1' : '0';
// Snapshot must NOT read $idaa_loc — that would subscribe the effect to idaa_loc
// writes (which happen after verification) and cause a needless re-run cycle.
// uuid + api_key presence is sufficient to detect a meaningful change.
const snapshot = `${uuid}:${has_api_key}`;
if (now - last_effect_ts < 500 && uuid === last_effect_uuid && snapshot === last_effect_snapshot) {
if (log_lvl) console.debug('IDAA verify effect: deduped noisy re-run');
return;
}
last_effect_ts = now;
last_effect_uuid = uuid;
last_effect_snapshot = snapshot;
} catch (e) {
/* ignore snapshot errors */
}
// Post-dedupe debug: only log when this invocation is proceeding past
// the dedupe guard. Keep the log minimal to avoid subscribing to large
// store objects.
if (log_lvl) {
try {
console.log(`[IDAA verify effect] run ts=${new Date().toISOString()} uuid=${uuid} novi_verified=${$idaa_loc.novi_verified} novi_uuid=${$idaa_loc.novi_uuid}`);
} catch (e) {
console.debug('[IDAA verify effect] log error', e);
}
}
untrack(() => {
if (!uuid) {
// No UUID in URL — non-Novi path, nothing to do here.
$idaa_loc.novi_verified = false;
novi_verifying = false;
return;
}
// If a verification call is already in flight, skip starting another.
// NOTE: do not use novi_verifying here — it is pre-initialized to true when
// a UUID is present (to prevent the Access Denied flash) and would cause this
// guard to exit immediately before any API call is ever made.
if (verify_in_flight) {
if (log_lvl) console.log(`IDAA Layout: verification already in progress for ${uuid}, skipping.`);
return;
}
// Track whether this is a different UUID than the last verified one.
// Impersonation changes the UUID mid-session; a changed UUID is always a new
// identity and must bypass TTL cache and rate-limits from the previous UUID.
const uuid_changed = uuid !== $idaa_loc.novi_uuid;
const now = Date.now();
const cfg_ttl_ms = ttl_ms ?? VERIFIED_TTL_MS_DEFAULT;
// TTL cache: skip re-verification if this exact UUID was recently confirmed.
// Never skip when the UUID has changed (impersonation → always re-verify).
if (
!uuid_changed &&
$idaa_loc.novi_verified &&
$idaa_loc.novi_verified_ts &&
now - $idaa_loc.novi_verified_ts < cfg_ttl_ms
) {
if (log_lvl) console.log(`IDAA Layout: cached verification valid for ${uuid} (${Math.round((now - $idaa_loc.novi_verified_ts) / 1000)}s old)`);
novi_verifying = false;
return;
}
// Rate limit: only apply when retrying the *same* UUID.
// A changed UUID (impersonation) bypasses rate-limits — it's a fresh identity.
if (!uuid_changed && $idaa_loc.novi_rate_limited_until && now < $idaa_loc.novi_rate_limited_until) {
if (log_lvl) console.warn(`IDAA Layout: verification skipped — rate-limited until ${new Date($idaa_loc.novi_rate_limited_until).toISOString()}`);
novi_verifying = false;
return;
}
// Load admin/trusted lists from site config first — needed by verify function.
// Only override if site_cfg_json actually provides them; falling back to [] would
// silently overwrite the hardcoded defaults in ae_idaa_stores.ts.
if (admin_li?.length) {
$idaa_loc.novi_admin_li = admin_li;
}
if (trusted_li?.length) {
$idaa_loc.novi_trusted_li = trusted_li;
}
const novi_api_key = api_key ?? null;
const novi_api_root_url = api_root;
// Fire-and-forget the async verification. After the first await, Svelte's
// reactive tracking no longer applies, so writes to stores are safe.
verify_in_flight = true;
novi_verifying = true;
verify_novi_uuid(uuid, novi_api_key, novi_api_root_url);
});
});
/**
* Verifies a Novi UUID against the Novi API and sets permissions accordingly.
* "All or nothing" — if no API key is configured or the call fails, access is denied.
* Called from within untrack(), so store writes here will not trigger reactive loops.
*/
async function verify_novi_uuid(
uuid: string,
api_key: string | null,
api_root_url: string
) {
console.log(`IDAA Layout: Starting Novi UUID verification for ${uuid}...`);
if (!api_key) {
// No Novi API key in site config. All-or-nothing means no UUID-based access.
console.warn(
'IDAA Layout: Novi API key not configured. UUID-based access denied.'
);
$idaa_loc.novi_uuid = null;
$idaa_loc.novi_email = null;
$idaa_loc.novi_full_name = null;
$idaa_loc.novi_verified = false;
novi_verifying = false;
return;
}
try {
if (log_lvl > 1) {
console.log(`IDAA Layout: Verifying Novi UUID ${uuid} via API...`);
}
const headers = new Headers();
headers.append('Authorization', `Basic ${api_key}`);
const response = await fetch(`${api_root_url}/customers/${uuid}`, {
method: 'GET',
headers
});
if (!response.ok) {
// Rate-limited — perform exponential backoff and honor Retry-After when present
if (response.status === 429) {
const ra = response.headers.get('Retry-After');
let retryAfterMs: number | null = null;
if (ra) {
// Retry-After can be seconds or HTTP-date
const raInt = parseInt(ra, 10);
if (!Number.isNaN(raInt)) {
retryAfterMs = raInt * 1000;
} else {
const raDate = Date.parse(ra);
if (!Number.isNaN(raDate)) {
retryAfterMs = raDate - Date.now();
}
}
}
const attempts = (verify_backoff_attempts[uuid] || 0) + 1;
verify_backoff_attempts[uuid] = attempts;
const expBackoff = Math.min(2 ** attempts * 1000, 5 * 60 * 1000); // cap 5m
const delay = retryAfterMs && retryAfterMs > 0 ? Math.max(retryAfterMs, expBackoff) : expBackoff;
$idaa_loc.novi_rate_limited_until = Date.now() + delay;
console.warn(`IDAA Layout: Novi API 429 for ${uuid}, backing off ${Math.round(delay/1000)}s (attempt ${attempts})`);
// Schedule a retry after delay (small padding)
setTimeout(() => {
// Only retry if the UUID hasn't changed since we started backing off
const currentUuid = data.url.searchParams.get('uuid');
if (currentUuid === uuid) {
console.log(`IDAA Layout: retrying Novi verification for ${uuid} after backoff`);
// set flag and call verify
novi_verifying = true;
verify_novi_uuid(uuid, api_key, api_root_url);
} else {
if (log_lvl) console.log(`IDAA Layout: uuid changed, not retrying ${uuid}`);
}
}, delay + 100);
// End this attempt
novi_verifying = false;
return;
}
throw new Error(
`Novi API returned ${response.status} for UUID ${uuid}`
);
}
const result = await response.json();
// Build display name: prefer "First L." format, fall back to full Name field.
const first_name = result?.FirstName ?? null;
const last_initial = result?.LastName
? `${result.LastName.charAt(0).toUpperCase()}.`
: '';
const verified_name =
first_name && last_initial
? `${first_name} ${last_initial}`
: (result?.Name ?? null);
// Normalize email — Novi occasionally includes spaces where + should be.
const verified_email = result?.Email
? result.Email.replace(/\s+/g, '+')
: null;
$idaa_loc.novi_uuid = uuid;
$idaa_loc.novi_email = verified_email;
$idaa_loc.novi_full_name = verified_name;
$idaa_loc.novi_verified = true;
$idaa_loc.novi_verified_ts = Date.now();
$idaa_loc.novi_rate_limited_until = null;
// reset backoff attempts on success
verify_backoff_attempts[uuid] = 0;
console.log(
`IDAA Layout: Novi UUID verified. Name: ${verified_name}, Email: ${verified_email}`
);
// Determine permission level based on verified UUID.
// UUID confirmed real → at minimum authenticated. Check lists for higher levels.
let target_novi_level = 'authenticated';
if ($idaa_loc.novi_admin_li?.includes(uuid)) {
target_novi_level = 'administrator';
} else if ($idaa_loc.novi_trusted_li?.includes(uuid)) {
target_novi_level = 'trusted';
}
// PERMISSION UPGRADE STRATEGY: only apply if higher than current level.
// This prevents a global 'manager' from being downgraded by the IDAA layout.
const current_level = $ae_loc.access_type || 'anonymous';
if (
ae_util.compare_access_levels(target_novi_level, current_level) ===
1
) {
console.log(
`IDAA Layout: Upgrading access from ${current_level} to ${target_novi_level} (Novi verified)`
);
const perms = ae_util.process_permission_checks(target_novi_level);
$ae_loc = { ...$ae_loc, ...perms };
} else {
if (log_lvl > 1) {
console.log(
`IDAA Layout: Keeping current access ${current_level} (Novi level ${target_novi_level} is not an upgrade)`
);
}
}
// Reset BB query filters to safe defaults in case they were left in a non-default state.
$idaa_loc.bb.qry__hidden = 'not_hidden';
$idaa_loc.bb.qry__enabled = 'enabled';
} catch (error) {
// Verification failed — all-or-nothing means deny access.
console.error(
`IDAA Layout: Novi UUID verification failed for ${uuid}:`,
error
);
$idaa_loc.novi_uuid = null;
$idaa_loc.novi_email = null;
$idaa_loc.novi_full_name = null;
$idaa_loc.novi_verified = false;
} finally {
verify_in_flight = false;
novi_verifying = false;
}
}
</script>
{#if !browser}
<!-- SSR / pre-hydration placeholder -->
<p class="text-center text-sm text-gray-500">
<span class="fas fa-spinner fa-spin"></span>
Loading...
</p>
{:else if novi_verifying}
<!-- Async Novi API call is in flight — show spinner to prevent Access Denied flash -->
<div
class="container m-8 flex w-full flex-col items-center justify-center gap-2 p-8">
<p class="text-center text-sm text-gray-500">
<span class="fas fa-spinner fa-spin"></span>
Verifying identity...
</p>
</div>
{:else if $ae_loc.trusted_access || ($ae_loc.authenticated_access && $idaa_loc.novi_uuid)}
{@render children?.()}
{#if $idaa_loc.novi_uuid}
<span class="text-sm text-gray-500">
Novi: <span class="fas fa-user m-1"></span>
{$idaa_loc.novi_uuid}
{$idaa_loc.novi_full_name ?? 'name not set'}
{$idaa_loc.novi_email ?? 'email not set'}
</span>
{:else}
<p class="text-center text-sm text-gray-500">
IDAA Novi UUID not found!
</p>
{/if}
{:else}
<div
class="container m-8 flex w-full flex-col items-center justify-center gap-1 p-8 font-bold">
<h1>
<span class="text-red-500">
<span class="fas fa-exclamation-triangle"></span>
Access Denied
<span class="fas fa-exclamation-triangle"></span>
</span>
</h1>
<p>You do not have access to these IDAA page.</p>
{#if $ae_loc.iframe}
In iframe mode
{/if}
{#if $idaa_loc.novi_uuid}
<span class="text-sm text-gray-500">
Novi: <span class="fas fa-user m-1"></span>
{$idaa_loc.novi_uuid}
{$idaa_loc.novi_full_name ?? 'name not set'}
{$idaa_loc.novi_email ?? 'email not set'}
</span>
{:else}
<p>IDAA Novi UUID not found!</p>
{/if}
{#if $ae_loc.iframe}
<!-- WHY: In iframe mode the Novi UUID is passed via URL param on first load.
If verification hasn't completed yet (timing race on Novi API), the user
lands on Access Denied. Reloading the iframe re-triggers verification. -->
<button
type="button"
class="btn btn-sm preset-tonal-primary border-primary-500 mt-4 border"
onclick={() => location.reload()}>
<span class="fas fa-redo m-1"></span>
Reload / Retry
</button>
<p class="mt-1 text-xs text-gray-500">
If your session just started, try reloading.
</p>
{/if}
</div>
{/if}