4 Commits

Author SHA1 Message Date
Scott Idem
42f40e990e fix(idaa): auto-retry 503 from Aether Novi proxy (mirrors network-error path)
Before: a 503 response from /v3/action/idaa/novi_member/{uuid} hit !response.ok
immediately, set verify_failed_for_uuid, and showed "Verification Unavailable" —
no automatic retry. In the old client-to-Novi flow an unreachable server threw
TypeError (auto-retried); the new server-side path returns a clean HTTP 503
(plain Error), bypassing the is_network_or_timeout branch.

After: 503 gets the same 3s wait + one retry that network/timeout errors get.
Only if the retry also 503s does it fall through to the error UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:41:01 -04:00
Scott Idem
3ea362c166 fix(idaa): restore site_cfg guard to prevent API call on non-IDAA domains
The server-side migration removed the old novi_idaa_api_key check, which was
also acting as an implicit 'is IDAA configured here?' guard. Without it, any
domain that resolves (including ghost/domain-not-found with account_id='ghost')
would fire the Aether endpoint and get an error response, showing 'Verification
Unavailable' over the root layout's 'Domain Not Found' message.

Restore the site_cfg.novi_idaa_api_key presence check as the first guard:
- key absent → site_cfg_json still loading OR this is not an IDAA site → skip
- account_id='ghost' → domain lookup failed → added explicit ghost guard too

The key itself is unused for auth (server holds it); we only test its presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:57:26 -04:00
Scott Idem
400312456b feat(idaa): replace client-side Novi call with server-side Aether proxy endpoint
verify_novi_uuid() now calls GET /v3/action/idaa/novi_member/{uuid} instead
of fetching Novi directly from the browser. The Aether backend handles the
Novi call server-to-server, eliminating false Access Denied failures caused
by hotel/conference WiFi, VPNs, and Cloudflare IP filtering.

Response parsing simplified — full_name and email are normalized server-side.
Empty-200 anti-pattern handling, email space→+ normalization, and display-name
formatting moved to the backend (confirmed working per API agent).

Retry logic and error classification unchanged (429→rate_limited, network
error→retry once, all others→api_error).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:49:18 -04:00
Scott Idem
6755a68b13 fix(idaa): add VPN/network hint, bump TTL to 12h, document server-side verify plan
- Classify persistent network/timeout failures as 'network_error' (separate from
  generic 'api_error') so the UI can show a targeted message
- Add actionable hint for members on hotel WiFi, VPN, or corporate networks:
  turn off VPN, switch to cellular, try a different network
- Extend VERIFIED_TTL_MS_DEFAULT from 45 min to 12 hours — covers a full workday
  so members at conferences do not need to re-verify mid-day
- Document planned server-side Novi verification FastAPI endpoint in
  CLIENT__IDAA_and_customized_mods.md (once implemented, eliminates client-side
  Cloudflare/IP-reputation exposure entirely)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:23:45 -04:00
3 changed files with 169 additions and 88 deletions

View File

@@ -220,6 +220,30 @@ These fields are read elsewhere in the IDAA UI to enable flows for verified user
If you need a compact checklist for re-creating this flow in another integration, ask and I will add a small runbook with exact request/response field mappings.
### Planned: Server-Side Novi Verification (FastAPI)
**Problem:** The current implementation calls the Novi API client-side — from the member's browser directly to Novi. Hotel/conference WiFi, VPNs, corporate/hospital networks, and Cloudflare IP reputation filtering can block these calls and produce false "Access Denied" for legitimate members.
**Solution:** A FastAPI endpoint proxies the Novi call server-to-server (Aether → Novi), caching results in Redis. Members' browser IPs are no longer in the call path.
**Endpoint:** `GET /v3/action/idaa/novi_member/{uuid}`
- Standard Aether auth headers required (`x-aether-api-key`, `x-account-id`)
- Server reads `novi_idaa_api_key` / `novi_api_root_url` from site `cfg_json`
- Redis cache key: `idaa:novi_member:{account_id}:{uuid}` — TTL 4 hours, only cache verified 200s
**Response codes:**
| Code | Meaning | Frontend action |
|---|---|---|
| `200` | Verified — `{ "verified": true, "full_name": "...", "email": "..." }` | Grant access |
| `404` | UUID not in Novi (genuine non-member) | Deny access |
| `429` | Novi rate limited | Show retry UI (not a denial) |
| `503` | Novi unreachable | Show retry UI (not a denial) |
**Frontend change when implemented:** Replace the direct `fetch()` to Novi in `verify_novi_uuid()` with a call to this endpoint via `ae_api`. The `api_key` param becomes unused (server holds it). Response code mapping: 404 → denied, 429 → `'rate_limited'`, 503 → `'api_error'`.
**FastAPI task:** Tracked in `aether_api_fastapi/documentation/TODO__Agents.md` under "IDAA: Server-Side Novi Verification".
### Permission Levels (Ascending)
| Level | Condition | Access |
|---|---|---|

View File

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

View File

@@ -66,7 +66,7 @@ let verify_failed_for_uuid: string | null = null;
// Tracks the type of Novi API failure for the UI — 'rate_limited' or 'api_error'.
// A transient API error is NOT the same as a real membership denial; this lets us
// show a distinct "Verification Unavailable" state instead of "Access Denied".
let verify_error_type: 'rate_limited' | 'api_error' | null = $state(null);
let verify_error_type: 'rate_limited' | 'api_error' | 'network_error' | null = $state(null);
// Shown inside the spinner — updated during rate-limit retry waits.
let verifying_status_msg: string = $state('Verifying identity...');
// Incremented by handle_verify_retry() to re-run Effect 2 without a full page reload.
@@ -148,7 +148,10 @@ $effect(() => {
}
});
const VERIFIED_TTL_MS_DEFAULT = 45 * 60 * 1000; // 45 minutes
// WHY 12 hours: members open IDAA in a hotel/conference context and should not need to
// re-verify mid-day. After our client-side Novi call is replaced by server-side (FastAPI),
// this TTL can be tuned independently. Until then, 12h covers a full workday.
const VERIFIED_TTL_MS_DEFAULT = 12 * 60 * 60 * 1000; // 12 hours
// Effect 1: Set URL origin and params
$effect(() => {
@@ -171,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;
@@ -280,62 +281,65 @@ $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) {
// WHY: Check presence of novi_idaa_api_key to detect whether this site has IDAA
// configured. The key itself is no longer used for auth (the Aether server holds it),
// but its absence means either (a) this is not the IDAA site / dev domain, or (b)
// site_cfg_json hasn't finished loading yet. In both cases: skip and wait for the
// next Effect 2 trigger. This also guards the 'ghost' account_id state (domain-not-found
// fallback gives site_cfg_json = {}) — prevents a spurious API call with a bad account_id.
const site_cfg = $ae_loc.site_cfg_json || {};
if (!site_cfg.novi_idaa_api_key) {
console.warn('IDAA Layout: Novi not configured for this site (or site_cfg_json still loading) — skipping verification.');
verify_in_flight = false;
return;
}
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 || account_id === 'ghost' || !api_key || !api_url) {
// Aether config not yet available. Do not clear $idaa_loc: that would destroy a
// valid cached session and cause a re-auth loop on the next Effect 2 run.
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 {
@@ -345,64 +349,52 @@ 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<void>((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.status === 503) {
// Novi unreachable or Novi 5xx — Aether backend returns 503.
// Mirror the network-error retry path: one automatic 3s wait before giving up.
if (is_retry) {
throw new Error(`Novi verification: Novi unreachable (503) — retry also failed`);
}
console.warn(`IDAA Layout: Novi unreachable (503) for ${uuid}. Retrying in 3s...`);
verifying_status_msg = 'Connection issue — retrying...';
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
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).
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.
@@ -429,8 +421,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');
@@ -441,18 +432,19 @@ 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<void>((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; everything else is an unexpected API error.
if (!verify_error_type) verify_error_type = 'api_error';
// 'rate_limited' is set before throw for 429.
// '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';
}
$idaa_loc.novi_uuid = null;
$idaa_loc.novi_email = null;
$idaa_loc.novi_full_name = null;
@@ -538,9 +530,19 @@ function handle_verify_retry() {
<p class="text-sm">
The membership directory is temporarily busy (rate limited). Please wait a moment and try again.
</p>
{:else if verify_error_type === 'network_error'}
<p class="text-sm">
We were unable to reach the membership directory after two attempts. This is usually caused by a network filter blocking the connection — it is not a problem with your membership.
</p>
<ul class="mt-1 list-disc pl-5 text-left text-sm">
<li>Turn off your VPN if one is running</li>
<li>Switch from hotel or conference WiFi to your phone's cellular data</li>
<li>Try a different network (mobile data, home WiFi)</li>
<li>If on a corporate or hospital network, try from a personal device</li>
</ul>
{:else}
<p class="text-sm">
We were unable to connect to the membership directory. This is usually a temporary network issue — it is not a problem with your membership or access.
We were unable to connect to the membership directory. This is usually a temporary issue — it is not a problem with your membership or access.
</p>
{/if}
<p class="text-xs italic text-gray-500">