feat: browser-native client — wire SPA to the SDK, delete the Go gateway
Phase 2 of issue 0001. uniweb becomes a pure frontend (web/ only), like unibus_android: the SPA talks directly to the bus and the Go gateway is gone. - busService.ts: the new data layer over the bus SDK, replacing the old api module. It holds the user's wallet identity and a connected BusClient IN THE BROWSER and opens the session locally — the private key is never sent anywhere (closes the gateway-era hole where the browser POSTed its private key to /api/session). - Wire account/App/ChatShell/ChatPanel/WalletLogin/Recover/Join to busService; subscribeRoom replaces the SSE streamRoom; ApiError -> SessionError. - SDK: ControlPlane.createRoom + listMemberRooms, and fetchRoom mapped to the real control-plane wire shape (snake_case, no id) — all verified by the live round-trip. - Delete cmd/webgw, go.mod, go.sum, src/api.ts and the orphan operator Login. uniweb now has zero Go and no dependency on unibus as a module. - vite: drop the /api proxy, dev server on 5173 to match the bus CORS allowlist; add vite-env typings. app.md: lang ts, no uses_functions, e2e_checks are now web-only. Bump 0.3.0. Onboarding by token is now admin-side (the bus has no self-register endpoint; the gateway only mocked it). tsc + pnpm build + 19/19 unit green.
This commit is contained in:
+7
-14
@@ -5,7 +5,7 @@ import { Join } from "./Join";
|
||||
import { Recover } from "./Recover";
|
||||
import { WalletLogin } from "./WalletLogin";
|
||||
import { Welcome } from "./Welcome";
|
||||
import { api } from "./api";
|
||||
import { bus } from "./busService";
|
||||
import { localIdentity } from "./wallet/account";
|
||||
import type { User } from "./types";
|
||||
|
||||
@@ -31,9 +31,11 @@ export function App() {
|
||||
const [token, setToken] = useState("");
|
||||
const [storedHandle, setStoredHandle] = useState("");
|
||||
|
||||
// Decide the entry screen on mount: an invite link goes straight to join; a live
|
||||
// gateway session resumes the chat; a device with a stored identity shows the
|
||||
// password unlock; an empty device shows the welcome chooser.
|
||||
// Decide the entry screen on mount: an invite link goes straight to join; a device
|
||||
// with a stored identity shows the password unlock; an empty device shows the
|
||||
// welcome chooser. There is no "resume session" step: the bus session lives in
|
||||
// memory (the SDK runs in the browser), so a reload always re-unlocks locally
|
||||
// rather than resuming a server-side cookie session.
|
||||
useEffect(() => {
|
||||
const t = readJoinToken();
|
||||
if (t) {
|
||||
@@ -43,15 +45,6 @@ export function App() {
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api.me();
|
||||
if (cancelled) return;
|
||||
setUser({ id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) });
|
||||
setRoute("chat");
|
||||
return;
|
||||
} catch {
|
||||
// no live session — fall through
|
||||
}
|
||||
const stored = await localIdentity();
|
||||
if (cancelled) return;
|
||||
if (stored) {
|
||||
@@ -73,7 +66,7 @@ export function App() {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
void api.logout().catch(() => {});
|
||||
void bus.logout().catch(() => {});
|
||||
setUser(null);
|
||||
// Keep the encrypted identity on the device: logging out returns to the
|
||||
// password unlock, not a full reset.
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
IconDotsVertical,
|
||||
IconPaperclip,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, streamRoom } from "./api";
|
||||
import { bus } from "./busService";
|
||||
import type { Message, Room } from "./types";
|
||||
|
||||
function initials(s: string) {
|
||||
@@ -68,7 +68,7 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
|
||||
setMessages([]);
|
||||
setSendError(null);
|
||||
if (!room) return;
|
||||
const close = streamRoom(room.id, (m) => {
|
||||
const close = bus.subscribeRoom(room.id, (m) => {
|
||||
setMessages((prev) =>
|
||||
prev.some((p) => p.id === m.id) ? prev : [...prev, m],
|
||||
);
|
||||
@@ -94,9 +94,9 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
|
||||
setDraft("");
|
||||
setSendError(null);
|
||||
try {
|
||||
// No optimista: el mensaje propio vuelve por SSE con su id real (mine:true),
|
||||
// evitando duplicados.
|
||||
await api.send(room.id, body);
|
||||
// No optimista: el mensaje propio vuelve por la suscripción con su id real
|
||||
// (mine:true), evitando duplicados.
|
||||
await bus.send(room.id, body);
|
||||
} catch (e) {
|
||||
setDraft(body); // restaura el borrador si el envío falló
|
||||
setSendError(e instanceof Error ? e.message : "No se pudo enviar");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
import { api } from "./api";
|
||||
import { bus } from "./busService";
|
||||
import type { Room, User } from "./types";
|
||||
|
||||
export function ChatShell({
|
||||
@@ -19,7 +19,7 @@ export function ChatShell({
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
bus
|
||||
.listRooms()
|
||||
.then((rs) => {
|
||||
setRooms(rs);
|
||||
|
||||
+13
-6
@@ -21,7 +21,7 @@ import {
|
||||
IconKey,
|
||||
IconShieldLock,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, ApiError } from "./api";
|
||||
import { SessionError } from "./busService";
|
||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||
import type { User } from "./types";
|
||||
import { newMnemonic, mnemonicWords } from "./wallet/bip39";
|
||||
@@ -124,14 +124,21 @@ export function Join({
|
||||
setStep("joining");
|
||||
setError(null);
|
||||
try {
|
||||
// Register the PUBLIC identity with the bus (token authorizes), then
|
||||
// encrypt the private key locally and open the per-user session.
|
||||
const res = await api.register(token, identity.signPub, identity.kexPub);
|
||||
const user = await saveAndOpen(identity, res.handle, password);
|
||||
// The bus has no token-register endpoint (that was a gateway mock): a
|
||||
// browser cannot self-register on an enforce cluster. The identity must be
|
||||
// allow-listed by an admin first. We persist it locally and try to open the
|
||||
// session; if the identity is not yet authorized, openSession fails and we
|
||||
// tell the user to have an admin authorize their public key.
|
||||
const handle = identity.signPub.slice(0, 8);
|
||||
const user = await saveAndOpen(identity, handle, password);
|
||||
onJoined(user);
|
||||
} catch (e) {
|
||||
const base =
|
||||
e instanceof SessionError || e instanceof Error
|
||||
? e.message
|
||||
: "No se pudo completar el alta.";
|
||||
setError(
|
||||
e instanceof ApiError ? e.message : "No se pudo completar el alta.",
|
||||
`${base}. Pide a un administrador que autorice tu clave pública: ${identity.signPub}`,
|
||||
);
|
||||
setStep("password");
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldLock, IconKey } from "@tabler/icons-react";
|
||||
import { api, ApiError } from "./api";
|
||||
import type { User } from "./types";
|
||||
|
||||
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
|
||||
const [handle, setHandle] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ready = handle.trim().length > 0 && password.length > 0;
|
||||
const connect = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// La contraseña desbloquea la sesión del gateway (passphrase del operador).
|
||||
// El handle es solo el nombre a mostrar en esta iteración (wallet = fase 2).
|
||||
const me = await api.login(password);
|
||||
const h = handle.trim() || me.endpoint.slice(0, 8);
|
||||
onLogin({ id: me.endpoint, handle: h });
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "No se pudo conectar al gateway");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center h="100vh" bg="dark.9">
|
||||
<Card w={380} p="xl" radius="lg" withBorder bg="dark.7">
|
||||
<Stack align="center" gap="lg">
|
||||
<ThemeIcon size={60} radius="xl" variant="light" color="brand">
|
||||
<IconShieldLock size={32} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} align="center">
|
||||
<Title order={2}>unibus</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Mensajería cifrada de extremo a extremo
|
||||
</Text>
|
||||
</Stack>
|
||||
<TextInput
|
||||
w="100%"
|
||||
label="Identidad"
|
||||
placeholder="tu-handle"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
w="100%"
|
||||
label="Contraseña"
|
||||
description="Desbloquea tu identidad cifrada en este dispositivo"
|
||||
placeholder="••••••••"
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void connect()}
|
||||
/>
|
||||
{error && (
|
||||
<Text c="red" size="sm" ta="center">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
w="100%"
|
||||
size="md"
|
||||
onClick={() => void connect()}
|
||||
disabled={!ready}
|
||||
loading={busy}
|
||||
>
|
||||
Conectar
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -13,7 +13,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconKey, IconRotateClockwise } from "@tabler/icons-react";
|
||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||
import { ApiError } from "./api";
|
||||
import { SessionError } from "./busService";
|
||||
import type { User } from "./types";
|
||||
import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39";
|
||||
import { deriveIdentity } from "./wallet/derive";
|
||||
@@ -112,7 +112,7 @@ export function Recover({
|
||||
onRecovered(user);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof ApiError
|
||||
e instanceof SessionError || e instanceof Error
|
||||
? e.message
|
||||
: "No se pudo abrir la sesión con la identidad recuperada.",
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core";
|
||||
import { IconKey, IconWallet } from "@tabler/icons-react";
|
||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||
import { ApiError } from "./api";
|
||||
import { SessionError } from "./busService";
|
||||
import type { User } from "./types";
|
||||
import { unlockAndOpen } from "./wallet/account";
|
||||
import { WrongPasswordError } from "./wallet/crypto";
|
||||
@@ -33,10 +33,11 @@ export function WalletLogin({
|
||||
} catch (e) {
|
||||
if (e instanceof WrongPasswordError) {
|
||||
setError("Contraseña incorrecta.");
|
||||
} else if (e instanceof ApiError) {
|
||||
} else if (e instanceof SessionError) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError("No se pudo abrir tu identidad.");
|
||||
// A connection/authorization failure (e.g. identity not yet allow-listed).
|
||||
setError(e instanceof Error ? e.message : "No se pudo abrir tu identidad.");
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
@@ -172,6 +172,14 @@ interface MemberJSON {
|
||||
sign_pub: string; // base64
|
||||
}
|
||||
|
||||
// MemberRoomWire is one row of GET /members/{endpoint}/rooms.
|
||||
interface MemberRoomWire {
|
||||
room_id: string;
|
||||
subject: string;
|
||||
epoch: number;
|
||||
policy: PolicyWire;
|
||||
}
|
||||
|
||||
// ControlPlane is the signed HTTP client for the membershipd control plane. Every
|
||||
// request carries the X-Unibus-* auth headers (busauth.signedHeaders). It pins no
|
||||
// host so it can target any cluster node.
|
||||
@@ -261,6 +269,18 @@ export class ControlPlane {
|
||||
return { key, epoch: resp.epoch };
|
||||
}
|
||||
|
||||
// listMemberRooms returns the rooms a peer belongs to (GET /members/{endpoint}/rooms),
|
||||
// mapping the wire shape (room_id, snake_case policy) to the SDK Room type.
|
||||
async listMemberRooms(endpoint: string): Promise<Room[]> {
|
||||
const wire = await this.request<MemberRoomWire[]>("GET", `/members/${endpoint}/rooms`);
|
||||
return wire.map((r) => ({
|
||||
id: r.room_id,
|
||||
subject: r.subject,
|
||||
epoch: r.epoch,
|
||||
policy: { encrypt: r.policy.encrypt, persist: r.policy.persist, signMsgs: r.policy.sign_msgs },
|
||||
}));
|
||||
}
|
||||
|
||||
// listMembers returns the room's members keyed by endpoint, so a receiver can find
|
||||
// a sender's signing public key to verify message signatures.
|
||||
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// The single data layer of the SPA — the browser-native replacement for the old
|
||||
// `api` module. Where `api` talked to a Go gateway under /api (cookie session, SSE,
|
||||
// and the private key shipped to the server), this talks DIRECTLY to the bus:
|
||||
//
|
||||
// - control plane: signed HTTPS to membershipd (rooms, keys, members), and
|
||||
// - data plane: nats.ws to NATS,
|
||||
//
|
||||
// using the user's wallet identity, which stays in the browser. The private key
|
||||
// signs and decrypts here and is NEVER sent anywhere (issue uniweb/0001, Phase 2).
|
||||
//
|
||||
// The exported `bus` object mirrors the old `api` surface so the page components
|
||||
// change only their import; streamRoom is replaced by bus.subscribeRoom.
|
||||
|
||||
import {
|
||||
BusClient,
|
||||
ControlPlane,
|
||||
WsNatsTransport,
|
||||
hexToBytes,
|
||||
endpointID,
|
||||
type Identity,
|
||||
type Frame,
|
||||
ModeMatrix,
|
||||
} from "./bus/index";
|
||||
import type { WalletIdentity } from "./wallet/derive";
|
||||
import type { MeInfo, Message, Room, User } from "./types";
|
||||
|
||||
// Bus endpoints. A browser cannot open a raw TCP NATS socket, so the data plane is
|
||||
// reached over WebSocket; the control plane is the signed HTTPS API. Both default to
|
||||
// a cluster node and can be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS).
|
||||
const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "https://51.91.100.142:8470";
|
||||
const BUS_WS = import.meta.env.VITE_BUS_WS ?? "wss://51.91.100.142:8480";
|
||||
|
||||
export class SessionError extends Error {}
|
||||
|
||||
// toIdentity maps the wallet's hex identity to the SDK's byte identity. The private
|
||||
// halves stay in memory only.
|
||||
function toIdentity(w: WalletIdentity): Identity {
|
||||
return {
|
||||
signPub: hexToBytes(w.signPub),
|
||||
signPriv: hexToBytes(w.signPriv),
|
||||
kexPub: hexToBytes(w.kexPub),
|
||||
kexPriv: hexToBytes(w.kexPriv),
|
||||
};
|
||||
}
|
||||
|
||||
// A live session: the connected BusClient plus the display identity. Held in a
|
||||
// module singleton — one active wallet per tab (MVP), like the wallet store.
|
||||
interface Session {
|
||||
identity: Identity;
|
||||
handle: string;
|
||||
endpoint: string;
|
||||
control: ControlPlane;
|
||||
transport: WsNatsTransport;
|
||||
client: BusClient;
|
||||
}
|
||||
|
||||
let session: Session | null = null;
|
||||
|
||||
function require_(): Session {
|
||||
if (!session) throw new SessionError("no active bus session");
|
||||
return session;
|
||||
}
|
||||
|
||||
export const bus = {
|
||||
// openSession connects to the bus AS this wallet user: it builds the signed
|
||||
// control-plane client and the nats.ws data-plane connection in the browser. The
|
||||
// private key never leaves — this is the fix for the old gateway model where the
|
||||
// browser POSTed its private key to /api/session.
|
||||
async openSession(wallet: WalletIdentity, handle: string): Promise<User> {
|
||||
const identity = toIdentity(wallet);
|
||||
const endpoint = endpointID(identity.signPub);
|
||||
const control = new ControlPlane(BUS_HTTP, identity);
|
||||
const transport = await WsNatsTransport.connect([BUS_WS], identity);
|
||||
const client = new BusClient(identity, transport, control);
|
||||
session = { identity, handle, endpoint, control, transport, client };
|
||||
return { id: endpoint, handle: handle || endpoint.slice(0, 8) };
|
||||
},
|
||||
|
||||
// me returns the identity of the active session (was GET /api/me).
|
||||
me(): MeInfo {
|
||||
const s = require_();
|
||||
return { endpoint: s.endpoint, sign_pub: "", handle: s.handle };
|
||||
},
|
||||
|
||||
// logout closes the data-plane connection and drops the session.
|
||||
async logout(): Promise<void> {
|
||||
if (session) {
|
||||
await session.transport.close().catch(() => {});
|
||||
session = null;
|
||||
}
|
||||
},
|
||||
|
||||
// listRooms lists the rooms this peer belongs to.
|
||||
async listRooms(): Promise<Room[]> {
|
||||
const s = require_();
|
||||
const wire = await s.control.listMemberRooms(s.endpoint);
|
||||
return wire.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.subject,
|
||||
encrypted: r.policy.encrypt,
|
||||
lastMessage: "",
|
||||
lastTs: 0,
|
||||
unread: 0,
|
||||
messages: [],
|
||||
}));
|
||||
},
|
||||
|
||||
// createRoom creates an encrypted, signed room owned by this peer (the Matrix-like
|
||||
// default). Returns the UI Room.
|
||||
async createRoom(subject: string): Promise<Room> {
|
||||
const s = require_();
|
||||
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
|
||||
return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] };
|
||||
},
|
||||
|
||||
// send publishes a plaintext message to a room; the SDK seals + signs it per the
|
||||
// room policy before it hits the wire.
|
||||
async send(roomID: string, body: string): Promise<void> {
|
||||
const s = require_();
|
||||
await s.client.publish(roomID, new TextEncoder().encode(body));
|
||||
},
|
||||
|
||||
// subscribeRoom delivers decrypted, verified messages for a room (replaces the old
|
||||
// SSE streamRoom). Returns an unsubscribe function.
|
||||
subscribeRoom(roomID: string, onMessage: (m: Message) => void): () => void {
|
||||
const s = require_();
|
||||
let unsub: (() => void) | null = null;
|
||||
let closed = false;
|
||||
s.client
|
||||
.subscribe(roomID, (f: Frame, plaintext: Uint8Array) => {
|
||||
onMessage({
|
||||
id: f.msgID,
|
||||
sender: f.sender,
|
||||
body: new TextDecoder().decode(plaintext),
|
||||
ts: Date.now(),
|
||||
mine: f.sender === s.endpoint,
|
||||
});
|
||||
})
|
||||
.then((sub) => {
|
||||
if (closed) void sub.unsubscribe();
|
||||
else unsub = () => void sub.unsubscribe();
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
closed = true;
|
||||
if (unsub) unsub();
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// hasSession reports whether a bus session is currently open (for the router).
|
||||
export function hasSession(): boolean {
|
||||
return session !== null;
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Build-time configuration for the bus endpoints. Both are optional; busService
|
||||
// falls back to a cluster node when unset.
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BUS_HTTP?: string;
|
||||
readonly VITE_BUS_WS?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
+13
-18
@@ -1,22 +1,19 @@
|
||||
// High-level wallet account operations shared by the join, recover and login
|
||||
// flows. These compose the low-level primitives (derive / crypto / store) with
|
||||
// the gateway API so the page components stay thin.
|
||||
// flows. These compose the low-level primitives (derive / crypto / store) with the
|
||||
// browser-native bus session so the page components stay thin.
|
||||
|
||||
import { api } from "../api";
|
||||
import type { MeInfo, User } from "../types";
|
||||
import { bus } from "../busService";
|
||||
import type { User } from "../types";
|
||||
import { decryptJSON, encryptJSON } from "./crypto";
|
||||
import type { WalletIdentity } from "./derive";
|
||||
import { getIdentity, putIdentity, type StoredIdentity } from "./store";
|
||||
|
||||
function toUser(me: MeInfo): User {
|
||||
return { id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) };
|
||||
}
|
||||
|
||||
// saveAndOpen encrypts the identity under `password`, stores it on this device,
|
||||
// and opens a gateway session as that user. Used by join (new identity) and
|
||||
// recover (re-derived identity): both end with a locally-encrypted key plus a
|
||||
// live per-user session. The mnemonic/seed is NOT touched here — only the derived
|
||||
// keypair is persisted (encrypted).
|
||||
// saveAndOpen encrypts the identity under `password`, stores it on this device, and
|
||||
// opens a bus session as that user. Used by join (new identity) and recover
|
||||
// (re-derived identity): both end with a locally-encrypted key plus a live session.
|
||||
// The mnemonic/seed is NOT touched here — only the derived keypair is persisted
|
||||
// (encrypted). The private key is used to open the session IN THE BROWSER and is
|
||||
// never sent to any server (unlike the old gateway model).
|
||||
export async function saveAndOpen(
|
||||
identity: WalletIdentity,
|
||||
handle: string,
|
||||
@@ -30,19 +27,17 @@ export async function saveAndOpen(
|
||||
enc,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const me = await api.session(identity, handle);
|
||||
return toUser(me);
|
||||
return bus.openSession(identity, handle);
|
||||
}
|
||||
|
||||
// unlockAndOpen reads this device's stored identity, decrypts the private key with
|
||||
// `password`, and opens a gateway session. Throws WrongPasswordError on a bad
|
||||
// `password`, and opens a bus session locally. Throws WrongPasswordError on a bad
|
||||
// password (GCM auth failure) and NoLocalIdentityError if the device has none.
|
||||
export async function unlockAndOpen(password: string): Promise<User> {
|
||||
const stored = await getIdentity();
|
||||
if (!stored) throw new NoLocalIdentityError();
|
||||
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
|
||||
const me = await api.session(identity, stored.handle);
|
||||
return toUser(me);
|
||||
return bus.openSession(identity, stored.handle);
|
||||
}
|
||||
|
||||
// localIdentity returns the device's stored identity record (or null), for the
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// IndexedDB persistence of the device-local wallet. Only the encrypted private
|
||||
// key plus the public halves and the display handle are stored — never the
|
||||
// password, never the BIP39 seed. The private key never leaves the device except
|
||||
// over TLS to the gateway to open a session (see api.session).
|
||||
// password, never the BIP39 seed. The private key NEVER leaves the device at all:
|
||||
// the bus session is opened in the browser (see busService.openSession), which signs
|
||||
// and decrypts locally — there is no server to send the key to.
|
||||
//
|
||||
// MVP: one active identity per device (keyed by a fixed id). Multi-account on a
|
||||
// single device is a documented gap.
|
||||
|
||||
+5
-5
@@ -3,12 +3,12 @@ import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// En dev, /api (REST + SSE) se proxea al gateway Go (cmd/webgw, puerto 8481).
|
||||
// El proxy hace streaming, así que el SSE de /api/rooms/{id}/stream funciona a
|
||||
// través de él. En producción el gateway sirve el dist embebido y no hay proxy.
|
||||
// The SPA talks DIRECTLY to the bus (signed HTTPS control plane + nats.ws data
|
||||
// plane), so there is no gateway and no /api proxy. The dev server runs on 5173 to
|
||||
// match the bus CORS allowlist (--cors-origins http://localhost:5173). Point the
|
||||
// SPA at a cluster node with VITE_BUS_HTTP / VITE_BUS_WS (see busService.ts).
|
||||
server: {
|
||||
host: true,
|
||||
port: 5183,
|
||||
proxy: { "/api": "http://127.0.0.1:8481" },
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user