diff --git a/documentation/GUIDE__AE_API_V3_for_Frontend.md b/documentation/GUIDE__AE_API_V3_for_Frontend.md index fcdff048..d36bf0d5 100644 --- a/documentation/GUIDE__AE_API_V3_for_Frontend.md +++ b/documentation/GUIDE__AE_API_V3_for_Frontend.md @@ -638,6 +638,61 @@ const url = URL.createObjectURL(blob); --- +## 12. IDAA: Server-Side Novi Member Verification + +Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether backend. This eliminates false "Access Denied" failures for members on hotel/conference WiFi, VPNs, and Cloudflare-filtered networks — the Novi call originates from the server's IP, not the member's browser IP. + +- **Method:** `GET` +- **Path:** `/v3/action/idaa/novi_member/{uuid}` +- **Auth:** Standard V3 (`x-aether-api-key` + `x-account-id` or `?jwt=`) + +### Request + +| Parameter | Location | Required | Description | +|---|---|---|---| +| `uuid` | Path | Yes | Novi member UUID (from Novi AMS) | + +### Response on success (`200 OK`) + +```json +{ + "data": { + "verified": true, + "full_name": "Alice S.", + "email": "alice+member@idaa.org" + } +} +``` + +- `full_name`: `"{FirstName} {LastName[0]}."` format. Falls back to the Novi `Name` field if first/last are absent. +- `email`: Novi `Email` field with space → `+` normalization applied (Novi quirk — `alice member@idaa.org` → `alice+member@idaa.org`). + +### Error responses + +| Status | Meaning | Frontend action | +|---|---|---| +| `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member | +| `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry | +| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry | + +### Migration from direct Novi call + +The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping: + +| Direct Novi result | This endpoint returns | Frontend state | +|---|---|---| +| `200` with identity data | `200` | `verified` | +| `200` with no identity data | `404` | `denied` | +| `404` | `404` | `denied` | +| `429` | `429` | `'rate_limited'` | +| Network error / Novi 5xx | `503` | `'api_error'` | + +### Caching + +Verified results are cached in Redis (`idaa:novi_member:{uuid}`, 4-hour TTL). `404` results are **never** cached so recently-joined members are not incorrectly denied on their next attempt. + +--- + ## 11. Troubleshooting 403 Forbidden If you receive a 403 on a valid ID: diff --git a/src/routes/idaa/(idaa)/+layout.svelte b/src/routes/idaa/(idaa)/+layout.svelte index 10dedb5f..2c843fbe 100644 --- a/src/routes/idaa/(idaa)/+layout.svelte +++ b/src/routes/idaa/(idaa)/+layout.svelte @@ -174,8 +174,6 @@ $effect(() => { if (!browser) return; const site_cfg = $ae_loc.site_cfg_json || {}; - const api_key: string | null = site_cfg.novi_idaa_api_key ?? null; - const api_root: string = site_cfg.novi_api_root_url ?? 'https://www.idaa.org/api'; const admin_li: string[] = site_cfg.novi_admin_li ?? []; const trusted_li: string[] = site_cfg.novi_trusted_li ?? []; const ttl_ms: number = site_cfg.novi_verified_ttl_ms ?? VERIFIED_TTL_MS_DEFAULT; @@ -283,62 +281,54 @@ $effect(() => { if (!has_valid_session) { novi_verifying = true; } - verify_novi_uuid(current_uuid, api_key, api_root); + verify_novi_uuid(current_uuid); }); }); /** - * 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. + * Verifies a Novi UUID via the Aether server-side proxy endpoint. + * The backend calls Novi server-to-server, eliminating browser IP exposure (hotel WiFi, + * VPN blocks, Cloudflare IP reputation) that caused false "Access Denied" for some members. * - * Retry policy: - * 429 (rate limited) → wait 10s, retry once. - * Network error / timeout → wait 3s, retry once. - * Anything else (4xx, 5xx, empty 200) → fail immediately. - * - * The Novi fetch is wrapped in an AbortController with a 12s hard timeout. - * Without it, a hung Novi server leaves verify_in_flight=true indefinitely. + * Aether endpoint: GET /v3/action/idaa/novi_member/{uuid} + * Response codes: + * 200 → verified — data.full_name and data.email populated + * 404 → not a member (or Novi returned empty 200, handled server-side) → deny + * 429 → Novi rate limited → wait 10s, retry once + * 503 → Novi unreachable / Novi 5xx → show api_error UI + * Network error / AbortError → wait 3s, retry once */ -async function verify_novi_uuid( - uuid: string, - api_key: string | null, - api_root_url: string, - is_retry: boolean = false -) { - if (!api_key) { - // WHY: Do NOT clear $idaa_loc state here, and do NOT log "Starting verification". - // The api_key can be transiently null when $ae_loc.site_cfg_json hasn't finished - // loading (SWR: root layout fires $ae_loc = new_loc before cfg is in the store). - // Clearing novi_verified/novi_uuid here would destroy a valid cached session, making - // the TTL check fail on the very next Effect 2 run — causing a re-auth loop. - // If the api_key is genuinely not configured, $idaa_loc starts with - // novi_verified=false and novi_uuid=null (defaults), so access is denied naturally. - // novi_verifying is left at its current value intentionally: - // - If has_valid_session was false above, novi_verifying=true (spinner stays visible - // until api_key loads and Effect 2 re-runs with a real key). - // - If has_valid_session was true, novi_verifying=false (children keep rendering). - console.warn('IDAA Layout: Novi API key not yet available — skipping verification (will retry when site_cfg_json loads).'); +async function verify_novi_uuid(uuid: string, is_retry: boolean = false) { + const account_id = $ae_loc.account_id; + const api_key = $ae_api.api_secret_key; + const api_url = $ae_api.base_url; + + if (!account_id || !api_key || !api_url) { + // Aether config not yet available (SWR race on startup — site_cfg_json loads async). + // Do not clear $idaa_loc: that would destroy a valid cached session and cause a + // re-auth loop on the next Effect 2 run. Access is denied naturally when + // $idaa_loc starts with novi_verified=false (its default). + console.warn('IDAA Layout: Aether API config not yet available — skipping verification (will retry when config loads).'); verify_in_flight = false; return; } + verifying_status_msg = 'Verifying identity...'; verify_error_type = null; - console.log(`IDAA Layout: Starting Novi UUID verification for ${uuid}...`); + console.log(`IDAA Layout: Starting Novi UUID verification (server-side) for ${uuid}...`); try { - const headers = new Headers(); - headers.append('Authorization', `Basic ${api_key}`); - - // Hard timeout: abort if Novi doesn't respond within 12 seconds. - // Prevents verify_in_flight from getting stuck on a hung server. + // Hard timeout: abort if the Aether endpoint doesn't respond within 12 seconds. const abort_ctrl = new AbortController(); const abort_timeout_id = setTimeout(() => abort_ctrl.abort(), 12_000); let response: Response; try { - response = await fetch(`${api_root_url}/customers/${uuid}`, { + response = await fetch(`${api_url}/v3/action/idaa/novi_member/${uuid}`, { method: 'GET', - headers, + headers: { + 'x-aether-api-key': api_key, + 'x-account-id': account_id + }, signal: abort_ctrl.signal }); } finally { @@ -348,64 +338,40 @@ async function verify_novi_uuid( if (response.status === 429) { if (is_retry) { verify_error_type = 'rate_limited'; - throw new Error(`Novi API rate limited for UUID ${uuid} (retry also failed)`); + throw new Error(`Novi verification rate limited for UUID ${uuid} (retry also failed)`); } - console.warn(`IDAA Layout: Novi API rate limited (429) for ${uuid}. Retrying in 10s...`); + console.warn(`IDAA Layout: Rate limited (429) for ${uuid}. Retrying in 10s...`); verifying_status_msg = 'High traffic — retrying in 10 seconds...'; await new Promise((resolve) => setTimeout(resolve, 10_000)); - await verify_novi_uuid(uuid, api_key, api_root_url, true); + await verify_novi_uuid(uuid, true); return; } if (!response.ok) { - throw new Error(`Novi API returned ${response.status} for UUID ${uuid}`); + // 404 = not a member (Novi 404, or Novi empty-200 anti-pattern handled server-side). + // 503 = Novi unreachable / Novi 5xx — show api_error UI so user can retry. + throw new Error(`Novi verification returned ${response.status} for UUID ${uuid}`); } const result = await response.json(); - // log_lvl = 2; if (log_lvl > 1) { - console.log(`IDAA Layout: Novi API response for ${uuid}:`, result); + console.log(`IDAA Layout: Novi verification response for ${uuid}:`, result); } - // NOTE: We are currently trusting that the pages in the Novi CMS are handling the actual current active member checks. It (Novi CMS page config) denies access to the page as a whole if they are not a member. 2026-05-19 + const verified_name: string | null = result?.data?.full_name ?? null; + const verified_email: string | null = result?.data?.email ?? null; - // 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; - - // WHY: Novi may return HTTP 200 with empty/null fields for UUIDs that don't - // exist or have been deleted (common API anti-pattern — empty 200 vs 404). - // Without this check, any UUID would pass verification as long as Novi doesn't - // return a 4xx status. Require at least one identity field to treat as a real member. - if (!verified_email && !first_name && !(result?.Name)) { - throw new Error( - `Novi API returned 200 but no member data for UUID ${uuid} — treating as unverified` - ); + if (!result?.data?.verified || (!verified_name && !verified_email)) { + throw new Error(`Novi verification returned 200 but no identity data for UUID ${uuid}`); } - // Other result fields for future use: - // Role ("Regular User"), MembershipExpires, MemberStatus ("current"), CreatedDate, LastUpdatedDate, Groups ("GroupUniqueID", "GroupName", "InheritingMember", "JoinDate") - $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(); - console.log( - `IDAA Layout: Novi UUID verified. Name: ${verified_name}, Email: ${verified_email}` - ); + console.log(`IDAA Layout: Novi UUID verified (server-side). Name: ${verified_name}, Email: ${verified_email}`); // Determine permission level based on verified UUID. // UUID confirmed real → at minimum authenticated. Check lists for higher levels. @@ -432,8 +398,7 @@ async function verify_novi_uuid( $idaa_loc.bb.qry__enabled = 'enabled'; } catch (error) { // Network errors (TypeError: Failed to fetch) and AbortError (our 12s timeout) get one - // automatic retry after a short wait — they are almost always transient (cellular drop, - // hotel WiFi hiccup, momentary Novi server lag). + // automatic retry after a short wait — they are almost always transient. const is_network_or_timeout = error instanceof TypeError || (error instanceof Error && error.name === 'AbortError'); @@ -444,21 +409,16 @@ async function verify_novi_uuid( console.warn(`IDAA Layout: Novi verification ${reason} for ${uuid}. Retrying in 3s...`); verifying_status_msg = 'Connection issue — retrying...'; await new Promise((resolve) => setTimeout(resolve, 3_000)); - await verify_novi_uuid(uuid, api_key, api_root_url, true); + await verify_novi_uuid(uuid, true); return; } // Verification failed — all-or-nothing means deny access. - console.error( - `IDAA Layout: Novi UUID verification failed for ${uuid}:`, - error - ); + console.error(`IDAA Layout: Novi UUID verification failed for ${uuid}:`, error); verify_failed_for_uuid = uuid; // Latch — stop retry loop for this UUID. See declaration comment. // 'rate_limited' is set before throw for 429. - // 'network_error' for persistent network/timeout failures after retry — both the first - // attempt AND the auto-retry failed with TypeError/AbortError. Most common cause: hotel/ - // conference WiFi, VPN, or Cloudflare IP reputation blocking the client-side Novi call. - // 'api_error' for everything else (4xx, 5xx, empty 200). + // 'network_error' for persistent network/timeout failures after retry. + // 'api_error' for all other failures (404 non-member, 503 Novi down, etc.). if (!verify_error_type) { verify_error_type = is_network_or_timeout ? 'network_error' : 'api_error'; }