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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user