// Runtime configuration for the auth/HTTP layer. // // Apps call `configureApi({...})` at startup with their API base URL // and storage-key prefix. The prefix namespaces SecureStore / // sessionStorage entries so apps that share a host (vault-on-localhost // ↔ bootstrap-on-localhost during dev) do not collide on each other's // session tokens. // // State lives on a singleton `ApiConfigStore` instance. The exported // free functions are thin wrappers so existing consumers don't need to // import the class — tests call `__resetApiConfigStoreForTests()` // between cases to isolate state. import { Platform } from "react-native"; import * as SecureStore from "expo-secure-store"; const DEFAULT_RUNTIME_CONFIG_KEY = "__ENTROPY_RUNTIME_CONFIG__"; export interface ApiConfig { /** Default API base URL (e.g. "http://localhost:8080/api"). */ apiBaseUrl: string; /** * Prefix used to namespace platform-storage entries * (`${prefix}_session_token`, `${prefix}_server_url_override`). * Lowercase with no whitespace; must be stable across releases — * changing it orphans previously stored sessions and forces re-login. */ storagePrefix: string; /** * Optional `window` key for runtime-config override (web only). Apps * that ship a docker image emitting `/config.js` set this to match * the global the script writes. Defaults to * `__ENTROPY_RUNTIME_CONFIG__`. */ runtimeConfigKey?: string; /** * Auth request shape. `"email"` (default) is the single-tenant contract * used by vault/kryptonite/simple-finance: login is `{ email, password }` * and returns `{ user, session_token }`. `"multi-tenant"` is for IdP-style * servers (access): login is `{ tenant, identifier, password }`, forgot * is `{ tenant, email }`, and the login response is the user record * directly (session is a cookie). The tenant is contextual — set it with * {@link setTenant} (e.g. from the OIDC `?tenant=` param) rather than here. */ authMode?: "email" | "multi-tenant"; /** Initial tenant for `"multi-tenant"` mode; usually set via {@link setTenant}. */ tenant?: string; } class ApiConfigStore { storagePrefix = "app"; configuredDefaultBaseUrl: string | null = null; baseUrl: string | null = null; runtimeConfigKey = DEFAULT_RUNTIME_CONFIG_KEY; authMode: "email" | "multi-tenant" = "email"; tenant: string | null = null; configure(config: ApiConfig): void { if (this.storagePrefix === "app") { this.storagePrefix = config.storagePrefix; } if (config.runtimeConfigKey) { this.runtimeConfigKey = config.runtimeConfigKey; } if (config.authMode) { this.authMode = config.authMode; } if (config.tenant) { this.tenant = config.tenant; } this.configuredDefaultBaseUrl = this.readRuntimeApiUrl() ?? config.apiBaseUrl; if (this.baseUrl === null) { this.baseUrl = this.configuredDefaultBaseUrl; } } private readRuntimeApiUrl(): string | null { if (typeof window === "undefined") { return null; } const cfg = (window as unknown as Record)[ this.runtimeConfigKey ]; return cfg && typeof cfg.apiUrl === "string" && cfg.apiUrl.length > 0 ? cfg.apiUrl : null; } /** * Test-only: reset back to factory defaults. Production code never * calls this — module-level singletons stay alive for the lifetime * of the JS context. */ reset(): void { this.storagePrefix = "app"; this.configuredDefaultBaseUrl = null; this.baseUrl = null; this.runtimeConfigKey = DEFAULT_RUNTIME_CONFIG_KEY; this.authMode = "email"; this.tenant = null; } } const store = new ApiConfigStore(); /** * Install runtime configuration. Apps call this once at startup, * BEFORE `AuthService` is used. Re-configuring at runtime is * supported but only the base URL changes — the storage prefix is * locked after first call. */ export function configureApi(config: ApiConfig): void { store.configure(config); } function sessionTokenKey(): string { return `${store.storagePrefix}_session_token`; } function serverUrlOverrideKey(): string { return `${store.storagePrefix}_server_url_override`; } export function getStoragePrefix(): string { return store.storagePrefix; } /** Current auth request shape (see {@link ApiConfig.authMode}). */ export function getAuthMode(): "email" | "multi-tenant" { return store.authMode; } /** * Active tenant for `"multi-tenant"` auth mode. In an IdP the tenant is * contextual (resolved from the OIDC `?tenant=` param / the client), not * typed by the user — the consuming app calls this before login. Returns * an empty string when unset. */ export function getTenant(): string { return store.tenant ?? ""; } /** Set the active tenant for `"multi-tenant"` auth mode. */ export function setTenant(tenant: string): void { store.tenant = tenant; } /** * Internal key-value put. Native: SecureStore (encrypted at rest, * Keychain on iOS, AES-encrypted on Android). Web: sessionStorage * (origin-isolated, but plaintext AND readable by XSS). * * Web callers MUST NOT pass secrets — only UI preferences whose * disclosure is acceptable (server-url override, etc.). The web * branch deliberately uses sessionStorage rather than localStorage * so a misconfigured caller leaks values for the current tab only. * * For session credentials, use {@link storeSessionToken} (no-op on * web — the server sets an HttpOnly cookie). For the authenticated * user record, use {@link AuthService.persistAuthSession} (web path * stores in process memory). */ export async function kvPut(key: string, value: string | null): Promise { if (Platform.OS === "web") { if (typeof window === "undefined" || !window.sessionStorage) { return; } if (value === null) { window.sessionStorage.removeItem(key); } else { window.sessionStorage.setItem(key, value); } return; } if (value === null) { await SecureStore.deleteItemAsync(key); } else { await SecureStore.setItemAsync(key, value); } } /** Internal key-value get. See {@link kvPut} for the storage tier matrix. */ export async function kvGet(key: string): Promise { if (Platform.OS === "web") { if (typeof window === "undefined" || !window.sessionStorage) { return null; } return window.sessionStorage.getItem(key); } return await SecureStore.getItemAsync(key); } export function getBaseUrl(): string { if (store.baseUrl === null) { throw new Error( "entropy-ui: api base URL is not configured. Call configureApi({ apiBaseUrl, storagePrefix }) before using AuthService." ); } return store.baseUrl; } export function getDefaultBaseUrl(): string { if (store.configuredDefaultBaseUrl === null) { throw new Error( "entropy-ui: api base URL is not configured. Call configureApi({ apiBaseUrl, storagePrefix }) before using AuthService." ); } return store.configuredDefaultBaseUrl; } export async function setBaseUrl(url: string): Promise { let parsed: URL; try { parsed = new URL(url); } catch { throw new Error("Invalid server URL"); } const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; if (parsed.protocol !== "https:" && !(isLocalhost && parsed.protocol === "http:")) { throw new Error("Server URL must use HTTPS"); } store.baseUrl = url; await kvPut(serverUrlOverrideKey(), url); } export async function resetBaseUrl(): Promise { store.baseUrl = store.configuredDefaultBaseUrl; await kvPut(serverUrlOverrideKey(), null); } export async function loadServerUrlOverride(): Promise { const override = await kvGet(serverUrlOverrideKey()); if (override) { store.baseUrl = override; } } /** * Session credential used as a `Bearer` token. * - Native: SecureStore (encrypted at rest). * - Web: sessionStorage (origin-isolated, cleared on tab close). Production * web also has the HttpOnly session cookie; the sessionStorage Bearer is what * keeps a *split-origin dev* session (Expo :8081 → API :8100) alive across a * refresh, since a SameSite=Lax cookie can't ride cross-origin XHR. Readable * by XSS — acceptable tradeoff chosen for dev parity; clears on tab close. */ export async function getSessionToken(): Promise { return await kvGet(sessionTokenKey()); } export async function storeSessionToken(token: string | null): Promise { await kvPut(sessionTokenKey(), token); } export async function clearSessionToken(): Promise { await kvPut(sessionTokenKey(), null); } /** Test-only — reset the singleton's state. */ export function __resetApiConfigStoreForTests(): void { store.reset(); }