e8e37d77fe
Extracted from unibus v0.13.0: the chat SPA (web/, React+Mantine, per-user BIP39 wallet) and the web gateway (cmd/webgw, REST+SSE) that acts as a bus peer for the browser. Consumes unibus as a Go module via replace => ../unibus, keeping its own replace fn-registry for the cybersecurity primitives. go build/vet/test and pnpm build green in the new location.
168 lines
5.5 KiB
TypeScript
168 lines
5.5 KiB
TypeScript
// 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<T>(path: string, init?: RequestInit): Promise<T> {
|
|
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<RegisterResult>("/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<MeInfo>("/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<MeInfo>("/api/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ passphrase }),
|
|
}),
|
|
logout: () => req<{ status: string }>("/api/logout", { method: "POST" }),
|
|
me: () => req<MeInfo>("/api/me"),
|
|
|
|
// ---- rooms --------------------------------------------------------------
|
|
listRooms: async (): Promise<Room[]> => {
|
|
const wire = await req<RoomWire[]>("/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<Room> => {
|
|
const r = await req<RoomWire>("/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();
|
|
}
|