From 19d0145d002f1b4f83566956035b1af7f780f36c Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 27 Mar 2026 13:38:42 -0400 Subject: [PATCH] =?UTF-8?q?fix(idaa):=20fix=20Novi=20UUID=20verification?= =?UTF-8?q?=20=E2=80=94=20stuck=20spinner,=20repeat=20calls,=20impersonati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../CLIENT__IDAA_and_customized_mods.md | 12 +- src/lib/stores/ae_idaa_stores.ts | 6 + src/routes/idaa/(idaa)/+layout.svelte | 172 ++++++++++++++++-- static/idaa_novi_iframe_jitsi_meeting.html | 12 +- 4 files changed, 179 insertions(+), 23 deletions(-) diff --git a/documentation/CLIENT__IDAA_and_customized_mods.md b/documentation/CLIENT__IDAA_and_customized_mods.md index 297e49a7..d46a7168 100644 --- a/documentation/CLIENT__IDAA_and_customized_mods.md +++ b/documentation/CLIENT__IDAA_and_customized_mods.md @@ -544,7 +544,8 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... }; --- ## IDAA Novi Groups and Moderators -IDAA Couples Meeting = "e9e162f0-3d03-4241-9682-340135ec3fb8" + +### IDAA Couples Meeting = "e9e162f0-3d03-4241-9682-340135ec3fb8" "Gregory X Boehm" "00ee764c-7559-496b-9d18-40d3e9092c0c" "Kee B. PARK" "24ab3297-bfce-473c-9311-4b31e3a8974f" @@ -553,10 +554,17 @@ IDAA Couples Meeting = "e9e162f0-3d03-4241-9682-340135ec3fb8" "Owen Lander" "9671a2c4-ff95-48c2-bcde-5c6eba95cded" "Susan Park" "4a9f94c5-d766-4808-ab76-117c9e43903a" -"Student/Resident Meeting Moderators" "d76d2c00-962d-40f6-a2e8-ed9c85594d96" +### "Student/Resident Meeting Moderators" "d76d2c00-962d-40f6-a2e8-ed9c85594d96" + "Melissa Eve Valasky" "182d1db3-caa9-41bc-b04a-2facc6859aeb" "Steven L. Klein" "5724aad7-6d89-47e7-8943-966fd22911bd" +### "IDAA BIPOC Meeting" "873d3ad0-2605-4ccf-824c-638c16b2b9cf" + +"Paula Lynn Bailey-Walton" "68383ba2-0989-4860-9ea6-073f9698df67" +"Tasha Hudson" "03d5408c-3c13-4c3a-a93f-49871f9050b1" + + --- **Document Status:** ✅ Current diff --git a/src/lib/stores/ae_idaa_stores.ts b/src/lib/stores/ae_idaa_stores.ts index 6d78c387..f09b9e6c 100644 --- a/src/lib/stores/ae_idaa_stores.ts +++ b/src/lib/stores/ae_idaa_stores.ts @@ -21,6 +21,12 @@ const idaa_local_data_struct: key_val = { // True after a successful Novi API verification (UUID confirmed to be a real Novi member). // False on load, on verification failure, or for non-Novi sign-in paths. novi_verified: false, + // Timestamp (ms since epoch) when the last successful verification occurred. + // Used to cache verification results and avoid repeated Novi API calls. + novi_verified_ts: null, + // If set to a ms timestamp, verification attempts should be skipped until this time. + // Used to honor rate-limits and Retry-After behavior. + novi_rate_limited_until: null, // Populated from $ae_loc.site_cfg_json at IDAA layout mount — not managed here. // See routes/idaa/(idaa)/+layout.svelte for the override logic. novi_admin_li: [], diff --git a/src/routes/idaa/(idaa)/+layout.svelte b/src/routes/idaa/(idaa)/+layout.svelte index a66e418f..f6d1ff22 100644 --- a/src/routes/idaa/(idaa)/+layout.svelte +++ b/src/routes/idaa/(idaa)/+layout.svelte @@ -26,6 +26,22 @@ interface Props { 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. @@ -34,6 +50,20 @@ let novi_verifying: boolean = $state( !!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 = {}; +// 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(() => { @@ -53,14 +83,44 @@ $effect(() => { $effect(() => { if (!browser) return; - const uuid = data.url.searchParams.get('uuid'); // tracked — re-runs if URL changes + // 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; - // WHY tracked outside untrack: on first load the fast-path returns a stale Dexie - // cache, so site_cfg_json may be missing novi_idaa_api_key when this effect first - // runs. The background refresh in ae_core__site.ts pushes fresh cfg_json into - // $ae_loc after the API responds. Tracking here means this effect re-runs at that - // point and retries verification with the correct key — no manual reload needed. - const site_cfg_json = $ae_loc.site_cfg_json; + // 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) { @@ -70,9 +130,39 @@ $effect(() => { return; } - // Already verified for this exact UUID — don't repeat the round-trip. - // This guard fires when site_cfg_json changes for reasons unrelated to Novi. - if ($idaa_loc.novi_verified && $idaa_loc.novi_uuid === uuid) { + // 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; } @@ -80,19 +170,19 @@ $effect(() => { // 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 (site_cfg_json?.novi_admin_li?.length) { - $idaa_loc.novi_admin_li = site_cfg_json.novi_admin_li; + if (admin_li?.length) { + $idaa_loc.novi_admin_li = admin_li; } - if (site_cfg_json?.novi_trusted_li?.length) { - $idaa_loc.novi_trusted_li = site_cfg_json.novi_trusted_li; + if (trusted_li?.length) { + $idaa_loc.novi_trusted_li = trusted_li; } - const novi_api_key = site_cfg_json?.novi_idaa_api_key ?? null; - const novi_api_root_url = - site_cfg_json?.novi_api_root_url ?? 'https://www.idaa.org/api'; + 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); }); @@ -135,6 +225,49 @@ async function verify_novi_uuid( }); 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}` ); @@ -161,6 +294,10 @@ async function verify_novi_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}` @@ -209,6 +346,7 @@ async function verify_novi_uuid( $idaa_loc.novi_full_name = null; $idaa_loc.novi_verified = false; } finally { + verify_in_flight = false; novi_verifying = false; } } diff --git a/static/idaa_novi_iframe_jitsi_meeting.html b/static/idaa_novi_iframe_jitsi_meeting.html index 114e0c79..2adc73db 100644 --- a/static/idaa_novi_iframe_jitsi_meeting.html +++ b/static/idaa_novi_iframe_jitsi_meeting.html @@ -29,11 +29,15 @@ let novi_customer_uid = '<%=Novi.User.CustomerUniqueId%>'; // NOTE: The Novi UUID for the current current user/customer console.log(`Novi's Current User's ID: ${novi_customer_uid}`); + let novi_group_uid = 'check-Novi-Group-UID'; + // let novi_category_id = ''; // Not in use yet or at all? + // NOTE: Change the room_name value to the desired Jitsi room name for the meeting. + // Example meeting room names: + // 'IDAA-Meeting' 'IDAA-Student-and-Resident-Meeting' 'IDAA-Couples-Meeting' 'IDAA-BIPOC-Meeting' let room_name = 'IDAA-Example-Meeting'; // // NOTE: Change this example meeting room name - // Example meeting room names: 'IDAA-Meeting' 'IDAA-Student-and-Resident-Meeting' - // let novi_group_id = ''; // Not in use yet - // let novi_category_id = ''; // Not in use yet + + // WARNING:Do *not* use relative paths here. They must be direct to the site OSIT is hosting for IDAA. This value must point to the Svelte Jitsi page. let idaa_osit_ae_api_root_url = @@ -50,7 +54,7 @@ ); idaa_ae_iframe_element.src = - `${idaa_osit_ae_api_root_url}?uuid=${novi_customer_uid}&iframe=true&key=${idaa_osit_ae_site_key}&room=${room_name}` + `${idaa_osit_ae_api_root_url}?uuid=${novi_customer_uid}&g_uuid=${novi_group_uid}&iframe=true&key=${idaa_osit_ae_site_key}&room=${room_name}` ;