Files
OSIT-AE-App-Svelte/src/lib/ae_api/api_patch_object.ts
Scott Idem 53c517ec30 feat: session-expired banner via ae_auth_error store
- Add ae_auth_error writable store to ae_stores.ts
- Wire api_get_object, api_post_object, api_patch_object to set
  ae_auth_error on 401/403 (browser-only guard, never fires SSR)
- Root layout watches ae_auth_error; only raises flag_expired when
  a JWT is present (prevents false trigger on unauthenticated loads)
- Dismissible amber banner added to root layout (non-blocking, above content)
- Tested via debug menu trigger; banner fires and clears correctly
2026-03-11 16:56:07 -04:00

260 lines
9.3 KiB
TypeScript

import { browser } from '$app/environment';
import { ae_auth_error } from '$lib/stores/ae_stores';
import type { key_val } from '$lib/stores/ae_stores';
/**
* Performs a PATCH request to the Aether API.
* Refactored 2026-01-08 to use standard fetch with timeout, custom fetch injection,
* standardized kebab-case headers, and robust V3 response handling.
*/
export const patch_object = async function patch_object({
api_cfg = null,
endpoint = '',
headers = {},
params = {},
data = {},
timeout = 60000,
return_meta = false,
log_lvl = 0,
retry_count = 5
}: {
api_cfg: any;
endpoint: string;
headers?: any;
params?: any;
data?: any;
timeout?: number;
return_meta?: boolean;
log_lvl?: number;
retry_count?: number;
}) {
if (log_lvl) {
console.log(`*** patch_object() *** Endpoint: ${endpoint}`);
console.log('Params:', params);
if (log_lvl > 1) {
console.log('Data:', data);
console.log(`Base URL: ${api_cfg?.['base_url']}`);
}
}
if (!api_cfg) {
console.error('No API Config was provided. Returning false.');
return false;
}
// Construct the URL with query parameters
const url = new URL(endpoint, api_cfg['base_url']);
if (params) {
Object.keys(params).forEach((key) => url.searchParams.append(key, params[key]));
}
// Clean and merge headers without mutating the original api_cfg
const headers_cleaned: key_val = {};
const merged_headers = { ...api_cfg['headers'], ...headers };
// Auto-promote account_id from api_cfg to header if missing
let account_id = merged_headers['x-account-id'] || api_cfg['account_id'];
// IMMEDIATE ACCOUNT ID SCAVENGING: Read from localStorage to avoid race conditions
if (!account_id && typeof localStorage !== 'undefined') {
try {
const ae_loc_raw = localStorage.getItem('ae_loc');
if (ae_loc_raw) {
const ae_loc_json = JSON.parse(ae_loc_raw);
if (ae_loc_json.account_id) {
account_id = ae_loc_json.account_id;
}
}
} catch (e) {
// Silently fail on storage read
}
}
if (account_id) {
merged_headers['x-account-id'] = account_id;
}
// Handle "Bootstrap Paradox" for unauthenticated requests
const bypass_val = merged_headers['x-no-account-id'] || merged_headers['x_no_account_id'];
const is_valid_bypass = bypass_val === 'bypass' ||
bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download';
if (is_valid_bypass) {
if (log_lvl > 1) console.log('api_patch_object: Valid bypass detected. Stripping account ID context.');
delete merged_headers['x-account-id'];
delete merged_headers['x_account_id'];
} else {
// If it's a placeholder (like "No_Account_ID_Here"), just remove the bypass header
// but DO NOT strip the valid Account ID.
delete merged_headers['x-no-account-id'];
delete merged_headers['x_no_account_id'];
}
for (const prop in merged_headers) {
const prop_cleaned = prop.replaceAll('_', '-');
let value = merged_headers[prop];
if (value === null || value === undefined) continue;
if (typeof value !== 'string') {
value = JSON.stringify(value);
}
headers_cleaned[prop_cleaned] = value;
}
// Auto-inject Authorization header if JWT is present but header is missing
let jwt = headers_cleaned['jwt'] ||
headers_cleaned['JWT'] ||
api_cfg['jwt'] ||
api_cfg['headers']?.['jwt'] ||
api_cfg['headers']?.['JWT'];
// Final Fallback: Direct check of primary ae_loc key
if (!jwt && typeof localStorage !== 'undefined') {
try {
const raw = localStorage.getItem('ae_loc');
if (raw) {
const json = JSON.parse(raw);
if (json.jwt) jwt = json.jwt;
}
} catch (e) {
// Silently fail on storage read
}
}
if (jwt && !headers_cleaned['Authorization'] && !headers_cleaned['authorization']) {
headers_cleaned['Authorization'] = `Bearer ${jwt}`;
}
headers_cleaned['Content-Type'] = 'application/json';
if (log_lvl > 1) {
console.log('Final cleaned headers:', headers_cleaned);
}
let fetch_method: any = fetch;
if (api_cfg.fetch) {
if (log_lvl > 1) {
console.log('Using custom fetch function from api_cfg!!!');
}
fetch_method = api_cfg.fetch;
}
for (let attempt = 1; attempt <= retry_count; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
console.error(`API PATCH request timed out after ${timeout}ms.`);
controller.abort();
}, timeout);
const fetchOptions: RequestInit = {
method: 'PATCH',
headers: headers_cleaned,
body: JSON.stringify(data),
signal: controller.signal
};
const response = await fetch_method(url.toString(), fetchOptions).catch(function (
error: any
) {
console.log(
'API PATCH Object *fetch* request was aborted or failed in an unexpected way.',
error
);
});
clearTimeout(timeoutId);
if (!response) {
throw new Error(
`HTTP fetch request was aborted or failed in an unexpected way! URL = ${url.toString()}`
);
}
if (log_lvl) {
console.log(`Response: status=${response.status} attempt=${attempt}`);
}
if (!response.ok) {
if (response.status === 404) {
if (log_lvl) {
console.log('The response was a 404 not found "error". Returning null.');
}
return null;
}
// FAIL FAST (Section 2D): Do not retry on Auth or Client errors (400, 401, 403, 422)
if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422) {
if (log_lvl) console.error(`API Client Failure (${response.status}). Failing fast.`);
if (response.status === 401 || response.status === 403) {
console.warn(`AUTH DIAGNOSTICS (PATCH): Headers sent for ${endpoint}:`, {
has_auth: !!headers_cleaned['Authorization'],
has_api_key: !!headers_cleaned['x-aether-api-key'],
has_account_id: !!headers_cleaned['x-account-id'],
jwt_preview: jwt ? `${jwt.slice(0, 8)}...` : 'MISSING'
});
// Signal the root layout to show the session-expired banner.
if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() });
}
// Structured Error Handling (V3): Attempt to get rich error metadata
let error_json: any = null;
try {
error_json = await response.json();
} catch (e) {
// Not JSON
}
if (log_lvl) console.log('The response was not ok. Structured Error Check:', error_json);
if (error_json?.meta?.details) {
return error_json;
}
// Fallback for standard FastAPI "detail" errors
if (error_json?.detail) {
return {
meta: {
success: false,
status_code: response.status,
details: {
category: 'validation',
message: typeof error_json.detail === 'string' ? error_json.detail : JSON.stringify(error_json.detail),
raw: error_json.detail
}
}
};
}
return false;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (log_lvl > 1) {
console.log('Response JSON:', json);
}
// Return the response data or metadata
// Robustly handle V3 response envelopes
return return_meta ? json : (json.data !== undefined ? json.data : json);
} catch (error) {
console.error(`API PATCH error on attempt ${attempt}:`, error);
if (attempt === retry_count) {
console.error('Max retry attempts reached. Returning false.');
return false;
}
if (log_lvl) {
console.log(`Retrying... (${attempt}/${retry_count})`);
}
}
}
};