Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59705b5a4f | |||
| 63ebc1eed9 | |||
| 893df42d29 | |||
| c142b3a025 | |||
| 45d12e03aa | |||
| 3049265230 | |||
| 6c4baf1397 | |||
| 5fbf319172 | |||
| 5e9bf4e777 | |||
| 103a7f2f05 | |||
| 1dc8b6257a | |||
| f8b2bf8e9e | |||
| e12894099f |
@@ -2,7 +2,7 @@
|
|||||||
name: uniweb
|
name: uniweb
|
||||||
lang: ts
|
lang: ts
|
||||||
domain: infra
|
domain: infra
|
||||||
version: 0.3.0
|
version: 0.6.0
|
||||||
description: "Cliente web browser-nativo del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) que habla DIRECTO al bus (nats.ws + control-plane HTTPS firmado), sin gateway. La clave privada nunca sale del navegador."
|
description: "Cliente web browser-nativo del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) que habla DIRECTO al bus (nats.ws + control-plane HTTPS firmado), sin gateway. La clave privada nunca sale del navegador."
|
||||||
tags: [messaging, web, frontend, e2e]
|
tags: [messaging, web, frontend, e2e]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
@@ -62,14 +62,18 @@ total.
|
|||||||
## Ejemplo
|
## Ejemplo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# El bus ya corre (cluster unibus con WebSocket habilitado, --ws-port). Apunta la SPA a un
|
# Producción: SPA same-origin detrás de Caddy, que sirve la SPA + /api + /nats.
|
||||||
# nodo y arráncala en dev (puerto 5173, que coincide con la CORS allowlist del cluster):
|
# Build estático y despliegue de web/dist:
|
||||||
cd web && pnpm install
|
cd web && pnpm install
|
||||||
VITE_BUS_HTTP=https://<nodo>:8470 VITE_BUS_WS=wss://<nodo>:8480 pnpm dev
|
pnpm build # genera web/dist (se despliega a magnus:/opt/uniweb/dist)
|
||||||
# Navegador: http://localhost:5173
|
|
||||||
|
|
||||||
# Producción: build estático y sirve web/dist con cualquier static server.
|
# Dev (`pnpm dev`): el dev server NO tiene el proxy de Caddy, así que /api y /nats no
|
||||||
cd web && pnpm build # genera web/dist
|
# existen en localhost. Apunta la SPA a un nodo real del cluster con las env vars
|
||||||
|
# (overrides del default same-origin). El dev server corre en el puerto 5174:
|
||||||
|
VITE_BUS_HTTP=https://<nodo>:8470 VITE_BUS_WS=wss://<nodo>:8480 pnpm dev
|
||||||
|
# Navegador: http://localhost:5174
|
||||||
|
# (Añade http://localhost:5174 a la --cors-origins del nodo, o el control-plane
|
||||||
|
# rechazará la petición por CORS.)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuándo usarla
|
## Cuándo usarla
|
||||||
@@ -87,14 +91,44 @@ programáticos) ve a `unibus`; `uniweb` es el cliente web encima.
|
|||||||
*mockeaba*). En `enforce`, una identidad nueva debe ser autorizada por un admin
|
*mockeaba*). En `enforce`, una identidad nueva debe ser autorizada por un admin
|
||||||
(`membershipd user add`) antes de poder abrir sesión; el flujo de Join muestra la clave
|
(`membershipd user add`) antes de poder abrir sesión; el flujo de Join muestra la clave
|
||||||
pública del usuario para que un admin la autorice.
|
pública del usuario para que un admin la autorice.
|
||||||
- **CORS**: el dev server corre en `http://localhost:5173` para coincidir con la
|
- **CORS / same-origin**: en producción la SPA es same-origin detrás de Caddy (`/api` y
|
||||||
`--cors-origins` del cluster. Otro origen necesita añadirse a esa allowlist.
|
`/nats` proxyados), así que no hay CORS. En dev (`pnpm dev`, puerto 5174) esos paths
|
||||||
|
relativos no existen: hay que apuntar a un nodo con `VITE_BUS_HTTP`/`VITE_BUS_WS` y
|
||||||
|
añadir `http://localhost:5174` a la `--cors-origins` del nodo. El puerto 5173 está
|
||||||
|
reservado a otra app local; si 5174 está ocupado, Vite usa el siguiente libre.
|
||||||
- La passphrase del wallet nunca se guarda ni se envía; perderla en un dispositivo sin la
|
- La passphrase del wallet nunca se guarda ni se envía; perderla en un dispositivo sin la
|
||||||
mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12
|
mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12
|
||||||
palabras).
|
palabras).
|
||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
|
- v0.6.0 (2026-06-14) — carga el histórico de cada room (`GET /api/rooms/{id}/history`) al
|
||||||
|
abrirla, con dedup vs live; recargar ya no pierde los mensajes. `ControlPlane.fetchHistory`
|
||||||
|
pega al control-plane (firmado, mismas cabeceras `X-Unibus-*`) y decodifica cada frame de
|
||||||
|
base64-std; `BusClient.history` lo descifra/verifica con el MISMO camino de envelope que
|
||||||
|
`subscribe` (refactor: helper privado `openFrame` compartido por ambos). En `busService`,
|
||||||
|
`bus.subscribeRoom` (que usa `ChatPanel`) ahora siembra la room con su historia y sigue en
|
||||||
|
vivo: dedup por `frame.id` con un `Set` por room y los mensajes live se bufferean hasta que
|
||||||
|
la historia (oldest->newest) se entrega, garantizando el orden; si el endpoint falta
|
||||||
|
(404/500) cae a live-only como antes. El ts de cada mensaje se deriva del ULID `msgID`
|
||||||
|
(`ulidTime`, inverso de `newULID`) para que historia y live compartan reloj y ordenen bien;
|
||||||
|
`ChatPanel` ordena por ts. El sidebar siembra su preview con `history(id, 1)` (sin traer
|
||||||
|
todo), manteniendo el fallback "—" para rooms vacías. `tsc` + 23 unit (incluye `ulid.test.ts`)
|
||||||
|
+ `pnpm build` verdes.
|
||||||
|
- v0.5.0 (2026-06-14) — nombres legibles en mensajes + sidebar con último mensaje/hora
|
||||||
|
reales + `pnpm dev` documentado. (1) Los mensajes muestran el **handle** del remitente en
|
||||||
|
vez del endpoint id: `ControlPlane.fetchDirectory()` pega al control-plane
|
||||||
|
`GET /api/directory` (firmado) y `busService` mantiene un mapa `endpoint -> handle`
|
||||||
|
(cargado al abrir sesión, refrescado tras `createRoom`); el resolver
|
||||||
|
`bus.displayName(endpoint)` devuelve el handle o un id corto de fallback (nunca el
|
||||||
|
endpoint largo), usado en la cabecera y el avatar de `ChatPanel` (el endpoint queda en un
|
||||||
|
`title` para depurar). Resiliente: si el endpoint aún no existe en el cluster (404) el
|
||||||
|
mapa queda vacío y el chat funciona igual que antes. (2) El sidebar muestra el **último
|
||||||
|
mensaje y la hora reales**: `busService` posee un store de rooms con una suscripción de
|
||||||
|
metadatos por room (último mensaje/hora + unread de rooms no activas); `Sidebar` ya no
|
||||||
|
pinta el "01:00" de epoch-0. (3) `pnpm dev` queda usable tras el cambio a same-origin:
|
||||||
|
apunta a un nodo con `VITE_BUS_HTTP`/`VITE_BUS_WS` y el dev server corre en el puerto 5174
|
||||||
|
(documentado en `app.md` + `vite.config.ts`). `tsc` + 19/19 unit + `pnpm build` verdes.
|
||||||
- v0.3.0 (2026-06-14) — `uniweb` se vuelve **cliente browser-nativo puro** (issue 0001, Fase
|
- v0.3.0 (2026-06-14) — `uniweb` se vuelve **cliente browser-nativo puro** (issue 0001, Fase
|
||||||
2): la SPA se cablea al SDK del bus (`busService.ts` reemplaza el módulo `api`) y se
|
2): la SPA se cablea al SDK del bus (`busService.ts` reemplaza el módulo `api`) y se
|
||||||
**elimina el gateway Go** (`cmd/webgw`, `go.mod`, `go.sum`). `uniweb` queda como solo `web/`,
|
**elimina el gateway Go** (`cmd/webgw`, `go.mod`, `go.sum`). `uniweb` queda como solo `web/`,
|
||||||
@@ -122,21 +156,3 @@ programáticos) ve a `unibus`; `uniweb` es el cliente web encima.
|
|||||||
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
|
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
|
||||||
cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva
|
cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva
|
||||||
ubicación con los `replace` cross-repo.
|
ubicación con los `replace` cross-repo.
|
||||||
|
|
||||||
- v0.2.0 (2026-06-13) — SDK del bus en TypeScript (`web/src/bus/`), issue 0001 Fase 1:
|
|
||||||
el protocolo y el cifrado E2E del bus portados al navegador para que `uniweb` deje
|
|
||||||
de depender del gateway Go. Módulos: `crypto.ts` (Ed25519, ChaCha20-Poly1305,
|
|
||||||
sealed box con nonce BLAKE2b igual que Go), `frame.ts` (wire format = `encoding/json`
|
|
||||||
de Go byte a byte), `room.ts` (Policy), `busauth.ts` (nkey NATS + firma de requests
|
|
||||||
del control-plane), `client.ts` (envelope de room puro + `BusClient` sobre una
|
|
||||||
interfaz de transporte + cliente HTTP firmado) y `wstransport.ts` (adaptador
|
|
||||||
`nats.ws`). Paridad cross-language verificada contra vectores Go (`cmd/busvectors`):
|
|
||||||
**19/19 tests verdes** — endpoint id, firma Ed25519, AEAD, sealed box, frame
|
|
||||||
marshal/sign, nkey y canonical request. La clave privada del usuario nunca se
|
|
||||||
serializa hacia la red. La conexión `nats.ws` + control-plane reales se validan en
|
|
||||||
la Fase 3 (E2E) por requerir un unibus vivo con WebSocket.
|
|
||||||
- v0.1.0 (2026-06-13) — scaffold inicial: extracción de la SPA (`web/`) y el gateway
|
|
||||||
(`cmd/webgw`) desde `unibus` v0.13.0 a su propia app/sub-repo. Sin cambios de capacidad
|
|
||||||
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
|
|
||||||
cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva
|
|
||||||
ubicación con los `replace` cross-repo.
|
|
||||||
|
|||||||
+13
-5
@@ -31,11 +31,12 @@ export function App() {
|
|||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [storedHandle, setStoredHandle] = useState("");
|
const [storedHandle, setStoredHandle] = useState("");
|
||||||
|
|
||||||
// Decide the entry screen on mount: an invite link goes straight to join; a device
|
// Decide the entry screen on mount: an invite link goes straight to join; otherwise
|
||||||
// with a stored identity shows the password unlock; an empty device shows the
|
// try to RESTORE a persisted session (survives reloads, and — with "keep me signed
|
||||||
// welcome chooser. There is no "resume session" step: the bus session lives in
|
// in" — closing the browser, up to its TTL/idle limits) so a reload does not force a
|
||||||
// memory (the SDK runs in the browser), so a reload always re-unlocks locally
|
// re-unlock; if there is none, a device with a stored identity shows the password
|
||||||
// rather than resuming a server-side cookie session.
|
// unlock and an empty device shows the welcome chooser. The private key stays in the
|
||||||
|
// browser throughout; nothing is resumed from a server-side cookie.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = readJoinToken();
|
const t = readJoinToken();
|
||||||
if (t) {
|
if (t) {
|
||||||
@@ -45,6 +46,13 @@ export function App() {
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const restored = await bus.restoreSession();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (restored) {
|
||||||
|
setUser(restored);
|
||||||
|
setRoute("chat");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const stored = await localIdentity();
|
const stored = await localIdentity();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (stored) {
|
if (stored) {
|
||||||
|
|||||||
+19
-7
@@ -33,15 +33,23 @@ function timeShort(ts: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MessageRow({ msg }: { msg: Message }) {
|
function MessageRow({ msg }: { msg: Message }) {
|
||||||
|
// Show the readable handle (resolved from the bus directory); the raw endpoint id
|
||||||
|
// stays in the title attribute as a debugging tooltip.
|
||||||
|
const name = bus.displayName(msg.sender);
|
||||||
return (
|
return (
|
||||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||||
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
|
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
|
||||||
{initials(msg.sender)}
|
{initials(name)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box style={{ minWidth: 0 }}>
|
<Box style={{ minWidth: 0 }}>
|
||||||
<Group gap={8} align="baseline">
|
<Group gap={8} align="baseline">
|
||||||
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
|
<Text
|
||||||
{msg.sender}
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
c={msg.mine ? "brand.4" : undefined}
|
||||||
|
title={msg.sender}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{timeShort(msg.ts)}
|
{timeShort(msg.ts)}
|
||||||
@@ -61,16 +69,20 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
|
|||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const viewport = useRef<HTMLDivElement>(null);
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Abre el stream SSE de la room activa. El gateway entrega historia (rooms
|
// Carga el histórico de la room activa y luego sigue en vivo: bus.subscribeRoom
|
||||||
// persistidas) y luego mensajes en vivo, ya descifrados. Dedup por id porque
|
// entrega primero la historia (oldest->newest) y después los mensajes en vivo, ya
|
||||||
// un re-render no debe duplicar y el eco del propio envío llega por aquí.
|
// descifrados y deduplicados por id. Aquí se mantiene la lista ordenada por ts y se
|
||||||
|
// deduplica de nuevo por id, porque un re-render no debe duplicar y el eco del propio
|
||||||
|
// envío también llega por esta vía.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
const close = bus.subscribeRoom(room.id, (m) => {
|
const close = bus.subscribeRoom(room.id, (m) => {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.some((p) => p.id === m.id) ? prev : [...prev, m],
|
prev.some((p) => p.id === m.id)
|
||||||
|
? prev
|
||||||
|
: [...prev, m].sort((a, b) => a.ts - b.ts),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return close;
|
return close;
|
||||||
|
|||||||
+27
-14
@@ -21,24 +21,32 @@ export function ChatShell({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [modalOpen, modal] = useDisclosure(false);
|
const [modalOpen, modal] = useDisclosure(false);
|
||||||
|
|
||||||
// Inserta la room recién creada al principio de la lista y la activa, sin
|
// The room list lives in busService (it owns a per-room metadata subscription so the
|
||||||
// recargar todo. Evita duplicar si el id ya estaba presente.
|
// sidebar shows the latest message/time and unread for rooms not being viewed). The
|
||||||
const handleRoomCreated = useCallback((room: Room) => {
|
// shell just mirrors the store into React state.
|
||||||
setRooms((prev) =>
|
useEffect(() => bus.watchRooms(setRooms), []);
|
||||||
prev.some((r) => r.id === room.id) ? prev : [room, ...prev],
|
|
||||||
);
|
// selectRoom activates a room in the UI and tells the store, which clears that room's
|
||||||
setActiveId(room.id);
|
// unread badge.
|
||||||
|
const selectRoom = useCallback((id: string) => {
|
||||||
|
setActiveId(id);
|
||||||
|
bus.setActiveRoom(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// La room recién creada ya está en el store (bus.createRoom la insertó); aquí solo
|
||||||
|
// se activa.
|
||||||
|
const handleRoomCreated = useCallback(
|
||||||
|
(room: Room) => {
|
||||||
|
selectRoom(room.id);
|
||||||
|
},
|
||||||
|
[selectRoom],
|
||||||
|
);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
bus
|
bus
|
||||||
.listRooms()
|
.loadRooms()
|
||||||
.then((rs) => {
|
.then(() => setError(null))
|
||||||
setRooms(rs);
|
|
||||||
setActiveId((cur) => cur || rs[0]?.id || "");
|
|
||||||
setError(null);
|
|
||||||
})
|
|
||||||
.catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms"))
|
.catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -47,6 +55,11 @@ export function ChatShell({
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// Activa la primera room en cuanto la lista se puebla y aún no hay ninguna activa.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeId && rooms.length > 0) selectRoom(rooms[0].id);
|
||||||
|
}, [rooms, activeId, selectRoom]);
|
||||||
|
|
||||||
const active = rooms.find((r) => r.id === activeId);
|
const active = rooms.find((r) => r.id === activeId);
|
||||||
|
|
||||||
// El panel derecho muestra el estado de carga/error/empty sin tocar el layout.
|
// El panel derecho muestra el estado de carga/error/empty sin tocar el layout.
|
||||||
@@ -106,7 +119,7 @@ export function ChatShell({
|
|||||||
user={user}
|
user={user}
|
||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
onSelect={setActiveId}
|
onSelect={selectRoom}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onNewRoom={modal.open}
|
onNewRoom={modal.open}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+2
-1
@@ -130,7 +130,8 @@ export function Join({
|
|||||||
// session; if the identity is not yet authorized, openSession fails and we
|
// session; if the identity is not yet authorized, openSession fails and we
|
||||||
// tell the user to have an admin authorize their public key.
|
// tell the user to have an admin authorize their public key.
|
||||||
const handle = identity.signPub.slice(0, 8);
|
const handle = identity.signPub.slice(0, 8);
|
||||||
const user = await saveAndOpen(identity, handle, password);
|
// Creating the account on this device implies it is yours: keep the session.
|
||||||
|
const user = await saveAndOpen(identity, handle, password, true);
|
||||||
onJoined(user);
|
onJoined(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const base =
|
const base =
|
||||||
|
|||||||
+2
-1
@@ -108,7 +108,8 @@ export function Recover({
|
|||||||
try {
|
try {
|
||||||
// No register here: the identity is already in the allowlist. Just re-encrypt
|
// No register here: the identity is already in the allowlist. Just re-encrypt
|
||||||
// locally and open the session as the recovered user.
|
// locally and open the session as the recovered user.
|
||||||
const user = await saveAndOpen(identity, handle.trim(), pw);
|
// Recovering on this device implies it is yours: keep the session by default.
|
||||||
|
const user = await saveAndOpen(identity, handle.trim(), pw, true);
|
||||||
onRecovered(user);
|
onRecovered(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ function initials(s: string) {
|
|||||||
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// timeShort renders HH:MM, or an em dash when there is no message yet (ts 0/falsy) so
|
||||||
|
// an empty room does not show the epoch-0 "01:00".
|
||||||
function timeShort(ts: number) {
|
function timeShort(ts: number) {
|
||||||
|
if (!ts) return "—";
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
return `${String(d.getHours()).padStart(2, "0")}:${String(
|
return `${String(d.getHours()).padStart(2, "0")}:${String(
|
||||||
d.getMinutes(),
|
d.getMinutes(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core";
|
import { Anchor, Button, Checkbox, Group, PasswordInput, Text } from "@mantine/core";
|
||||||
import { IconKey, IconWallet } from "@tabler/icons-react";
|
import { IconKey, IconWallet } from "@tabler/icons-react";
|
||||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||||
import { SessionError } from "./busService";
|
import { SessionError } from "./busService";
|
||||||
@@ -20,6 +20,7 @@ export function WalletLogin({
|
|||||||
onRecover: () => void;
|
onRecover: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [remember, setRemember] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export function WalletLogin({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const user = await unlockAndOpen(password);
|
const user = await unlockAndOpen(password, remember);
|
||||||
onLoggedIn(user);
|
onLoggedIn(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof WrongPasswordError) {
|
if (e instanceof WrongPasswordError) {
|
||||||
@@ -60,6 +61,12 @@ export function WalletLogin({
|
|||||||
onKeyDown={(e) => e.key === "Enter" && void unlock()}
|
onKeyDown={(e) => e.key === "Enter" && void unlock()}
|
||||||
data-autofocus
|
data-autofocus
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Mantener la sesión en este dispositivo"
|
||||||
|
description="Hasta 30 días; se bloquea sola tras 12 h sin usarla. Desmárcala en un dispositivo compartido."
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<Text c="red" size="sm" ta="center">
|
<Text c="red" size="sm" ta="center">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
+126
-5
@@ -60,6 +60,22 @@ export function newULID(nowMs: number = Date.now()): string {
|
|||||||
return ts + r;
|
return ts + r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ulidTime decodes the millisecond epoch timestamp a ULID encodes in its first 10
|
||||||
|
// Crockford base32 characters (the inverse of newULID's time prefix). A frame carries
|
||||||
|
// no explicit timestamp on the wire — its ULID id IS the timestamp — so the UI derives
|
||||||
|
// a message's time from it, which keeps live and replayed-history messages on the same
|
||||||
|
// clock (the sender's send time, not the receiver's arrival time). Returns 0 for an id
|
||||||
|
// whose prefix is not valid Crockford base32, so a malformed id never blows up the UI.
|
||||||
|
export function ulidTime(id: string): number {
|
||||||
|
let t = 0;
|
||||||
|
for (let i = 0; i < 10 && i < id.length; i++) {
|
||||||
|
const v = CROCKFORD.indexOf(id[i].toUpperCase());
|
||||||
|
if (v < 0) return 0;
|
||||||
|
t = t * 32 + v;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
// --- room envelope (pure, the security-critical core) ------------------------
|
// --- room envelope (pure, the security-critical core) ------------------------
|
||||||
|
|
||||||
export interface SealOptions {
|
export interface SealOptions {
|
||||||
@@ -138,6 +154,10 @@ export type MessageHandler = (subject: string, data: Uint8Array) => void;
|
|||||||
export interface NatsTransport {
|
export interface NatsTransport {
|
||||||
publish(subject: string, data: Uint8Array): void | Promise<void>;
|
publish(subject: string, data: Uint8Array): void | Promise<void>;
|
||||||
subscribe(subject: string, handler: MessageHandler): Promise<Subscription>;
|
subscribe(subject: string, handler: MessageHandler): Promise<Subscription>;
|
||||||
|
// reconnect rebuilds the connection so the server's per-subject ACL re-evaluates
|
||||||
|
// this peer's room membership (a room created after connecting is otherwise not in
|
||||||
|
// the grant). Active subscriptions are dropped; re-subscribe after calling it.
|
||||||
|
reconnect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +172,14 @@ interface RoomKeyResponse {
|
|||||||
epoch: number;
|
epoch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HistoryResp is GET /rooms/{id}/history?limit=N: a room's replayed frames, oldest ->
|
||||||
|
// newest, each base64-standard encoded. Every entry is one marshaled wire frame — the
|
||||||
|
// exact bytes the live subscription delivers — so the caller opens them with the same
|
||||||
|
// envelope path as a live message. A room with no stored history yields an empty list.
|
||||||
|
interface HistoryResp {
|
||||||
|
messages: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// PolicyWire is the control-plane JSON shape of a policy (snake_case sign_msgs).
|
// PolicyWire is the control-plane JSON shape of a policy (snake_case sign_msgs).
|
||||||
interface PolicyWire {
|
interface PolicyWire {
|
||||||
encrypt: boolean;
|
encrypt: boolean;
|
||||||
@@ -172,6 +200,29 @@ interface MemberJSON {
|
|||||||
sign_pub: string; // base64
|
sign_pub: string; // base64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DirectoryMemberWire is one row of GET /directory: a cluster-wide member with its
|
||||||
|
// human handle and role. sign_pub here is 64-hex (the raw Ed25519 public key), and
|
||||||
|
// endpoint matches endpointID(signPub) byte for byte.
|
||||||
|
interface DirectoryMemberWire {
|
||||||
|
sign_pub: string; // 64-hex
|
||||||
|
endpoint: string; // base64url-nopad, == endpointID(signPub)
|
||||||
|
handle: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryResp {
|
||||||
|
members: DirectoryMemberWire[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectoryEntry is the SDK shape of one directory member: the readable handle keyed
|
||||||
|
// by the stable endpoint id, so the UI can show a name instead of the raw id.
|
||||||
|
export interface DirectoryEntry {
|
||||||
|
signPub: string; // 64-hex
|
||||||
|
endpoint: string;
|
||||||
|
handle: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
// MemberRoomWire is one row of GET /members/{endpoint}/rooms.
|
// MemberRoomWire is one row of GET /members/{endpoint}/rooms.
|
||||||
interface MemberRoomWire {
|
interface MemberRoomWire {
|
||||||
room_id: string;
|
room_id: string;
|
||||||
@@ -281,6 +332,21 @@ export class ControlPlane {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchDirectory returns the cluster-wide member directory (GET /api/directory), so
|
||||||
|
// the UI can resolve a message sender's endpoint id to a readable handle. The
|
||||||
|
// request is signed like every other control-plane call. The caller is expected to
|
||||||
|
// tolerate this endpoint being absent on older clusters (404) and fall back to the
|
||||||
|
// short id; this method only maps the wire shape and lets transport errors surface.
|
||||||
|
async fetchDirectory(): Promise<DirectoryEntry[]> {
|
||||||
|
const resp = await this.request<DirectoryResp>("GET", "/directory");
|
||||||
|
return (resp.members ?? []).map((m) => ({
|
||||||
|
signPub: m.sign_pub,
|
||||||
|
endpoint: m.endpoint,
|
||||||
|
handle: m.handle,
|
||||||
|
role: m.role,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// listMembers returns the room's members keyed by endpoint, so a receiver can find
|
// listMembers returns the room's members keyed by endpoint, so a receiver can find
|
||||||
// a sender's signing public key to verify message signatures.
|
// a sender's signing public key to verify message signatures.
|
||||||
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
|
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
|
||||||
@@ -289,6 +355,17 @@ export class ControlPlane {
|
|||||||
for (const member of members) m.set(member.endpoint, base64ToBytesLocal(member.sign_pub));
|
for (const member of members) m.set(member.endpoint, base64ToBytesLocal(member.sign_pub));
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchHistory replays a room's stored frames (GET /rooms/{id}/history?limit=N),
|
||||||
|
// returning up to N marshaled wire frames oldest -> newest. The server base64-standard
|
||||||
|
// encodes each frame; this decodes them back to the raw bytes the live subscription
|
||||||
|
// delivers, so BusClient.history can open each with the same envelope path as
|
||||||
|
// subscribe. The caller tolerates this endpoint being absent on older clusters
|
||||||
|
// (404/500): the error surfaces and BusClient.history's caller falls back to live-only.
|
||||||
|
async fetchHistory(roomID: string, limit = 200): Promise<Uint8Array[]> {
|
||||||
|
const resp = await this.request<HistoryResp>("GET", `/rooms/${roomID}/history?limit=${limit}`);
|
||||||
|
return (resp.messages ?? []).map((b64) => base64ToBytesLocal(b64));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// base64ToBytesLocal decodes standard base64 (kept local to avoid widening crypto's
|
// base64ToBytesLocal decodes standard base64 (kept local to avoid widening crypto's
|
||||||
@@ -350,21 +427,65 @@ export class BusClient {
|
|||||||
await this.transport.publish(room.subject, marshal(f));
|
await this.transport.publish(room.subject, marshal(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openFrame is the shared envelope-opening core behind subscribe (live) and history
|
||||||
|
// (replay): it unmarshals one wire frame, resolves the sender's signing key (from the
|
||||||
|
// sign cache, populated by loadSigners for signed rooms) and the room key for the
|
||||||
|
// frame's epoch, then verifies + decrypts via openRoomMessage. Returns null when the
|
||||||
|
// frame fails verification or decryption, so both callers drop it the same way.
|
||||||
|
private async openFrame(
|
||||||
|
roomID: string,
|
||||||
|
policy: Policy,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): Promise<{ frame: Frame; plaintext: Uint8Array } | null> {
|
||||||
|
const frame = unmarshal(bytes);
|
||||||
|
const signerPub = policy.signMsgs ? this.signCache.get(roomID)?.get(frame.sender) : undefined;
|
||||||
|
const roomKey = policy.encrypt ? await this.roomKey(roomID, frame.epoch) : undefined;
|
||||||
|
const plaintext = openRoomMessage(frame, policy, signerPub, roomKey);
|
||||||
|
return plaintext ? { frame, plaintext } : null;
|
||||||
|
}
|
||||||
|
|
||||||
// subscribe delivers decoded, verified, decrypted messages for a room. Messages
|
// subscribe delivers decoded, verified, decrypted messages for a room. Messages
|
||||||
// that fail signature verification or decryption are dropped silently.
|
// that fail signature verification or decryption are dropped silently.
|
||||||
async subscribe(roomID: string, handler: (f: Frame, plaintext: Uint8Array) => void): Promise<Subscription> {
|
async subscribe(roomID: string, handler: (f: Frame, plaintext: Uint8Array) => void): Promise<Subscription> {
|
||||||
const room = await this.control.fetchRoom(roomID);
|
const room = await this.control.fetchRoom(roomID);
|
||||||
if (room.policy.signMsgs) await this.loadSigners(roomID);
|
if (room.policy.signMsgs) await this.loadSigners(roomID);
|
||||||
return this.transport.subscribe(room.subject, async (_subject, data) => {
|
return this.transport.subscribe(room.subject, async (_subject, data) => {
|
||||||
const f = unmarshal(data);
|
const opened = await this.openFrame(roomID, room.policy, data);
|
||||||
const signerPub = room.policy.signMsgs ? this.signCache.get(roomID)?.get(f.sender) : undefined;
|
if (opened) handler(opened.frame, opened.plaintext);
|
||||||
const roomKey = room.policy.encrypt ? await this.roomKey(roomID, f.epoch) : undefined;
|
|
||||||
const plaintext = openRoomMessage(f, room.policy, signerPub, roomKey);
|
|
||||||
if (plaintext) handler(f, plaintext);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// history replays a room's stored messages, decrypted and verified exactly like
|
||||||
|
// subscribe (NATS delivers live only, so without this a reload shows nothing until
|
||||||
|
// new traffic arrives). It resolves the room policy, loads the signer keys for a
|
||||||
|
// signed room, fetches the marshaled frames from the control plane, and opens each
|
||||||
|
// with the same openFrame path. Frames that fail verification/decryption are dropped.
|
||||||
|
// Returns the opened messages in the server's order (oldest -> newest).
|
||||||
|
async history(
|
||||||
|
roomID: string,
|
||||||
|
limit = 200,
|
||||||
|
): Promise<Array<{ frame: Frame; plaintext: Uint8Array }>> {
|
||||||
|
const room = await this.control.fetchRoom(roomID);
|
||||||
|
if (room.policy.signMsgs) await this.loadSigners(roomID);
|
||||||
|
const frames = await this.control.fetchHistory(roomID, limit);
|
||||||
|
const out: Array<{ frame: Frame; plaintext: Uint8Array }> = [];
|
||||||
|
for (const bytes of frames) {
|
||||||
|
const opened = await this.openFrame(roomID, room.policy, bytes);
|
||||||
|
if (opened) out.push(opened);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private async loadSigners(roomID: string): Promise<void> {
|
private async loadSigners(roomID: string): Promise<void> {
|
||||||
this.signCache.set(roomID, await this.control.signerKeys(roomID));
|
this.signCache.set(roomID, await this.control.signerKeys(roomID));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refresh reconnects the data plane so the server's per-subject ACL re-evaluates
|
||||||
|
// this peer's room membership. Call it after creating or joining a room while
|
||||||
|
// connected: NATS freezes a connection's publishable/subscribable subjects at
|
||||||
|
// connect time, so the new room's subject only becomes usable on a fresh
|
||||||
|
// connection. Active subscriptions are dropped — re-subscribe afterwards.
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
await this.transport.reconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Tests for ulidTime, the decoder of the millisecond timestamp a ULID encodes in its
|
||||||
|
// first 10 Crockford base32 characters. A wire frame carries no explicit timestamp —
|
||||||
|
// its ULID id IS the timestamp — so the UI derives a message's time (and thus its sort
|
||||||
|
// order, live and replayed-history alike) from this function. These tests pin that it
|
||||||
|
// is the exact inverse of newULID's time prefix and that it is time-ordered.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { newULID, ulidTime } from "./client.js";
|
||||||
|
|
||||||
|
describe("ulidTime", () => {
|
||||||
|
it("round-trips the millisecond timestamp newULID encodes", () => {
|
||||||
|
for (const ms of [0, 1, 1_000, 1_700_000_000_000, 2_000_000_000_000, Date.now()]) {
|
||||||
|
expect(ulidTime(newULID(ms))).toBe(ms);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is monotonic: a later message decodes to a larger time", () => {
|
||||||
|
const earlier = newULID(1_700_000_000_000);
|
||||||
|
const later = newULID(1_700_000_001_000);
|
||||||
|
expect(ulidTime(earlier)).toBeLessThan(ulidTime(later));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the 16-char random suffix (only the 10-char time prefix matters)", () => {
|
||||||
|
const ms = 1_736_000_000_000;
|
||||||
|
// Two ULIDs minted at the same ms differ only in their random tail, yet decode equal.
|
||||||
|
expect(ulidTime(newULID(ms))).toBe(ms);
|
||||||
|
expect(ulidTime(newULID(ms))).toBe(ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for an id whose prefix is not valid Crockford base32", () => {
|
||||||
|
expect(ulidTime("!!!!!!!!!!xxxxxxxxxxxxxxxx")).toBe(0);
|
||||||
|
expect(ulidTime("")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,17 +14,39 @@ import type { Identity, NatsTransport, MessageHandler, Subscription } from "./cl
|
|||||||
import { natsAuthenticator } from "./busauth.js";
|
import { natsAuthenticator } from "./busauth.js";
|
||||||
|
|
||||||
export class WsNatsTransport implements NatsTransport {
|
export class WsNatsTransport implements NatsTransport {
|
||||||
private constructor(private nc: NatsConnection) {}
|
// servers + id are retained so reconnect() can rebuild the connection with the same
|
||||||
|
// identity — needed because the per-subject ACL freezes a peer's publishable/
|
||||||
|
// subscribable subjects at connect time, so a room created after connecting only
|
||||||
|
// becomes usable after a fresh connection re-evaluates membership.
|
||||||
|
private constructor(
|
||||||
|
private nc: NatsConnection,
|
||||||
|
private servers: string[],
|
||||||
|
private id: Identity,
|
||||||
|
) {}
|
||||||
|
|
||||||
// connect opens a WebSocket connection to one of the given ws(s):// servers,
|
private static newConn(servers: string[], id: Identity): Promise<NatsConnection> {
|
||||||
// authenticating with the user's nkey identity.
|
|
||||||
static async connect(servers: string[], id: Identity): Promise<WsNatsTransport> {
|
|
||||||
const sign = natsAuthenticator(id.signPub, id.signPriv);
|
const sign = natsAuthenticator(id.signPub, id.signPriv);
|
||||||
// nats.ws's Authenticator returns the nkey + the base64url signature of the
|
// nats.ws's Authenticator returns the nkey + the base64url signature of the
|
||||||
// server nonce; our natsAuthenticator produces exactly that shape.
|
// server nonce; our natsAuthenticator produces exactly that shape.
|
||||||
const authenticator: Authenticator = (nonce?: string) => sign(nonce ?? "");
|
const authenticator: Authenticator = (nonce?: string) => sign(nonce ?? "");
|
||||||
const nc = await connect({ servers, authenticator });
|
return connect({ servers, authenticator });
|
||||||
return new WsNatsTransport(nc);
|
}
|
||||||
|
|
||||||
|
// connect opens a WebSocket connection to one of the given ws(s):// servers,
|
||||||
|
// authenticating with the user's nkey identity.
|
||||||
|
static async connect(servers: string[], id: Identity): Promise<WsNatsTransport> {
|
||||||
|
const nc = await WsNatsTransport.newConn(servers, id);
|
||||||
|
return new WsNatsTransport(nc, servers, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnect drops the current connection and opens a fresh one with the same
|
||||||
|
// identity, so the server's subject-ACL re-evaluates this peer's room membership.
|
||||||
|
// Active subscriptions from the previous connection are lost; the caller must
|
||||||
|
// re-subscribe (BusClient.subscribe) to the rooms it cares about afterwards.
|
||||||
|
async reconnect(): Promise<void> {
|
||||||
|
const old = this.nc;
|
||||||
|
this.nc = await WsNatsTransport.newConn(this.servers, this.id);
|
||||||
|
await old.close().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(subject: string, data: Uint8Array): void {
|
publish(subject: string, data: Uint8Array): void {
|
||||||
|
|||||||
+361
-47
@@ -17,18 +17,35 @@ import {
|
|||||||
WsNatsTransport,
|
WsNatsTransport,
|
||||||
hexToBytes,
|
hexToBytes,
|
||||||
endpointID,
|
endpointID,
|
||||||
|
ulidTime,
|
||||||
type Identity,
|
type Identity,
|
||||||
type Frame,
|
type Frame,
|
||||||
ModeMatrix,
|
ModeMatrix,
|
||||||
} from "./bus/index";
|
} from "./bus/index";
|
||||||
import type { WalletIdentity } from "./wallet/derive";
|
import type { WalletIdentity } from "./wallet/derive";
|
||||||
import type { MeInfo, Message, Room, User } from "./types";
|
import type { MeInfo, Message, Room, User } from "./types";
|
||||||
|
import { saveSession, loadSession, touchSession, clearSession } from "./session";
|
||||||
|
|
||||||
// Bus endpoints. A browser cannot open a raw TCP NATS socket, so the data plane is
|
// Bus endpoints. The SPA is served same-origin behind a reverse proxy (Caddy):
|
||||||
// reached over WebSocket; the control plane is the signed HTTPS API. Both default to
|
// both planes are reached through this page's OWN origin, so there is no CORS and
|
||||||
// a cluster node and can be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS).
|
// the cluster node IPs stay hidden behind the proxy. The control plane is the
|
||||||
const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "https://51.91.100.142:8470";
|
// signed HTTPS API under the relative path /api; the data plane is NATS over
|
||||||
const BUS_WS = import.meta.env.VITE_BUS_WS ?? "wss://51.91.100.142:8480";
|
// 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 {}
|
export class SessionError extends Error {}
|
||||||
|
|
||||||
@@ -56,24 +73,282 @@ interface Session {
|
|||||||
|
|
||||||
let session: Session | null = null;
|
let session: Session | null = null;
|
||||||
|
|
||||||
|
// directory maps a peer's stable endpoint id to its human handle, so the UI can show
|
||||||
|
// a readable name instead of the long base64url id. Populated from the control-plane
|
||||||
|
// GET /api/directory once a session opens, and refreshed when membership changes. It
|
||||||
|
// is best-effort: a cluster without the directory endpoint leaves it empty and the UI
|
||||||
|
// falls back to a short id (see displayName), so the chat keeps working regardless.
|
||||||
|
let directory = new Map<string, string>();
|
||||||
|
|
||||||
|
// shortId is the display fallback for an endpoint with no known handle: the first 8
|
||||||
|
// characters of the id, never the full long string.
|
||||||
|
function shortId(endpoint: string): string {
|
||||||
|
return endpoint.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDirectory (re)loads the cluster member directory into the endpoint -> handle
|
||||||
|
// map. It NEVER throws: if the endpoint is missing (older cluster, 404) or the request
|
||||||
|
// fails, the existing map is kept (empty on first load) and callers fall back to the
|
||||||
|
// short id. The new map is built locally and only swapped in on success, so a failed
|
||||||
|
// refresh never wipes a directory that loaded earlier.
|
||||||
|
async function loadDirectory(s: Session): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await s.control.fetchDirectory();
|
||||||
|
const next = new Map<string, string>();
|
||||||
|
for (const e of entries) if (e.handle) next.set(e.endpoint, e.handle);
|
||||||
|
directory = next;
|
||||||
|
} catch {
|
||||||
|
// No directory endpoint yet, or a transient failure: keep what we have (the chat
|
||||||
|
// must work exactly as before without readable names).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayNameOf is the resolver behind bus.displayName, kept module-level so the
|
||||||
|
// room store can reuse it for last-message previews.
|
||||||
|
function displayNameOf(endpoint: string): string {
|
||||||
|
if (session && endpoint === session.endpoint) {
|
||||||
|
return session.handle || directory.get(endpoint) || shortId(endpoint);
|
||||||
|
}
|
||||||
|
return directory.get(endpoint) || shortId(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
function require_(): Session {
|
function require_(): Session {
|
||||||
if (!session) throw new SessionError("no active bus session");
|
if (!session) throw new SessionError("no active bus session");
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- room store (sidebar metadata) -----------------------------------------
|
||||||
|
//
|
||||||
|
// The sidebar needs each room's last message and time, plus an unread count for
|
||||||
|
// rooms the user is NOT currently viewing. NATS delivers live only, so a live metadata
|
||||||
|
// subscription per room keeps the sidebar current while the app is open; on first load
|
||||||
|
// (or after a reload) the control plane's history endpoint seeds each room's last
|
||||||
|
// message so a room with no live traffic yet still shows its real latest line instead
|
||||||
|
// of "—". This store owns that: it holds the room list, subscribes to each room for
|
||||||
|
// metadata, seeds the preview from history, and notifies React watchers on every
|
||||||
|
// change. ChatPanel keeps its own subscription for the open conversation; this store's
|
||||||
|
// per-room subscription is independent and only updates sidebar metadata.
|
||||||
|
|
||||||
|
let roomList: Room[] = [];
|
||||||
|
let activeRoomID = "";
|
||||||
|
const roomListeners = new Set<(rooms: Room[]) => void>();
|
||||||
|
const metaSubs = new Map<string, () => void>(); // roomID -> unsubscribe
|
||||||
|
|
||||||
|
const PREVIEW_MAX = 48; // characters of a last-message preview in the sidebar
|
||||||
|
|
||||||
|
// snapshotRooms returns a shallow copy so React sees a new array/objects and re-renders.
|
||||||
|
function snapshotRooms(): Room[] {
|
||||||
|
return roomList.map((r) => ({ ...r }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyRooms(): void {
|
||||||
|
const snap = snapshotRooms();
|
||||||
|
for (const l of roomListeners) l(snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewText builds the sidebar's last-message line: "name: body" with the body
|
||||||
|
// truncated, reusing the directory resolver so the sender shows as a readable handle.
|
||||||
|
function previewText(m: Message): string {
|
||||||
|
const body =
|
||||||
|
m.body.length > PREVIEW_MAX ? m.body.slice(0, PREVIEW_MAX - 1) + "…" : m.body;
|
||||||
|
return `${displayNameOf(m.sender)}: ${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRoomMeta opens a metadata subscription for one room: each delivered message
|
||||||
|
// updates the room's last message/time and bumps unread when the room is not active.
|
||||||
|
function trackRoomMeta(roomID: string): void {
|
||||||
|
if (metaSubs.has(roomID)) return;
|
||||||
|
const unsub = subscribeRoomInternal(roomID, (m) => {
|
||||||
|
const r = roomList.find((x) => x.id === roomID);
|
||||||
|
if (!r) return;
|
||||||
|
r.lastTs = m.ts;
|
||||||
|
r.lastMessage = previewText(m);
|
||||||
|
if (roomID !== activeRoomID && !m.mine) r.unread += 1;
|
||||||
|
notifyRooms();
|
||||||
|
});
|
||||||
|
metaSubs.set(roomID, unsub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRoomPreviews fills each room's sidebar preview (last message + time) from the
|
||||||
|
// control plane's history, best-effort and in the background: the room list renders
|
||||||
|
// immediately, then each preview updates as its single most-recent stored message
|
||||||
|
// arrives. It never overwrites a live message that is already newer, and a room with
|
||||||
|
// genuinely no history keeps the "—" placeholder (lastTs 0). Errors (missing endpoint,
|
||||||
|
// transient) are swallowed per room so one failure never blocks the others.
|
||||||
|
function seedRoomPreviews(s: Session): void {
|
||||||
|
for (const r of roomList) {
|
||||||
|
s.client
|
||||||
|
.history(r.id, 1)
|
||||||
|
.then((items) => {
|
||||||
|
if (!items.length) return;
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const m = toMessage(s, last.frame, last.plaintext);
|
||||||
|
const room = roomList.find((x) => x.id === r.id);
|
||||||
|
if (!room || m.ts < room.lastTs) return; // a newer live message already won
|
||||||
|
room.lastTs = m.ts;
|
||||||
|
room.lastMessage = previewText(m);
|
||||||
|
notifyRooms();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function untrackAllRooms(): void {
|
||||||
|
for (const unsub of metaSubs.values()) {
|
||||||
|
try {
|
||||||
|
unsub();
|
||||||
|
} catch {
|
||||||
|
/* a closing transport may already be gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaSubs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrackRooms re-establishes a metadata subscription for every room. Used after a
|
||||||
|
// data-plane reconnect (createRoom's refresh), which drops all existing subscriptions.
|
||||||
|
function retrackRooms(): void {
|
||||||
|
untrackAllRooms();
|
||||||
|
for (const r of roomList) trackRoomMeta(r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetRoomStore clears the store and tears down subscriptions (on logout / new
|
||||||
|
// session), then pushes the empty snapshot so any live watcher renders an empty list.
|
||||||
|
function resetRoomStore(): void {
|
||||||
|
untrackAllRooms();
|
||||||
|
roomList = [];
|
||||||
|
activeRoomID = "";
|
||||||
|
notifyRooms();
|
||||||
|
}
|
||||||
|
|
||||||
|
// toMessage maps an opened bus frame to the UI's Message. The timestamp comes from the
|
||||||
|
// frame's ULID id (ulidTime), NOT the arrival time: a frame carries no explicit ts on
|
||||||
|
// the wire, and deriving it from the id puts live and replayed-history messages on the
|
||||||
|
// same clock so they sort into one correct order.
|
||||||
|
function toMessage(s: Session, f: Frame, plaintext: Uint8Array): Message {
|
||||||
|
return {
|
||||||
|
id: f.msgID,
|
||||||
|
sender: f.sender,
|
||||||
|
body: new TextDecoder().decode(plaintext),
|
||||||
|
ts: ulidTime(f.msgID),
|
||||||
|
mine: f.sender === s.endpoint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeRoomInternal is the live-only core behind the store's per-room metadata
|
||||||
|
// subscription (and the live half of subscribeRoomWithHistory): it decodes each frame
|
||||||
|
// into a UI Message and hands it to onMessage. Returns a function that cancels the
|
||||||
|
// subscription.
|
||||||
|
function subscribeRoomInternal(
|
||||||
|
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(toMessage(s, f, plaintext));
|
||||||
|
})
|
||||||
|
.then((sub) => {
|
||||||
|
if (closed) void sub.unsubscribe();
|
||||||
|
else unsub = () => void sub.unsubscribe();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
if (unsub) unsub();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeRoomWithHistory is what ChatPanel opens for the conversation it is viewing:
|
||||||
|
// it seeds the room with its stored history (so a reload no longer loses the messages)
|
||||||
|
// and then keeps it live. History and live are deduplicated by frame id through a
|
||||||
|
// per-room `seen` set — a message can arrive both ways when it lands between the fetch
|
||||||
|
// and the subscription. To guarantee history shows first (oldest -> newest) regardless
|
||||||
|
// of timing, live messages are buffered until the history batch has been delivered,
|
||||||
|
// then flushed. If history fails or the endpoint is absent (404/500 on an older
|
||||||
|
// cluster), it is treated as empty and the room runs live-only, exactly as before.
|
||||||
|
function subscribeRoomWithHistory(
|
||||||
|
roomID: string,
|
||||||
|
onMessage: (m: Message) => void,
|
||||||
|
): () => void {
|
||||||
|
const s = require_();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let historyDone = false;
|
||||||
|
let pending: Message[] = [];
|
||||||
|
const deliver = (m: Message): void => {
|
||||||
|
if (seen.has(m.id)) return;
|
||||||
|
seen.add(m.id);
|
||||||
|
onMessage(m);
|
||||||
|
};
|
||||||
|
// Live is subscribed immediately so nothing published during the history fetch is
|
||||||
|
// missed; messages are buffered until the history batch lands, then delivered.
|
||||||
|
const liveUnsub = subscribeRoomInternal(roomID, (m) => {
|
||||||
|
if (historyDone) deliver(m);
|
||||||
|
else pending.push(m);
|
||||||
|
});
|
||||||
|
s.client
|
||||||
|
.history(roomID)
|
||||||
|
.then((items) => {
|
||||||
|
for (const { frame, plaintext } of items) deliver(toMessage(s, frame, plaintext));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// No history endpoint yet, or a transient failure: fall back to live-only.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
historyDone = true;
|
||||||
|
for (const m of pending) deliver(m);
|
||||||
|
pending = [];
|
||||||
|
});
|
||||||
|
return liveUnsub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectSession opens the live bus connection (control plane + nats.ws data plane)
|
||||||
|
// for a wallet identity, WITHOUT touching persistence. The private key is used here
|
||||||
|
// in the browser and never leaves it.
|
||||||
|
async function connectSession(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 };
|
||||||
|
directory = new Map(); // fresh identity: drop any prior session's handle map
|
||||||
|
resetRoomStore(); // drop any prior session's room store + metadata subscriptions
|
||||||
|
await loadDirectory(session); // best-effort; never blocks login on a directory error
|
||||||
|
return { id: endpoint, handle: handle || endpoint.slice(0, 8) };
|
||||||
|
}
|
||||||
|
|
||||||
export const bus = {
|
export const bus = {
|
||||||
// openSession connects to the bus AS this wallet user: it builds the signed
|
// openSession connects to the bus AS this wallet user and persists the session so a
|
||||||
// control-plane client and the nats.ws data-plane connection in the browser. The
|
// reload does not force a password re-unlock. remember=true keeps it across closing
|
||||||
// private key never leaves — this is the fix for the old gateway model where the
|
// the browser (localStorage, up to 30 days / 12 h idle); false keeps it only for the
|
||||||
// browser POSTed its private key to /api/session.
|
// tab (sessionStorage, survives F5). The private key never leaves the browser — this
|
||||||
async openSession(wallet: WalletIdentity, handle: string): Promise<User> {
|
// is the fix for the old gateway model where the browser POSTed its private key.
|
||||||
const identity = toIdentity(wallet);
|
async openSession(wallet: WalletIdentity, handle: string, remember = false): Promise<User> {
|
||||||
const endpoint = endpointID(identity.signPub);
|
const user = await connectSession(wallet, handle);
|
||||||
const control = new ControlPlane(BUS_HTTP, identity);
|
saveSession(wallet, handle, remember);
|
||||||
const transport = await WsNatsTransport.connect([BUS_WS], identity);
|
return user;
|
||||||
const client = new BusClient(identity, transport, control);
|
},
|
||||||
session = { identity, handle, endpoint, control, transport, client };
|
|
||||||
return { id: endpoint, handle: handle || endpoint.slice(0, 8) };
|
// restoreSession re-opens a previously persisted session on page load, if one exists
|
||||||
|
// and has not expired (TTL/idle checked in loadSession). It does NOT re-save (so the
|
||||||
|
// absolute 30-day TTL is not renewed on every reload) — it only refreshes the idle
|
||||||
|
// timer. Returns the User on success, or null when there is nothing to restore.
|
||||||
|
async restoreSession(): Promise<User | null> {
|
||||||
|
const persisted = loadSession();
|
||||||
|
if (!persisted) return null;
|
||||||
|
try {
|
||||||
|
const user = await connectSession(persisted.wallet, persisted.handle);
|
||||||
|
touchSession(); // restart the idle window; keep createdAt (TTL) intact
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
// Connection failed (offline, identity revoked, ...): drop the stale session so
|
||||||
|
// the router falls back to the password unlock rather than looping.
|
||||||
|
clearSession();
|
||||||
|
session = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// me returns the identity of the active session (was GET /api/me).
|
// me returns the identity of the active session (was GET /api/me).
|
||||||
@@ -82,19 +357,45 @@ export const bus = {
|
|||||||
return { endpoint: s.endpoint, sign_pub: "", handle: s.handle };
|
return { endpoint: s.endpoint, sign_pub: "", handle: s.handle };
|
||||||
},
|
},
|
||||||
|
|
||||||
// logout closes the data-plane connection and drops the session.
|
// displayName resolves a sender endpoint id to a readable name for the UI: the
|
||||||
|
// member's handle when the directory knows it, the session user's own handle for
|
||||||
|
// their own messages, and a short id fallback otherwise — NEVER the full long
|
||||||
|
// endpoint. Pure lookup over the in-memory directory; safe to call from render.
|
||||||
|
displayName(endpoint: string): string {
|
||||||
|
return displayNameOf(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
// logout closes the data-plane connection, drops the in-memory session, and clears
|
||||||
|
// the persisted session from both stores so it cannot be restored.
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
|
clearSession();
|
||||||
|
resetRoomStore();
|
||||||
|
directory = new Map();
|
||||||
if (session) {
|
if (session) {
|
||||||
await session.transport.close().catch(() => {});
|
await session.transport.close().catch(() => {});
|
||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// listRooms lists the rooms this peer belongs to.
|
// watchRooms subscribes a listener to the sidebar room list and returns a function
|
||||||
async listRooms(): Promise<Room[]> {
|
// to detach it. The current snapshot is pushed immediately, so a component mounting
|
||||||
|
// mid-session renders the rooms it already has. Call loadRooms() to (re)populate.
|
||||||
|
watchRooms(listener: (rooms: Room[]) => void): () => void {
|
||||||
|
roomListeners.add(listener);
|
||||||
|
listener(snapshotRooms());
|
||||||
|
return () => {
|
||||||
|
roomListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// loadRooms fetches the rooms this peer belongs to, replaces the store, opens a
|
||||||
|
// metadata subscription per room (so the sidebar shows the latest message/time and
|
||||||
|
// unread for rooms the user is not viewing), and notifies watchers.
|
||||||
|
async loadRooms(): Promise<void> {
|
||||||
const s = require_();
|
const s = require_();
|
||||||
const wire = await s.control.listMemberRooms(s.endpoint);
|
const wire = await s.control.listMemberRooms(s.endpoint);
|
||||||
return wire.map((r) => ({
|
untrackAllRooms();
|
||||||
|
roomList = wire.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.subject,
|
name: r.subject,
|
||||||
encrypted: r.policy.encrypt,
|
encrypted: r.policy.encrypt,
|
||||||
@@ -103,14 +404,44 @@ export const bus = {
|
|||||||
unread: 0,
|
unread: 0,
|
||||||
messages: [],
|
messages: [],
|
||||||
}));
|
}));
|
||||||
|
for (const r of roomList) trackRoomMeta(r.id);
|
||||||
|
notifyRooms();
|
||||||
|
seedRoomPreviews(s); // fill each preview from history without blocking the render
|
||||||
|
},
|
||||||
|
|
||||||
|
// setActiveRoom marks the room the user is viewing: its unread count is cleared and
|
||||||
|
// future messages to it do not bump unread (see trackRoomMeta).
|
||||||
|
setActiveRoom(roomID: string): void {
|
||||||
|
activeRoomID = roomID;
|
||||||
|
const r = roomList.find((x) => x.id === roomID);
|
||||||
|
if (r) r.unread = 0;
|
||||||
|
notifyRooms();
|
||||||
},
|
},
|
||||||
|
|
||||||
// createRoom creates an encrypted, signed room owned by this peer (the Matrix-like
|
// createRoom creates an encrypted, signed room owned by this peer (the Matrix-like
|
||||||
// default). Returns the UI Room.
|
// default), then reconnects the data plane so the new room's subject enters this
|
||||||
|
// connection's ACL grant — otherwise publish/subscribe on a just-created room would
|
||||||
|
// silently not deliver until a reconnect/re-login. The reconnect drops every
|
||||||
|
// metadata subscription, so they are re-established here. Returns the UI Room.
|
||||||
async createRoom(subject: string): Promise<Room> {
|
async createRoom(subject: string): Promise<Room> {
|
||||||
const s = require_();
|
const s = require_();
|
||||||
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
|
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
|
||||||
return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] };
|
await s.client.refresh(); // re-evaluate the per-subject ACL with the new room
|
||||||
|
await loadDirectory(s); // a new room may bring new members into the directory
|
||||||
|
touchSession();
|
||||||
|
const room: Room = {
|
||||||
|
id: roomID,
|
||||||
|
name: subject,
|
||||||
|
encrypted: true,
|
||||||
|
lastMessage: "",
|
||||||
|
lastTs: 0,
|
||||||
|
unread: 0,
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
if (!roomList.some((r) => r.id === roomID)) roomList = [room, ...roomList];
|
||||||
|
retrackRooms(); // refresh() dropped all data-plane subs; re-subscribe every room
|
||||||
|
notifyRooms();
|
||||||
|
return room;
|
||||||
},
|
},
|
||||||
|
|
||||||
// send publishes a plaintext message to a room; the SDK seals + signs it per the
|
// send publishes a plaintext message to a room; the SDK seals + signs it per the
|
||||||
@@ -118,33 +449,16 @@ export const bus = {
|
|||||||
async send(roomID: string, body: string): Promise<void> {
|
async send(roomID: string, body: string): Promise<void> {
|
||||||
const s = require_();
|
const s = require_();
|
||||||
await s.client.publish(roomID, new TextEncoder().encode(body));
|
await s.client.publish(roomID, new TextEncoder().encode(body));
|
||||||
|
touchSession(); // user activity: restart the idle auto-lock window
|
||||||
},
|
},
|
||||||
|
|
||||||
// subscribeRoom delivers decrypted, verified messages for a room (replaces the old
|
// subscribeRoom delivers a room's stored history followed by its live messages, both
|
||||||
// SSE streamRoom). Returns an unsubscribe function.
|
// decrypted, verified and deduplicated by id (replaces the old SSE streamRoom).
|
||||||
|
// Returns an unsubscribe function. ChatPanel uses this for the open conversation, so
|
||||||
|
// reloading the page no longer loses the conversation; the sidebar metadata uses the
|
||||||
|
// live-only core (subscribeRoomInternal) and seeds its preview from history separately.
|
||||||
subscribeRoom(roomID: string, onMessage: (m: Message) => void): () => void {
|
subscribeRoom(roomID: string, onMessage: (m: Message) => void): () => void {
|
||||||
const s = require_();
|
return subscribeRoomWithHistory(roomID, onMessage);
|
||||||
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();
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Session persistence for the SPA. The bus session (the unlocked wallet identity)
|
||||||
|
// normally lives only in memory, so a page reload — even an F5 — drops it and forces
|
||||||
|
// a password re-unlock. This module keeps the session usable across reloads without
|
||||||
|
// ever sending anything to the network.
|
||||||
|
//
|
||||||
|
// Storage choice and its trade-off:
|
||||||
|
// - By DEFAULT the session is kept in sessionStorage: it survives an F5 but is
|
||||||
|
// cleared when the tab/window closes. This already fixes the "logs out on
|
||||||
|
// refresh" annoyance at minimal risk.
|
||||||
|
// - When the user ticks "keep me signed in" (remember=true) it is kept in
|
||||||
|
// localStorage instead: it survives closing the tab and the browser, until it
|
||||||
|
// EXPIRES or the user logs out.
|
||||||
|
//
|
||||||
|
// We never use a cookie: the wallet's private key must not travel to any server, and
|
||||||
|
// a cookie rides every request. The persisted value (the decrypted hex identity)
|
||||||
|
// stays on the device and is read only by this origin's own code.
|
||||||
|
//
|
||||||
|
// Two time bounds keep the persisted private key from living unbounded on disk:
|
||||||
|
// - TTL: an absolute lifetime (30 days). After it, re-unlock with the password.
|
||||||
|
// - IDLE: an inactivity auto-lock (12 h). Activity calls touchSession(); after 12 h
|
||||||
|
// with no activity the session re-locks even if the TTL has not elapsed.
|
||||||
|
|
||||||
|
import type { WalletIdentity } from "./wallet/derive";
|
||||||
|
|
||||||
|
const KEY = "unibus-session";
|
||||||
|
const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days absolute lifetime
|
||||||
|
const IDLE_MS = 12 * 60 * 60 * 1000; // 12 h inactivity auto-lock
|
||||||
|
|
||||||
|
interface PersistedSession {
|
||||||
|
// The decrypted wallet identity (hex), INCLUDING the private halves. This is the
|
||||||
|
// sensitive part that lives on the device so the user need not re-enter the
|
||||||
|
// password on every reload. Bounded by TTL_MS + IDLE_MS and cleared on logout.
|
||||||
|
wallet: WalletIdentity;
|
||||||
|
handle: string;
|
||||||
|
remember: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
lastActivity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stores(): Storage[] {
|
||||||
|
// Guard for SSR/tests where window is absent.
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
return [window.localStorage, window.sessionStorage];
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveSession persists the unlocked identity. remember=true uses localStorage
|
||||||
|
// (survives closing the browser); false uses sessionStorage (cleared with the tab).
|
||||||
|
export function saveSession(wallet: WalletIdentity, handle: string, remember: boolean): void {
|
||||||
|
clearSession(); // never keep it in both stores at once
|
||||||
|
const target = remember ? window.localStorage : window.sessionStorage;
|
||||||
|
const s: PersistedSession = {
|
||||||
|
wallet,
|
||||||
|
handle,
|
||||||
|
remember,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
target.setItem(KEY, JSON.stringify(s));
|
||||||
|
} catch {
|
||||||
|
/* storage full/blocked: fall back to memory-only (no persistence) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSession returns the persisted identity if one exists and is still valid (not
|
||||||
|
// past its TTL and not idle-expired), otherwise null. An expired entry is removed.
|
||||||
|
export function loadSession(): { wallet: WalletIdentity; handle: string; remember: boolean } | null {
|
||||||
|
for (const st of stores()) {
|
||||||
|
const raw = st.getItem(KEY);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(raw) as PersistedSession;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - s.createdAt > TTL_MS || now - s.lastActivity > IDLE_MS) {
|
||||||
|
st.removeItem(KEY); // expired by TTL or idle auto-lock
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return { wallet: s.wallet, handle: s.handle, remember: s.remember };
|
||||||
|
} catch {
|
||||||
|
st.removeItem(KEY); // corrupt entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchSession refreshes the last-activity timestamp so the idle auto-lock window
|
||||||
|
// restarts. Call it on meaningful user activity (sending, navigating rooms).
|
||||||
|
export function touchSession(): void {
|
||||||
|
for (const st of stores()) {
|
||||||
|
const raw = st.getItem(KEY);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(raw) as PersistedSession;
|
||||||
|
s.lastActivity = Date.now();
|
||||||
|
st.setItem(KEY, JSON.stringify(s));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearSession removes the persisted session from both stores (logout / lock).
|
||||||
|
export function clearSession(): void {
|
||||||
|
for (const st of stores()) st.removeItem(KEY);
|
||||||
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@ export interface User {
|
|||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
sender: string; // endpoint id del remitente (handle legible es fase 2)
|
sender: string; // endpoint id del remitente; el nombre legible se resuelve con bus.displayName()
|
||||||
body: string;
|
body: string;
|
||||||
ts: number; // epoch ms
|
ts: number; // epoch ms
|
||||||
mine?: boolean;
|
mine?: boolean;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export async function saveAndOpen(
|
|||||||
identity: WalletIdentity,
|
identity: WalletIdentity,
|
||||||
handle: string,
|
handle: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
remember = false,
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const enc = await encryptJSON(identity, password);
|
const enc = await encryptJSON(identity, password);
|
||||||
await putIdentity({
|
await putIdentity({
|
||||||
@@ -27,17 +28,17 @@ export async function saveAndOpen(
|
|||||||
enc,
|
enc,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
return bus.openSession(identity, handle);
|
return bus.openSession(identity, handle, remember);
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockAndOpen reads this device's stored identity, decrypts the private key with
|
// unlockAndOpen reads this device's stored identity, decrypts the private key with
|
||||||
// `password`, and opens a bus session locally. 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.
|
// password (GCM auth failure) and NoLocalIdentityError if the device has none.
|
||||||
export async function unlockAndOpen(password: string): Promise<User> {
|
export async function unlockAndOpen(password: string, remember = false): Promise<User> {
|
||||||
const stored = await getIdentity();
|
const stored = await getIdentity();
|
||||||
if (!stored) throw new NoLocalIdentityError();
|
if (!stored) throw new NoLocalIdentityError();
|
||||||
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
|
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
|
||||||
return bus.openSession(identity, stored.handle);
|
return bus.openSession(identity, stored.handle, remember);
|
||||||
}
|
}
|
||||||
|
|
||||||
// localIdentity returns the device's stored identity record (or null), for the
|
// localIdentity returns the device's stored identity record (or null), for the
|
||||||
|
|||||||
+9
-5
@@ -3,12 +3,16 @@ import react from "@vitejs/plugin-react";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
// The SPA talks DIRECTLY to the bus (signed HTTPS control plane + nats.ws data
|
// In production the SPA is served same-origin behind Caddy, which proxies /api and
|
||||||
// plane), so there is no gateway and no /api proxy. The dev server runs on 5173 to
|
// /nats to the cluster; those relative paths do not exist on the bare dev server, so
|
||||||
// match the bus CORS allowlist (--cors-origins http://localhost:5173). Point the
|
// `pnpm dev` must be pointed at a real cluster node with VITE_BUS_HTTP / VITE_BUS_WS
|
||||||
// SPA at a cluster node with VITE_BUS_HTTP / VITE_BUS_WS (see busService.ts).
|
// (busService.ts uses them as overrides of the same-origin defaults). Example:
|
||||||
|
// VITE_BUS_HTTP=https://<node>:8470 VITE_BUS_WS=wss://<node>:8480 pnpm dev
|
||||||
|
// The dev server runs on 5174 (5173 is reserved for an unrelated local app). Add the
|
||||||
|
// dev origin (http://localhost:5174) to the node's --cors-origins allowlist. strictPort
|
||||||
|
// is left off, so Vite falls back to the next free port if 5174 is busy.
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 5173,
|
port: 5174,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user