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>
This commit is contained in:
Scott Idem
2026-03-27 13:38:42 -04:00
parent 9d44b9341c
commit 19d0145d00
4 changed files with 179 additions and 23 deletions

View File

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

View File

@@ -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: [],

View File

@@ -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<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(() => {
@@ -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;
}
}

View File

@@ -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}`
;
</script>