// La única capa por la que la SPA habla con el bus. Cada llamada va al gateway Go // bajo /api; el gateway mantiene la sesión `pkg/client` (peer autenticado del // bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma, // nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de // sesión opaca (HttpOnly) que el gateway emite tras el login. import type { MeInfo, Message, MsgWire, RegisterResult, Room, RoomWire, } from "./types"; import type { WalletIdentity } from "./wallet/derive"; export class ApiError extends Error { status: number; constructor(message: string, status: number) { super(message); this.status = status; } } async function req(path: string, init?: RequestInit): Promise { const res = await fetch(path, { // same-origin envía la cookie de sesión automáticamente (también detrás del // proxy de vite en dev). credentials: "same-origin", headers: { "Content-Type": "application/json" }, ...init, }); const text = await res.text(); let body: unknown = null; if (text) { try { body = JSON.parse(text); } catch { body = text; } } if (!res.ok) { const msg = body && typeof body === "object" && "error" in body ? String((body as { error: unknown }).error) : `HTTP ${res.status}`; throw new ApiError(msg, res.status); } return body as T; } // roomFromWire mapea la fila del gateway al tipo Room que consume la UI. Los // mensajes NO viven aquí: llegan por stream(). lastMessage/lastTs/unread se // rellenan de forma neutra para no inventar datos (la cabecera de la sidebar se // alimentará del stream en una iteración futura). export function roomFromWire(r: RoomWire): Room { return { id: r.id, name: r.name || r.subject, encrypted: r.encrypt, lastMessage: "", lastTs: 0, unread: 0, messages: [], }; } // messageFromWire mapea un frame descifrado del SSE al tipo Message de la UI. export function messageFromWire(m: MsgWire): Message { return { id: m.id, sender: m.sender, body: m.body, ts: m.ts, mine: m.mine, }; } export const api = { // ---- onboarding wallet -------------------------------------------------- // register publica la identidad PÚBLICA del nuevo usuario en el allowlist del // bus usando el token del enlace de invitación. NO requiere sesión: el token // autoriza. El handle y el rol los fija el invite, no el cliente. La clave // privada NUNCA se envía aquí. register: (token: string, signPub: string, kexPub: string) => req("/api/register", { method: "POST", body: JSON.stringify({ token, sign_pub: signPub, kex_pub: kexPub }), }), // session abre una sesión POR USUARIO: el navegador entrega su identidad wallet // completa (incluida la privada, solo por TLS) y el gateway conecta un cliente // del bus que actúa COMO ese usuario. La privada vive en memoria del gateway // mientras dure la sesión; no se persiste en el servidor. session: (id: WalletIdentity, handle: string) => req("/api/session", { method: "POST", body: JSON.stringify({ handle, sign_pub: id.signPub, sign_priv: id.signPriv, kex_pub: id.kexPub, kex_priv: id.kexPriv, }), }), // ---- sesión (legacy operador) ------------------------------------------ // login desbloquea una sesión ligada al gateway del operador con su passphrase. // El camino principal ahora es el wallet (session); login se mantiene por // compatibilidad con el MVP de operador único. login: (passphrase: string) => req("/api/login", { method: "POST", body: JSON.stringify({ passphrase }), }), logout: () => req<{ status: string }>("/api/logout", { method: "POST" }), me: () => req("/api/me"), // ---- rooms -------------------------------------------------------------- listRooms: async (): Promise => { const wire = await req("/api/rooms"); return wire.map(roomFromWire); }, // createRoom: {subject, encrypted} basta — el gateway deriva la policy // Matrix-like (cifrada + persistida + firmada) por defecto. createRoom: async (subject: string, encrypted = true): Promise => { const r = await req("/api/rooms", { method: "POST", body: JSON.stringify({ subject, encrypted }), }); return roomFromWire(r); }, join: (roomID: string) => req<{ status: string }>( `/api/rooms/${encodeURIComponent(roomID)}/join`, { method: "POST" }, ), send: (roomID: string, body: string) => req<{ status: string }>( `/api/rooms/${encodeURIComponent(roomID)}/send`, { method: "POST", body: JSON.stringify({ body }) }, ), }; // streamRoom abre el SSE de una room y llama onMessage por cada frame descifrado // (historia primero en rooms persistidas, luego en vivo). Devuelve una función // de cierre. EventSource manda la cookie de sesión automáticamente y reconecta // solo si la conexión cae; onError se invoca en cada corte para que la UI pueda // reflejar el estado. export function streamRoom( roomID: string, onMessage: (m: Message) => void, onError?: (e: Event) => void, ): () => void { const es = new EventSource( `/api/rooms/${encodeURIComponent(roomID)}/stream`, ); es.onmessage = (ev) => { try { const wire = JSON.parse(ev.data) as MsgWire; onMessage(messageFromWire(wire)); } catch { // frame malformado: se ignora, el stream sigue. } }; if (onError) es.onerror = onError; return () => es.close(); }