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
|
## 11. Troubleshooting 403 Forbidden
|
||||||
|
|
||||||
If you receive a 403 on a valid ID:
|
If you receive a 403 on a valid ID:
|
||||||
|
|||||||
@@ -174,8 +174,6 @@ $effect(() => {
|
|||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
const site_cfg = $ae_loc.site_cfg_json || {};
|
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 admin_li: string[] = site_cfg.novi_admin_li ?? [];
|
||||||
const trusted_li: string[] = site_cfg.novi_trusted_li ?? [];
|
const trusted_li: string[] = site_cfg.novi_trusted_li ?? [];
|
||||||
const ttl_ms: number = site_cfg.novi_verified_ttl_ms ?? VERIFIED_TTL_MS_DEFAULT;
|
const ttl_ms: number = site_cfg.novi_verified_ttl_ms ?? VERIFIED_TTL_MS_DEFAULT;
|
||||||
@@ -283,62 +281,54 @@ $effect(() => {
|
|||||||
if (!has_valid_session) {
|
if (!has_valid_session) {
|
||||||
novi_verifying = true;
|
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.
|
* Verifies a Novi UUID via the Aether server-side proxy endpoint.
|
||||||
* "All or nothing" — if no API key is configured or the call fails, access is denied.
|
* The backend calls Novi server-to-server, eliminating browser IP exposure (hotel WiFi,
|
||||||
* Called from within untrack(), so store writes here will not trigger reactive loops.
|
* VPN blocks, Cloudflare IP reputation) that caused false "Access Denied" for some members.
|
||||||
*
|
*
|
||||||
* Retry policy:
|
* Aether endpoint: GET /v3/action/idaa/novi_member/{uuid}
|
||||||
* 429 (rate limited) → wait 10s, retry once.
|
* Response codes:
|
||||||
* Network error / timeout → wait 3s, retry once.
|
* 200 → verified — data.full_name and data.email populated
|
||||||
* Anything else (4xx, 5xx, empty 200) → fail immediately.
|
* 404 → not a member (or Novi returned empty 200, handled server-side) → deny
|
||||||
*
|
* 429 → Novi rate limited → wait 10s, retry once
|
||||||
* The Novi fetch is wrapped in an AbortController with a 12s hard timeout.
|
* 503 → Novi unreachable / Novi 5xx → show api_error UI
|
||||||
* Without it, a hung Novi server leaves verify_in_flight=true indefinitely.
|
* Network error / AbortError → wait 3s, retry once
|
||||||
*/
|
*/
|
||||||
async function verify_novi_uuid(
|
async function verify_novi_uuid(uuid: string, is_retry: boolean = false) {
|
||||||
uuid: string,
|
const account_id = $ae_loc.account_id;
|
||||||
api_key: string | null,
|
const api_key = $ae_api.api_secret_key;
|
||||||
api_root_url: string,
|
const api_url = $ae_api.base_url;
|
||||||
is_retry: boolean = false
|
|
||||||
) {
|
if (!account_id || !api_key || !api_url) {
|
||||||
if (!api_key) {
|
// Aether config not yet available (SWR race on startup — site_cfg_json loads async).
|
||||||
// WHY: Do NOT clear $idaa_loc state here, and do NOT log "Starting verification".
|
// Do not clear $idaa_loc: that would destroy a valid cached session and cause a
|
||||||
// The api_key can be transiently null when $ae_loc.site_cfg_json hasn't finished
|
// re-auth loop on the next Effect 2 run. Access is denied naturally when
|
||||||
// loading (SWR: root layout fires $ae_loc = new_loc before cfg is in the store).
|
// $idaa_loc starts with novi_verified=false (its default).
|
||||||
// Clearing novi_verified/novi_uuid here would destroy a valid cached session, making
|
console.warn('IDAA Layout: Aether API config not yet available — skipping verification (will retry when config loads).');
|
||||||
// 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).');
|
|
||||||
verify_in_flight = false;
|
verify_in_flight = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
verifying_status_msg = 'Verifying identity...';
|
verifying_status_msg = 'Verifying identity...';
|
||||||
verify_error_type = null;
|
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 {
|
try {
|
||||||
const headers = new Headers();
|
// Hard timeout: abort if the Aether endpoint doesn't respond within 12 seconds.
|
||||||
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.
|
|
||||||
const abort_ctrl = new AbortController();
|
const abort_ctrl = new AbortController();
|
||||||
const abort_timeout_id = setTimeout(() => abort_ctrl.abort(), 12_000);
|
const abort_timeout_id = setTimeout(() => abort_ctrl.abort(), 12_000);
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${api_root_url}/customers/${uuid}`, {
|
response = await fetch(`${api_url}/v3/action/idaa/novi_member/${uuid}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers,
|
headers: {
|
||||||
|
'x-aether-api-key': api_key,
|
||||||
|
'x-account-id': account_id
|
||||||
|
},
|
||||||
signal: abort_ctrl.signal
|
signal: abort_ctrl.signal
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -348,64 +338,40 @@ async function verify_novi_uuid(
|
|||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
if (is_retry) {
|
if (is_retry) {
|
||||||
verify_error_type = 'rate_limited';
|
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...';
|
verifying_status_msg = 'High traffic — retrying in 10 seconds...';
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 10_000));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const result = await response.json();
|
||||||
// log_lvl = 2;
|
|
||||||
if (log_lvl > 1) {
|
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.
|
if (!result?.data?.verified || (!verified_name && !verified_email)) {
|
||||||
const first_name = result?.FirstName ?? null;
|
throw new Error(`Novi verification returned 200 but no identity data for UUID ${uuid}`);
|
||||||
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`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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_uuid = uuid;
|
||||||
$idaa_loc.novi_email = verified_email;
|
$idaa_loc.novi_email = verified_email;
|
||||||
$idaa_loc.novi_full_name = verified_name;
|
$idaa_loc.novi_full_name = verified_name;
|
||||||
$idaa_loc.novi_verified = true;
|
$idaa_loc.novi_verified = true;
|
||||||
$idaa_loc.novi_verified_ts = Date.now();
|
$idaa_loc.novi_verified_ts = Date.now();
|
||||||
|
|
||||||
console.log(
|
console.log(`IDAA Layout: Novi UUID verified (server-side). Name: ${verified_name}, Email: ${verified_email}`);
|
||||||
`IDAA Layout: Novi UUID verified. Name: ${verified_name}, Email: ${verified_email}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine permission level based on verified UUID.
|
// Determine permission level based on verified UUID.
|
||||||
// UUID confirmed real → at minimum authenticated. Check lists for higher levels.
|
// 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';
|
$idaa_loc.bb.qry__enabled = 'enabled';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Network errors (TypeError: Failed to fetch) and AbortError (our 12s timeout) get one
|
// 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,
|
// automatic retry after a short wait — they are almost always transient.
|
||||||
// hotel WiFi hiccup, momentary Novi server lag).
|
|
||||||
const is_network_or_timeout =
|
const is_network_or_timeout =
|
||||||
error instanceof TypeError ||
|
error instanceof TypeError ||
|
||||||
(error instanceof Error && error.name === 'AbortError');
|
(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...`);
|
console.warn(`IDAA Layout: Novi verification ${reason} for ${uuid}. Retrying in 3s...`);
|
||||||
verifying_status_msg = 'Connection issue — retrying...';
|
verifying_status_msg = 'Connection issue — retrying...';
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verification failed — all-or-nothing means deny access.
|
// Verification failed — all-or-nothing means deny access.
|
||||||
console.error(
|
console.error(`IDAA Layout: Novi UUID verification failed for ${uuid}:`, 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.
|
verify_failed_for_uuid = uuid; // Latch — stop retry loop for this UUID. See declaration comment.
|
||||||
// 'rate_limited' is set before throw for 429.
|
// 'rate_limited' is set before throw for 429.
|
||||||
// 'network_error' for persistent network/timeout failures after retry — both the first
|
// 'network_error' for persistent network/timeout failures after retry.
|
||||||
// attempt AND the auto-retry failed with TypeError/AbortError. Most common cause: hotel/
|
// 'api_error' for all other failures (404 non-member, 503 Novi down, etc.).
|
||||||
// conference WiFi, VPN, or Cloudflare IP reputation blocking the client-side Novi call.
|
|
||||||
// 'api_error' for everything else (4xx, 5xx, empty 200).
|
|
||||||
if (!verify_error_type) {
|
if (!verify_error_type) {
|
||||||
verify_error_type = is_network_or_timeout ? 'network_error' : 'api_error';
|
verify_error_type = is_network_or_timeout ? 'network_error' : 'api_error';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user