/* * Multi-instance backend registry + per-backend scoped storage. * * The web UI is backend-agnostic: it can connect to any server speaking the * `/api/v1` contract. Everything we persist (auth tokens, cached prefs, …) is * therefore bound to the *instance it came from* — never global. Each saved * backend gets a stable `id` derived from its base URL, and every persisted key * lives under the `mcma::` namespace so switching backends can never mix * one server's session/cache into another's. * * Layout in localStorage: * mcma:instances -> Instance[] (the saved-backends registry) * mcma:activeInstance -> (which one is current) * mcma::auth -> persisted auth slice (per-backend) * mcma:: -> any other scoped value */ export interface Instance { id: string; baseUrl: string; name: string; lastUsedAt: number; } const REGISTRY_KEY = 'mcma:instances'; const ACTIVE_KEY = 'mcma:activeInstance'; // pre-multi-instance keys, migrated once on first load const LEGACY_URL_KEY = 'mcma_api_base_url'; const LEGACY_AUTH_KEY = 'mcma_auth'; // The UI always talks to the `/api/v1` contract, so users only enter the // origin (and optional reverse-proxy prefix). We append the contract path // here, the single choke point for both the base URL and the instance id, so // `domain.com`, `domain.com/`, and `domain.com/api/v1` all converge. function normalizeUrl(url: string): string { const trimmed = url.trim().replace(/\/+$/, ''); return /\/api\/v1$/.test(trimmed) ? trimmed : `${trimmed}/api/v1`; } /** Stable, readable id from a base URL — also serves as the storage namespace. */ export function instanceIdFromUrl(url: string): string { const stripped = normalizeUrl(url) .toLowerCase() .replace(/^https?:\/\//, ''); const slug = stripped.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); return slug || 'default'; } function defaultName(baseUrl: string): string { try { const u = new URL( /^https?:\/\//.test(baseUrl) ? baseUrl : `http://${baseUrl}`, ); return u.host || baseUrl; } catch { return baseUrl; } } function readRegistry(): Instance[] { try { const raw = localStorage.getItem(REGISTRY_KEY); return raw ? (JSON.parse(raw) as Instance[]) : []; } catch { return []; } } function writeRegistry(list: Instance[]): void { try { localStorage.setItem(REGISTRY_KEY, JSON.stringify(list)); } catch { /* storage unavailable */ } } /** Saved backends, most-recently-used first. */ export function listInstances(): Instance[] { return readRegistry().sort((a, b) => b.lastUsedAt - a.lastUsedAt); } /** Add (or refresh) a backend in the registry and return its record. */ export function upsertInstance(url: string, name?: string): Instance { const baseUrl = normalizeUrl(url); const id = instanceIdFromUrl(baseUrl); const list = readRegistry(); const existing = list.find((i) => i.id === id); const inst: Instance = { id, baseUrl, name: name ?? existing?.name ?? defaultName(baseUrl), lastUsedAt: Date.now(), }; writeRegistry( existing ? list.map((i) => (i.id === id ? inst : i)) : [...list, inst], ); return inst; } /** Clear a backend's stored session without forgetting the instance itself. */ export function clearInstanceAuth(id: string): void { localStorage.removeItem(scopedKey('auth', id)); } /** Remove a backend and wipe every scoped key it owns. */ export function removeInstance(id: string): void { writeRegistry(readRegistry().filter((i) => i.id !== id)); clearScope(id); if (getActiveInstanceId() === id) { const next = listInstances()[0]; if (next) setActiveInstanceId(next.id); else localStorage.removeItem(ACTIVE_KEY); } } export function getActiveInstanceId(): string | null { return localStorage.getItem(ACTIVE_KEY); } export function setActiveInstanceId(id: string): void { localStorage.setItem(ACTIVE_KEY, id); } export function getActiveInstance(): Instance | null { const id = getActiveInstanceId(); if (!id) return null; return readRegistry().find((i) => i.id === id) ?? null; } /** Build the namespaced localStorage key for a value scoped to a backend. */ export function scopedKey( key: string, instanceId: string | null = getActiveInstanceId(), ): string { return `mcma:${instanceId ?? 'none'}:${key}`; } /** * Per-backend key/value store. Reads/writes are no-ops when no instance is * active, so callers never have to special-case the unconnected state. */ export const instanceStorage = { get(key: string): string | null { const id = getActiveInstanceId(); return id ? localStorage.getItem(scopedKey(key, id)) : null; }, set(key: string, value: string): void { const id = getActiveInstanceId(); if (!id) return; try { localStorage.setItem(scopedKey(key, id), value); } catch { /* storage unavailable */ } }, remove(key: string): void { const id = getActiveInstanceId(); if (id) localStorage.removeItem(scopedKey(key, id)); }, }; function clearScope(id: string): void { const prefix = `mcma:${id}:`; const toRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(prefix)) toRemove.push(k); } toRemove.forEach((k) => localStorage.removeItem(k)); } /** One-time migration of the old single-backend keys into the namespaced model. */ function migrateLegacy(): void { try { const legacyUrl = localStorage.getItem(LEGACY_URL_KEY); if (!legacyUrl || readRegistry().length > 0) return; const inst = upsertInstance(legacyUrl); const legacyAuth = localStorage.getItem(LEGACY_AUTH_KEY); if (legacyAuth) localStorage.setItem(scopedKey('auth', inst.id), legacyAuth); setActiveInstanceId(inst.id); localStorage.removeItem(LEGACY_URL_KEY); localStorage.removeItem(LEGACY_AUTH_KEY); } catch { /* best-effort */ } } // Fold any pre-multi-instance keys into the namespaced model on first load. // We deliberately do NOT seed an instance from the env default: before the // user connects there is no active instance, getApiBaseUrl() falls back to the // env default, and the connect flow registers the real backend. if (typeof localStorage !== 'undefined') { migrateLegacy(); }