// 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. The SPA is served same-origin behind a reverse proxy (Caddy): // both planes are reached through this page's OWN origin, so there is no CORS and // the cluster node IPs stay hidden behind the proxy. The control plane is the // signed HTTPS API under the relative path /api; the data plane is NATS over // WebSocket under /nats (a browser cannot open a raw TCP NATS socket). Both can // still be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS) for a dev setup // that points straight at a cluster node. const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "/api"; const BUS_WS = import.meta.env.VITE_BUS_WS ?? defaultBusWS(); // defaultBusWS derives the data-plane WebSocket URL from the page origin: the same // host and port as the SPA, the wss/ws scheme mirroring https/http, path /nats. A // browser WebSocket needs an absolute ws(s) URL, so this is computed from location // rather than left relative. Returns "" where window is absent (SSR/tests), where // the build-time override is expected instead. function defaultBusWS(): string { if (typeof window === "undefined") return ""; const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${window.location.host}/nats`; } 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 { 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 { if (session) { await session.transport.close().catch(() => {}); session = null; } }, // listRooms lists the rooms this peer belongs to. async listRooms(): Promise { 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 { 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 { 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; }