NATS delivers live only, so reloading the page lost a room's history. Add the
client half of the new history endpoint:
- ControlPlane.fetchHistory(roomID, limit): signed GET /rooms/{id}/history?limit=N,
decoding each base64-std frame to the raw bytes the live subscription delivers.
- BusClient.history(roomID, limit): opens each replayed frame (verify + decrypt)
exactly like subscribe, dropping any that fail, oldest -> newest.
- Extract BusClient.openFrame as the shared envelope-opening core for subscribe
and history (no duplication; subscribe behavior unchanged).
- ulidTime(id): decode the ms-epoch a ULID encodes in its first 10 Crockford
chars (inverse of newULID), so a frame's timestamp comes from its id (the wire
carries none). Covered by ulid.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Readable handle in messages (GET /api/directory), sidebar shows real
last message + time per room, pnpm dev usable after same-origin switch,
dedup growth log (v0.5.0).
A merge left the v0.2.0 and v0.1.0 growth-log entries duplicated. Keep one
entry per version in descending order and add the v0.5.0 line covering this
release: readable handles in messages, sidebar with real last message/time,
and the documented `pnpm dev` setup. Frontmatter version 0.4.0 -> 0.5.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same-origin (Caddy) means the SPA reaches /api and /nats through its own
origin in production, but those relative paths do not exist on the bare Vite
dev server, so `pnpm dev` no longer connects. busService already reads
VITE_BUS_HTTP / VITE_BUS_WS as overrides of the same-origin defaults — this
documents that path (Option A, no proxy code) and moves the dev server off the
port reserved by an unrelated local app.
- vite.config: dev server port 5173 -> 5174 (5173 is in use by another local
app). strictPort left off so Vite falls back to the next free port. Comment
explains the same-origin/dev split and the env-var override.
- app.md: Ejemplo and the CORS gotcha document the exact dev command
(VITE_BUS_HTTP/WS pointing at a cluster node) on :5174 and the same-origin
production model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidebar showed room.lastTs/lastMessage that busService created as 0/""
on every load, so the time rendered as the epoch-0 "01:00" and there was no
preview. busService now owns a room store that keeps these fields live.
- busService gains a room store: the room list plus a per-room metadata
subscription. Since the wire has no message history (NATS delivers live
only), staying subscribed to every room is the only way to know each room's
latest message and to count unread for rooms the user is not viewing. Each
delivered message updates the room's lastTs, lastMessage (a "name: body"
preview, truncated, reusing the directory resolver) and bumps unread when
the room is not active.
- New surface: watchRooms(listener) to mirror the store into React,
loadRooms() to (re)populate and subscribe, setActiveRoom(id) to clear a
room's unread. createRoom adds the new room to the store and, because its
data-plane refresh() drops all subscriptions, re-subscribes every room.
subscribeRoom now shares the same decode core (subscribeRoomInternal) used
by the metadata subscription. The store is reset on login/logout.
- ChatShell mirrors the store via watchRooms instead of a one-shot listRooms,
selects a room through setActiveRoom (clearing its unread), and auto-selects
the first room once the list loads.
- Sidebar: timeShort renders an em dash for a room with no message yet instead
of the epoch-0 "01:00".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve a message sender's endpoint id to a human handle using a new
control-plane directory endpoint.
- ControlPlane.fetchDirectory(): signed GET /api/directory, mapped to
DirectoryEntry { signPub, endpoint, handle, role }. The server's endpoint
matches endpointID(signPub) byte for byte.
- busService keeps an endpoint -> handle Map, loaded once after a session
opens and refreshed after createRoom (where the ACL is already refreshed).
Exposes a pure displayName(endpoint) resolver: handle when known, the
session user's own handle for their messages, short id fallback otherwise.
- Resilience: loadDirectory never throws. A missing endpoint (404 on older
clusters) or a transient error leaves the map empty and the UI falls back to
the short id, so the chat keeps working exactly as before.
- ChatPanel renders displayName(msg.sender) in the message header and derives
the avatar initials from the handle; the raw endpoint stays in a title
tooltip for debugging.
- types: Message.sender comment updated (this is the "phase 2" readable name).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Session persistence (web/src/session.ts): the unlocked wallet identity is kept
across reloads so an F5 no longer forces a password re-unlock. By default it lives
in sessionStorage (survives F5, cleared with the tab); with 'keep me signed in' it
lives in localStorage (survives closing the browser) bounded by a 30-day absolute
TTL and a 12-hour inactivity auto-lock. logout clears it; activity (send/createRoom)
refreshes the idle timer. No cookie is ever used — the private key never travels to
any server. WalletLogin gains the 'keep me signed in' checkbox; Recover/Join keep
the session by default (recovering/creating on a device implies it is yours).
App.tsx restores the session on mount before falling back to the unlock screen.
ACL reconnect: a room created while connected was not in the NATS per-subject ACL
grant (subjects are frozen at connect time), so its first messages silently did not
deliver until a re-login. WsNatsTransport gains reconnect(); BusClient.refresh()
calls it; busService.createRoom reconnects after creating so the new room is usable
immediately. Bumps uniweb to 0.4.0.
Añade un control "Nueva room" en el header del sidebar (botón "+") y CTAs
en los estados vacíos del sidebar y del panel. Abren un modal que pide el
asunto, crea la room con bus.createRoom contra el bus real, la inserta en
la lista (dedup por id, sin recargar) y la activa.
- NewRoomModal: modal de Mantine con loading, manejo de SessionError/Error
en español, crear con Enter o botón, formulario limpio en cada apertura.
- ChatShell: estado del modal con useDisclosure, handleRoomCreated centraliza
inserción + selección, empty state del panel rediseñado con botón crear.
- Sidebar: prop onNewRoom, botón "+" con tooltip, empty state distingue
"sin resultados" de "sin rooms" (con CTA crear primera room).
No toca la capa de datos (busService.ts ni web/src/bus/): usa los métodos de
bus tal como están. Verificado end-to-end contra el cluster real: crear room
desde la UI, enviar mensaje y verlo aparecer por la suscripción.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SPA is now served behind a same-origin reverse proxy (Caddy) that
fronts both bus planes, so the data layer reaches them through the page's
own origin instead of a hardcoded cluster node IP. This removes CORS
entirely and hides the cluster IPs behind the proxy.
- BUS_HTTP falls back to the relative path /api (the signed HTTPS control
plane), resolved against the page origin by ControlPlane's fetch.
- BUS_WS falls back to a wss URL derived from window.location (same host,
scheme mirroring https/http, path /nats), since a browser WebSocket needs
an absolute ws(s) URL.
- The raw self-signed-IP fallback (https://51.91.100.142:8470, wss://...:8480)
is gone. The VITE_BUS_HTTP / VITE_BUS_WS build-time overrides remain for a
dev setup that points straight at a cluster node.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Validating the SDK against the real cluster surfaced the control-plane wire shapes:
the room/policy JSON is snake_case (sign_msgs) and GET /rooms/{id} omits the id.
Fix ControlPlane.fetchRoom to map the wire shape to the SDK Room type, and add
ControlPlane.createRoom (mint a room key, seal it to the owner via sealed box, POST
/rooms) so a browser peer can own an encrypted room.
The live smoke now does a full end-to-end round-trip against the 3-node cluster:
create an encrypted+signed room, connect over nats.ws, publish, and receive the
SDK's own message decrypted with the signature verified. Verified 2026-06-14:
room 01KV2Q…, plaintext round-tripped intact. The whole seal/sign/open happens in
the client; the private key never leaves it.
Exclude *.test.ts from the app tsconfig so the Node-API integration test does not
break the production build (vitest transpiles tests independently). Issue 0001,
Phase 3.
Extend the live smoke: when a registered identity is present (its sign_pub added
to the bus allowlist), assert the SAME SDK that a fresh identity gets rejected with
is now ACCEPTED — control plane no longer 401s and the nats.ws data-plane
connection succeeds. Verified 2026-06-14 against the 3-node cluster: random
identity -> 401 / 'authorization violation'; registered identity -> 403 'not a
member of this room' / connected=true. The allowlist is the only gate; the SDK
speaks both planes correctly end-to-end. Issue 0001, Phase 3.
A network smoke (self-skips unless BUS_HTTP/BUS_WS are set) that points the SDK
at the live 3-node cluster. With a fresh, unregistered identity it asserts BOTH
planes reject with an AUTHORIZATION error (not a signature/protocol error),
proving the SDK speaks the control plane (signed canonical request) and the data
plane (nats.ws + nkey) correctly end-to-end. Verified 2026-06-14 against datardos:
control-plane 401 'identity not authorized', data-plane 'authorization violation'.
Issue 0001, Phase 3.
Second half of the browser-native bus SDK (issue 0001, Phase 1), making uniweb a
peer of the bus in its own right (like unibus_android) without the Go gateway:
- busauth.ts: NATS user nkey from the Ed25519 key (base32 + crc16, no nkeys dep)
and control-plane request signing (CanonicalRequest + X-Unibus-* headers).
- room.ts: Policy / Room types (ModeNATS, ModeMatrix).
- client.ts: the pure room ENVELOPE (sealRoomMessage/openRoomMessage — AEAD with
the subject as AAD, Ed25519 sign, drop on verify/decrypt failure), a transport-
agnostic BusClient, and a signed ControlPlane HTTP client (fetch room/key/members,
open the sealed room key locally).
- wstransport.ts: concrete nats.ws WebSocket transport (validated E2E in Phase 3).
- index.ts: public SDK surface.
Parity pinned by vectors from unibus cmd/busvectors (extended with nkey + signed
control-request vectors): 19/19 green. The user's private key signs everything in
the browser and is never sent to any server. Bumps uniweb to 0.2.0.
Remaining for Phase 1 completion: the live nats.ws connection + control-plane,
which need a running unibus with the WebSocket listener — exercised in Phase 3.
First half of the browser-native bus SDK (issue 0001, Phase 1):
- crypto.ts: Ed25519 sign/verify (@noble), ChaCha20-Poly1305 AEAD (@noble),
endpoint id (sha256+base64url), and the anonymous sealed box for room-key
distribution. The sealed-box nonce is BLAKE2b-192 over ephPub||recipientPub,
matching Go's nacl/box.SealAnonymous (NOT SHA-512) so a Go-sealed key opens here.
- frame.ts: the Frame wire format, reproducing Go encoding/json byte-for-byte —
struct field order, omitempty rules, base64-std byte fields, and the default
HTML escaping (<, >, &, U+2028/U+2029) — plus sign/verify over canonical bytes.
vectors.test.ts checks all of it against the golden vectors generated by unibus
cmd/busvectors. 11/11 green: endpoint id, Ed25519 (incl. frame signature),
AEAD seal+open, sealed box open + round-trip, and frame signing-bytes + wire
marshal. This pins cross-language interop with Go/Kotlin peers.
Adds @noble/ciphers, tweetnacl (runtime) and vitest (dev).
Golden vectors generated by unibus cmd/busvectors: the contract the TypeScript
bus SDK must match byte-for-byte (Ed25519 sign, ChaCha20-Poly1305 AEAD, sealed
box, Frame wire format). Issue 0001, Phase 0.
Extracted from unibus v0.13.0: the chat SPA (web/, React+Mantine, per-user
BIP39 wallet) and the web gateway (cmd/webgw, REST+SSE) that acts as a bus
peer for the browser. Consumes unibus as a Go module via replace => ../unibus,
keeping its own replace fn-registry for the cybersecurity primitives.
go build/vet/test and pnpm build green in the new location.