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>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<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.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<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.
|
||||
// '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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user