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
+7 -14
View File
@@ -5,7 +5,7 @@ import { Join } from "./Join";
import { Recover } from "./Recover";
import { WalletLogin } from "./WalletLogin";
import { Welcome } from "./Welcome";
import { api } from "./api";
import { bus } from "./busService";
import { localIdentity } from "./wallet/account";
import type { User } from "./types";
@@ -31,9 +31,11 @@ export function App() {
const [token, setToken] = useState("");
const [storedHandle, setStoredHandle] = useState("");
// Decide the entry screen on mount: an invite link goes straight to join; a live
// gateway session resumes the chat; a device with a stored identity shows the
// password unlock; an empty device shows the welcome chooser.
// Decide the entry screen on mount: an invite link goes straight to join; a device
// with a stored identity shows the password unlock; an empty device shows the
// welcome chooser. There is no "resume session" step: the bus session lives in
// memory (the SDK runs in the browser), so a reload always re-unlocks locally
// rather than resuming a server-side cookie session.
useEffect(() => {
const t = readJoinToken();
if (t) {
@@ -43,15 +45,6 @@ export function App() {
}
let cancelled = false;
(async () => {
try {
const me = await api.me();
if (cancelled) return;
setUser({ id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) });
setRoute("chat");
return;
} catch {
// no live session — fall through
}
const stored = await localIdentity();
if (cancelled) return;
if (stored) {
@@ -73,7 +66,7 @@ export function App() {
};
const logout = () => {
void api.logout().catch(() => {});
void bus.logout().catch(() => {});
setUser(null);
// Keep the encrypted identity on the device: logging out returns to the
// password unlock, not a full reset.
+5 -5
View File
@@ -19,7 +19,7 @@ import {
IconDotsVertical,
IconPaperclip,
} from "@tabler/icons-react";
import { api, streamRoom } from "./api";
import { bus } from "./busService";
import type { Message, Room } from "./types";
function initials(s: string) {
@@ -68,7 +68,7 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
setMessages([]);
setSendError(null);
if (!room) return;
const close = streamRoom(room.id, (m) => {
const close = bus.subscribeRoom(room.id, (m) => {
setMessages((prev) =>
prev.some((p) => p.id === m.id) ? prev : [...prev, m],
);
@@ -94,9 +94,9 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
setDraft("");
setSendError(null);
try {
// No optimista: el mensaje propio vuelve por SSE con su id real (mine:true),
// evitando duplicados.
await api.send(room.id, body);
// No optimista: el mensaje propio vuelve por la suscripción con su id real
// (mine:true), evitando duplicados.
await bus.send(room.id, body);
} catch (e) {
setDraft(body); // restaura el borrador si el envío falló
setSendError(e instanceof Error ? e.message : "No se pudo enviar");
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core";
import { Sidebar } from "./Sidebar";
import { ChatPanel } from "./ChatPanel";
import { api } from "./api";
import { bus } from "./busService";
import type { Room, User } from "./types";
export function ChatShell({
@@ -19,7 +19,7 @@ export function ChatShell({
const load = useCallback(() => {
setLoading(true);
api
bus
.listRooms()
.then((rs) => {
setRooms(rs);
+13 -6
View File
@@ -21,7 +21,7 @@ import {
IconKey,
IconShieldLock,
} from "@tabler/icons-react";
import { api, ApiError } from "./api";
import { SessionError } from "./busService";
import { AuthCard, AuthHeader } from "./AuthShell";
import type { User } from "./types";
import { newMnemonic, mnemonicWords } from "./wallet/bip39";
@@ -124,14 +124,21 @@ export function Join({
setStep("joining");
setError(null);
try {
// Register the PUBLIC identity with the bus (token authorizes), then
// encrypt the private key locally and open the per-user session.
const res = await api.register(token, identity.signPub, identity.kexPub);
const user = await saveAndOpen(identity, res.handle, password);
// The bus has no token-register endpoint (that was a gateway mock): a
// browser cannot self-register on an enforce cluster. The identity must be
// allow-listed by an admin first. We persist it locally and try to open the
// session; if the identity is not yet authorized, openSession fails and we
// tell the user to have an admin authorize their public key.
const handle = identity.signPub.slice(0, 8);
const user = await saveAndOpen(identity, handle, password);
onJoined(user);
} catch (e) {
const base =
e instanceof SessionError || e instanceof Error
? e.message
: "No se pudo completar el alta.";
setError(
e instanceof ApiError ? e.message : "No se pudo completar el alta.",
`${base}. Pide a un administrador que autorice tu clave pública: ${identity.signPub}`,
);
setStep("password");
}
-89
View File
@@ -1,89 +0,0 @@
import { useState } from "react";
import {
Button,
Card,
Center,
PasswordInput,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconShieldLock, IconKey } from "@tabler/icons-react";
import { api, ApiError } from "./api";
import type { User } from "./types";
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
const [handle, setHandle] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const ready = handle.trim().length > 0 && password.length > 0;
const connect = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
// La contraseña desbloquea la sesión del gateway (passphrase del operador).
// El handle es solo el nombre a mostrar en esta iteración (wallet = fase 2).
const me = await api.login(password);
const h = handle.trim() || me.endpoint.slice(0, 8);
onLogin({ id: me.endpoint, handle: h });
} catch (e) {
setError(e instanceof ApiError ? e.message : "No se pudo conectar al gateway");
setBusy(false);
}
};
return (
<Center h="100vh" bg="dark.9">
<Card w={380} p="xl" radius="lg" withBorder bg="dark.7">
<Stack align="center" gap="lg">
<ThemeIcon size={60} radius="xl" variant="light" color="brand">
<IconShieldLock size={32} />
</ThemeIcon>
<Stack gap={2} align="center">
<Title order={2}>unibus</Title>
<Text c="dimmed" size="sm">
Mensajería cifrada de extremo a extremo
</Text>
</Stack>
<TextInput
w="100%"
label="Identidad"
placeholder="tu-handle"
value={handle}
onChange={(e) => setHandle(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && connect()}
data-autofocus
/>
<PasswordInput
w="100%"
label="Contraseña"
description="Desbloquea tu identidad cifrada en este dispositivo"
placeholder="••••••••"
leftSection={<IconKey size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void connect()}
/>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button
w="100%"
size="md"
onClick={() => void connect()}
disabled={!ready}
loading={busy}
>
Conectar
</Button>
</Stack>
</Card>
</Center>
);
}
+2 -2
View File
@@ -13,7 +13,7 @@ import {
} from "@mantine/core";
import { IconKey, IconRotateClockwise } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell";
import { ApiError } from "./api";
import { SessionError } from "./busService";
import type { User } from "./types";
import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39";
import { deriveIdentity } from "./wallet/derive";
@@ -112,7 +112,7 @@ export function Recover({
onRecovered(user);
} catch (e) {
setError(
e instanceof ApiError
e instanceof SessionError || e instanceof Error
? e.message
: "No se pudo abrir la sesión con la identidad recuperada.",
);
+4 -3
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core";
import { IconKey, IconWallet } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell";
import { ApiError } from "./api";
import { SessionError } from "./busService";
import type { User } from "./types";
import { unlockAndOpen } from "./wallet/account";
import { WrongPasswordError } from "./wallet/crypto";
@@ -33,10 +33,11 @@ export function WalletLogin({
} catch (e) {
if (e instanceof WrongPasswordError) {
setError("Contraseña incorrecta.");
} else if (e instanceof ApiError) {
} else if (e instanceof SessionError) {
setError(e.message);
} else {
setError("No se pudo abrir tu identidad.");
// A connection/authorization failure (e.g. identity not yet allow-listed).
setError(e instanceof Error ? e.message : "No se pudo abrir tu identidad.");
}
setBusy(false);
}
-167
View File
@@ -1,167 +0,0 @@
// La única capa por la que la SPA habla con el bus. Cada llamada va al gateway Go
// bajo /api; el gateway mantiene la sesión `pkg/client` (peer autenticado del
// bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma,
// nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de
// sesión opaca (HttpOnly) que el gateway emite tras el login.
import type {
MeInfo,
Message,
MsgWire,
RegisterResult,
Room,
RoomWire,
} from "./types";
import type { WalletIdentity } from "./wallet/derive";
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
// same-origin envía la cookie de sesión automáticamente (también detrás del
// proxy de vite en dev).
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
...init,
});
const text = await res.text();
let body: unknown = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
const msg =
body && typeof body === "object" && "error" in body
? String((body as { error: unknown }).error)
: `HTTP ${res.status}`;
throw new ApiError(msg, res.status);
}
return body as T;
}
// roomFromWire mapea la fila del gateway al tipo Room que consume la UI. Los
// mensajes NO viven aquí: llegan por stream(). lastMessage/lastTs/unread se
// rellenan de forma neutra para no inventar datos (la cabecera de la sidebar se
// alimentará del stream en una iteración futura).
export function roomFromWire(r: RoomWire): Room {
return {
id: r.id,
name: r.name || r.subject,
encrypted: r.encrypt,
lastMessage: "",
lastTs: 0,
unread: 0,
messages: [],
};
}
// messageFromWire mapea un frame descifrado del SSE al tipo Message de la UI.
export function messageFromWire(m: MsgWire): Message {
return {
id: m.id,
sender: m.sender,
body: m.body,
ts: m.ts,
mine: m.mine,
};
}
export const api = {
// ---- onboarding wallet --------------------------------------------------
// register publica la identidad PÚBLICA del nuevo usuario en el allowlist del
// bus usando el token del enlace de invitación. NO requiere sesión: el token
// autoriza. El handle y el rol los fija el invite, no el cliente. La clave
// privada NUNCA se envía aquí.
register: (token: string, signPub: string, kexPub: string) =>
req<RegisterResult>("/api/register", {
method: "POST",
body: JSON.stringify({ token, sign_pub: signPub, kex_pub: kexPub }),
}),
// session abre una sesión POR USUARIO: el navegador entrega su identidad wallet
// completa (incluida la privada, solo por TLS) y el gateway conecta un cliente
// del bus que actúa COMO ese usuario. La privada vive en memoria del gateway
// mientras dure la sesión; no se persiste en el servidor.
session: (id: WalletIdentity, handle: string) =>
req<MeInfo>("/api/session", {
method: "POST",
body: JSON.stringify({
handle,
sign_pub: id.signPub,
sign_priv: id.signPriv,
kex_pub: id.kexPub,
kex_priv: id.kexPriv,
}),
}),
// ---- sesión (legacy operador) ------------------------------------------
// login desbloquea una sesión ligada al gateway del operador con su passphrase.
// El camino principal ahora es el wallet (session); login se mantiene por
// compatibilidad con el MVP de operador único.
login: (passphrase: string) =>
req<MeInfo>("/api/login", {
method: "POST",
body: JSON.stringify({ passphrase }),
}),
logout: () => req<{ status: string }>("/api/logout", { method: "POST" }),
me: () => req<MeInfo>("/api/me"),
// ---- rooms --------------------------------------------------------------
listRooms: async (): Promise<Room[]> => {
const wire = await req<RoomWire[]>("/api/rooms");
return wire.map(roomFromWire);
},
// createRoom: {subject, encrypted} basta — el gateway deriva la policy
// Matrix-like (cifrada + persistida + firmada) por defecto.
createRoom: async (subject: string, encrypted = true): Promise<Room> => {
const r = await req<RoomWire>("/api/rooms", {
method: "POST",
body: JSON.stringify({ subject, encrypted }),
});
return roomFromWire(r);
},
join: (roomID: string) =>
req<{ status: string }>(
`/api/rooms/${encodeURIComponent(roomID)}/join`,
{ method: "POST" },
),
send: (roomID: string, body: string) =>
req<{ status: string }>(
`/api/rooms/${encodeURIComponent(roomID)}/send`,
{ method: "POST", body: JSON.stringify({ body }) },
),
};
// streamRoom abre el SSE de una room y llama onMessage por cada frame descifrado
// (historia primero en rooms persistidas, luego en vivo). Devuelve una función
// de cierre. EventSource manda la cookie de sesión automáticamente y reconecta
// solo si la conexión cae; onError se invoca en cada corte para que la UI pueda
// reflejar el estado.
export function streamRoom(
roomID: string,
onMessage: (m: Message) => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(
`/api/rooms/${encodeURIComponent(roomID)}/stream`,
);
es.onmessage = (ev) => {
try {
const wire = JSON.parse(ev.data) as MsgWire;
onMessage(messageFromWire(wire));
} catch {
// frame malformado: se ignora, el stream sigue.
}
};
if (onError) es.onerror = onError;
return () => es.close();
}
+20
View File
@@ -172,6 +172,14 @@ interface MemberJSON {
sign_pub: string; // base64
}
// MemberRoomWire is one row of GET /members/{endpoint}/rooms.
interface MemberRoomWire {
room_id: string;
subject: string;
epoch: number;
policy: PolicyWire;
}
// ControlPlane is the signed HTTP client for the membershipd control plane. Every
// request carries the X-Unibus-* auth headers (busauth.signedHeaders). It pins no
// host so it can target any cluster node.
@@ -261,6 +269,18 @@ export class ControlPlane {
return { key, epoch: resp.epoch };
}
// listMemberRooms returns the rooms a peer belongs to (GET /members/{endpoint}/rooms),
// mapping the wire shape (room_id, snake_case policy) to the SDK Room type.
async listMemberRooms(endpoint: string): Promise<Room[]> {
const wire = await this.request<MemberRoomWire[]>("GET", `/members/${endpoint}/rooms`);
return wire.map((r) => ({
id: r.room_id,
subject: r.subject,
epoch: r.epoch,
policy: { encrypt: r.policy.encrypt, persist: r.policy.persist, signMsgs: r.policy.sign_msgs },
}));
}
// listMembers returns the room's members keyed by endpoint, so a receiver can find
// a sender's signing public key to verify message signatures.
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
+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;
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
// Build-time configuration for the bus endpoints. Both are optional; busService
// falls back to a cluster node when unset.
interface ImportMetaEnv {
readonly VITE_BUS_HTTP?: string;
readonly VITE_BUS_WS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+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
+3 -2
View File
@@ -1,7 +1,8 @@
// IndexedDB persistence of the device-local wallet. Only the encrypted private
// key plus the public halves and the display handle are stored — never the
// password, never the BIP39 seed. The private key never leaves the device except
// over TLS to the gateway to open a session (see api.session).
// password, never the BIP39 seed. The private key NEVER leaves the device at all:
// the bus session is opened in the browser (see busService.openSession), which signs
// and decrypts locally — there is no server to send the key to.
//
// MVP: one active identity per device (keyed by a fixed id). Multi-account on a
// single device is a documented gap.