e12894099f
The SPA is now served behind a same-origin reverse proxy (Caddy) that fronts both bus planes, so the data layer reaches them through the page's own origin instead of a hardcoded cluster node IP. This removes CORS entirely and hides the cluster IPs behind the proxy. - BUS_HTTP falls back to the relative path /api (the signed HTTPS control plane), resolved against the page origin by ControlPlane's fetch. - BUS_WS falls back to a wss URL derived from window.location (same host, scheme mirroring https/http, path /nats), since a browser WebSocket needs an absolute ws(s) URL. - The raw self-signed-IP fallback (https://51.91.100.142:8470, wss://...:8480) is gone. The VITE_BUS_HTTP / VITE_BUS_WS build-time overrides remain for a dev setup that points straight at a cluster node. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
6.2 KiB
TypeScript
170 lines
6.2 KiB
TypeScript
// 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<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;
|
|
}
|