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
+13 -18
View File
@@ -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