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:
agent
2026-06-14 11:39:06 +02:00
parent bf0884527e
commit 3f52167b04
26 changed files with 319 additions and 1964 deletions
+154
View File
@@ -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;
}