plan(kez-chat): add web app design — Svelte SPA served by chat-server

The test UI is a Svelte 5 + TypeScript + Vite + Tailwind single-page
app served as static files by kez-chat-server. The web app uses the
exact same HTTP API a native client would use, so every action in the
UI dogfoods the API contract.

Architecture changes:

- kez-chat-server now serves `/` as the SPA (tower-http ServeDir)
  alongside the existing /v1 API
- Web app talks NATS over WebSocket (nats.ws + nats-server's
  built-in websocket transport — same auth callout, same nkey auth,
  same JetStream durable consumers)
- Web app cannot do Iroh: browsers can't open raw UDP sockets and
  Iroh's WebTransport story isn't ready in 2026. Web shows manifests
  and prompts "Download requires CLI" for actual file transfer.
- Key storage in browser: passphrase-encrypted IndexedDB (documented
  limitation — native clients use OS keychain)

New / updated sections in document.md:

- §1: opening pitch mentions the web app + that it dogfoods the API
- §4.1: responsibilities table adds "serves the test web app"
- §4.4 NEW: full design of the web app — stack, capabilities, what
  it can't do in v0, deployment model
- §4.5: endpoint list now includes / (the SPA) and /assets/*
- §4.3: nats.conf snippet enables WebSocket transport alongside the
  existing native NATS port; both transports hit the same auth
  callout
- §5.4: file-sharing flow notes the web app caveat (visible manifest,
  CLI required for actual download)
- §6.1: folder layout adds web/ subdirectory with Svelte/Vite/Tailwind
  scaffolding and an updated Dockerfile (multi-stage: build web →
  build rust → ship)
- §6.3: dependencies split into Rust server vs Web app sections.
  Web app pulls in svelte, typescript, vite, nats.ws, @noble/curves,
  @scure/base, canonicalize, svelte-spa-router, tailwindcss,
  idb-keyval.
- §7 MVP scope: full Web app checklist added; CLI section renamed
  and clarified ("same Rust core powers CLI and future native GUI")
- §8 out-of-scope: "file transfer from the browser" added
- §11 sequenced plan: split into 12 steps; new phases 7-10 are the
  web app build (scaffold → account/contacts → chat → manifest);
  step 12 deferred native GUI
- §12 summary: rewritten to reflect "two Rust services + a Svelte
  web app + a CLI"
- Decisions-locked table: added rows for test UI choice, browser
  file transfer, manifest format, frontend framework, in-browser
  key storage
This commit is contained in:
Tudisco 2026-05-24 23:10:48 -06:00
parent 055040423e
commit a1d1aa6983

View File

@ -21,6 +21,9 @@ A real-time chat + file-sharing application with verified identities.
downloaded when a recipient actually wants them. No background sync.
- Identity is portable: the underlying key + sigchain survives the home
server going dark. Handles can be migrated to other servers later.
- A **Svelte web app** served directly by `kez-chat-server` is the test
UI. It uses the same HTTP API any native client would use, so the
web app dogfoods the API. See §4.4.
This is the Keybase model rebuilt on a decentralized substrate:
- **Identity layer** → KEZ (instead of Keybase's central account system)
@ -198,6 +201,7 @@ microservices (NATS broker, sigchain server).
| **NATS auth callout** | ✅ Yes |
| **WebFinger endpoint** | ✅ Yes |
| **HTTP API for clients** | ✅ Yes |
| **Serves the test web app** (Svelte SPA, built into the binary) | ✅ Yes (§4.4) |
| **Sigchain storage** | ❌ No — defer to `kez-sig-server` (separate container) |
| **NATS broker** | ❌ No — separate `nats-server` (Go) container |
| **Iroh pinning** | ❌ No for v0 — files transfer P2P when both peers are online. Pinning is a future tier. |
@ -301,14 +305,22 @@ NATS:
2. Set `NATS_URL=nats://your-broker:4222` in the chat-server's env.
3. Apply our reference `nats.conf` snippet to their NATS deployment.
The auth_callout config snippet:
The auth_callout config snippet (and WebSocket for browser clients):
```conf
# nats.conf — patched into whichever NATS deployment is used
# Enable WebSocket transport so the browser SPA can connect.
# Native CLI clients use the standard NATS port (4222).
websocket {
port: 8443
no_tls: false # behind a TLS terminator in prod
}
authorization {
auth_callout {
issuer: "<our auth-callout signing nkey public part>"
auth_users: ["AUTHUSER"] # placeholder identity NATS uses
auth_users: ["AUTHUSER"] # placeholder identity NATS uses
account: "DEFAULT"
}
}
@ -316,12 +328,88 @@ authorization {
The chat-server signs auth-callout responses with a long-lived nkey
that NATS trusts. When a client connects to NATS with their KEZ
ed25519 key, NATS forwards the auth request to our chat-server,
which checks the handle registry and signs a yes/no response.
ed25519 key — whether via native protocol (CLI) or WebSocket
(browser) — NATS forwards the auth request to our chat-server,
which checks the handle registry and signs a yes/no response. Same
auth path for both transports.
### 4.4 Endpoints
### 4.4 The test web app
The chat-server serves a Svelte single-page app as static files under
`/`. The web app is the test UI for the project — and crucially, it
**uses the exact same HTTP API a native client would use.** No
backend-rendered pages, no server-side state for the SPA. Every action
in the web UI goes through the public `/v1/...` API, which means the
web app is also a continuous test that the API contract works end to
end.
```
Browser hits https://kez.lat/ → SPA HTML+JS+CSS
SPA calls https://kez.lat/v1/u/chris → handle lookup (same as CLI)
SPA opens wss://kez.lat/nats → NATS broker over WebSocket
SPA calls https://sig.kez.lat/v1/sigchains/... → fetch sigchain (same as CLI)
```
#### Stack
| Layer | Pick |
|---|---|
| Framework | **Svelte 5 + TypeScript** |
| Build | **Vite** (output: static files served by chat-server) |
| Routing | **`svelte-spa-router`** (hash routing — works under any subpath) |
| NATS client | **`nats.ws`** — the official NATS WebSocket client. nkey auth supported. |
| Crypto | **`@noble/curves`** + **`@noble/hashes`** (same primitives our Node port uses) |
| Key storage | **passphrase-encrypted IndexedDB.** User enters a passphrase on first launch; seed is encrypted with that key. Documented limitation: browsers don't have an equivalent of OS keychain. Native clients (CLI, future Tauri) get better protection via OS keychain. |
| State | **Svelte stores** (built-in; no Redux needed) |
| Styling | **Tailwind** (default; trivially swappable) |
#### What the web app can do (v0)
- Account creation: generate ed25519 key in-browser, display mnemonic
for paper backup, register handle via `POST /v1/register`, upload
sigchain endpoint events to `kez-sig-server` via HTTP.
- Contacts: look up handles, fetch sigchains, display verified
identities (uses the same channel-verification logic via in-browser
TypeScript, sharing `@kez/core` and `@kez/channels`).
- 1:1 chat: subscribe to NATS inbox over WebSocket, decrypt incoming,
encrypt + publish outgoing. Real-time messaging works.
- Manifest browse: fetch and decrypt `@chris`'s shared-files manifest;
display the list of files; show metadata.
- Identity verification view: show the user's sigchain visually (claims,
channel proofs, rotations).
- Settings: show/re-display the mnemonic, re-verify it, log out
(clears IndexedDB).
#### What the web app can't do (v0)
- **File download / upload via Iroh.** Browsers can't open raw UDP
sockets, and Iroh's WebTransport story isn't ready in 2026. The
web app shows the manifest entries and a "Download (requires
desktop client)" button that points at the CLI. v0.5 may revisit
if Iroh-in-browser matures.
- Anything that requires the OS keychain (proper offline crypto).
#### Deployment
The web app's static build output (e.g. `kez-chat-server/web/dist/`)
is bundled into the chat-server's Docker image at build time and
served by axum's `ServeDir`. No separate static host, no separate
CDN. `docker compose up` deploys the SPA along with everything else.
The build pipeline:
1. `cd web && npm install && npm run build` → produces `web/dist/`
2. Dockerfile copies `web/dist/` into the runtime image
3. Rust binary serves it as `GET /*` (with the API mounted at `/v1/...`)
For dev: `npm run dev` runs Vite's dev server on port 5173, proxying
`/v1` requests to the locally-running chat-server. Hot module reload
works as normal Svelte dev.
### 4.5 Endpoints
```
GET / the web app (Svelte SPA)
GET /assets/* SPA static assets (CSS, JS, images)
GET /v1/healthz
GET /v1/u/:handle handle → { primary, sigchain_url, endpoints }
POST /v1/register claim a handle (signed body)
@ -331,8 +419,8 @@ GET /.well-known/webfinger?resource=acct:tudisco@kez.lat
POST /internal/nats/auth verify nkey signature, return permissions
```
Sigchain endpoints are **not** on this server — clients talk directly to
`kez-sig-server` for those.
Sigchain endpoints are **not** on this server — both web and native
clients talk directly to `kez-sig-server` for those.
---
@ -427,11 +515,19 @@ The broker sees:
the unwrapped content key, verifies BLAKE3 hash. File appears.
```
**v0 limitation:** If tudisco is offline at step 10, chris waits.
Iroh will retry; download starts when tudisco's node comes back.
Pinning (the server holding a copy) is **not** in v0 — we accept this
limitation in exchange for zero server-side storage cost and the
simplest possible architecture.
**v0 limitations:**
1. If tudisco is offline at step 10, chris waits. Iroh will retry;
download starts when tudisco's node comes back. Pinning (the
server holding a copy) is **not** in v0 — we accept this
limitation in exchange for zero server-side storage cost and
the simplest possible architecture.
2. **The browser SPA can do steps 17 (sender side) and step 8
(notification + manifest entry visible), but cannot do step 10
(fetch the blob)** — browsers can't speak native Iroh. Web users
see "File available, open in CLI to download." Native CLI does
the whole flow. v0.5 may revisit when Iroh's WebTransport story
matures.
### 5.5 Browsing someone's files (Keybase-style)
@ -473,15 +569,27 @@ fetching is per-file deliberate. **Recipient never auto-syncs.**
├── kez-chat/ ← THIS PROJECT
│ ├── document.md (this file)
│ ├── Cargo.toml
│ ├── src/
│ ├── src/ Rust server
│ │ ├── main.rs binary entry
│ │ ├── handles.rs handle registry (sqlite-backed)
│ │ ├── nats_auth.rs NATS auth callout endpoint
│ │ ├── webfinger.rs WebFinger discovery endpoint
│ │ ├── static_files.rs serves the built web app (axum::ServeDir)
│ │ └── api.rs axum routes + state
│ ├── web/ Svelte web app (the test UI)
│ │ ├── package.json
│ │ ├── vite.config.ts
│ │ ├── svelte.config.ts
│ │ ├── tailwind.config.ts
│ │ ├── src/
│ │ │ ├── routes/ pages (login, contacts, chat, profile, settings)
│ │ │ ├── lib/ crypto, nats client, sigchain helpers
│ │ │ └── app.svelte
│ │ └── dist/ build output (copied into Docker image)
│ ├── deploy/
│ │ ├── docker-compose.yml chat-server + nats + sig-server
│ │ ├── nats.conf with auth_callout config
│ │ ├── nats.conf with auth_callout + websocket config
│ │ ├── Dockerfile multi-stage: build web → build rust → ship
│ │ └── systemd/ alternative deployment
│ └── tests/
│ └── http.rs integration tests
@ -522,6 +630,8 @@ import cleanly from the start.
### 6.3 Dependencies (planned)
**Rust server (`kez-chat-server`):**
| Crate | Why |
|---|---|
| `kez-core` (path) | Identity types, ed25519, signing |
@ -533,13 +643,28 @@ import cleanly from the start.
| `serde` / `serde_json` | Standard |
| `thiserror` / `anyhow` | Standard |
| `tracing` / `tracing-subscriber` | Logging |
| `tower-http` | CORS, request tracing |
| `tower-http` | CORS, request tracing, **`fs` feature for serving the SPA static files** |
| `clap` | CLI args |
**Not** depended on by the chat-server:
- `iroh` — server doesn't run an Iroh node in v0 (no pinning)
- nats-server (Go) — separate container, not a Rust dep
**Web app (`web/`):**
| Package | Why |
|---|---|
| `svelte` 5.x | Framework |
| `typescript` | Types |
| `vite` | Build + dev server |
| `nats.ws` | NATS client over WebSocket (browser-native NATS protocol) |
| `@noble/curves`, `@noble/hashes` | Same crypto primitives used in our Node port |
| `@scure/base` | bech32 (nsec/npub if needed), base64url |
| `canonicalize` | RFC 8785 JCS — for signature interop with native clients |
| `svelte-spa-router` | Hash-based routing |
| `tailwindcss` | Styling (default; trivially swappable) |
| `idb-keyval` | Tiny IndexedDB wrapper for the encrypted seed + cache |
### 6.4 NATS broker — bundled in compose, not in code
NATS is **not embedded in the Rust binary** — it's the official Go
@ -604,34 +729,51 @@ fallback storage) is a future addition (§8).
- [ ] Registration signature validation (uses kez-core)
- [ ] WebFinger endpoint
- [ ] NATS auth callout (POST /internal/nats/auth)
- [ ] Static-file serving for the SPA (`tower-http` `ServeDir`)
- [ ] Healthz / metrics
- [ ] Integration tests against real nats-server + sig-server in a
test docker-compose
### Deployment
### Web app (`web/`)
- [ ] docker-compose.yml (chat + nats + sig-server)
- [ ] nats.conf with auth_callout configured
- [ ] systemd alternative deployment recipe
- [ ] README with TLS / reverse proxy guidance
- [ ] Project scaffold (Svelte 5 + Vite + TypeScript + Tailwind)
- [ ] Account creation flow (key gen in-browser, mnemonic prompt,
registration POST, sigchain upload)
- [ ] Login flow (mnemonic in → derive key → unlock IndexedDB)
- [ ] Contacts list (handle lookup, sigchain fetch + display)
- [ ] 1:1 chat (NATS-over-WebSocket subscribe/publish, E2E encrypt/decrypt)
- [ ] Manifest browse (fetch from sigchain → list entries)
- [ ] "Download requires CLI" affordance on manifest entries
- [ ] Identity verification view (visualize the sigchain)
- [ ] Settings (re-show mnemonic, verify it, log out)
- [ ] Build script integrated into the chat-server Docker image
### Client (`kez-chat-cli` — separate project later)
### CLI client (`kez-chat-cli`)
Out of scope for the server work, but the **server isn't usable without**
at least a CLI client that does:
Same Rust core powers both the CLI and (later) a native GUI. CLI gets
the **file** capabilities the web app can't have:
- [ ] Account creation (key gen + mnemonic backup + handle registration)
- [ ] Contact lookup + verification
- [ ] Send / receive 1:1 chat messages (E2E via NATS)
- [ ] Send / receive 1:1 chat messages (E2E via NATS native)
- [ ] Send / receive files (E2E via Iroh)
- [ ] Browse @user shared-files manifest
- [ ] Browse `<handle>` shared-files manifest + download files
UI app comes after CLI proves the flow works.
### Deployment
- [ ] docker-compose.yml (chat-server [includes SPA] + nats + sig-server)
- [ ] nats.conf with auth_callout + websocket configured
- [ ] Multi-stage Dockerfile (build web → build rust → final image)
- [ ] systemd alternative deployment recipe
- [ ] README with TLS / reverse proxy guidance (Caddy recommended)
---
## 8. Out of scope (v0)
- **Iroh pinning** (sender must be online for receiver to fetch)
- **File transfer from the browser** (the web app can browse manifests
but file download/upload needs the CLI; browsers can't speak Iroh
natively in 2026)
- **Group chat** (only 1:1 for v0)
- **Forward secrecy / ratcheting** (Double Ratchet, MLS) — chat is
encrypted but each message uses the same X25519-derived key per pair
@ -675,6 +817,11 @@ Settle yes/no on this and the design is locked.
| Handle scope: federation or global? | **Global for v0**, federation-ready design (see §3.5). |
| Recovery if key lost? | **Paper backup (24-word mnemonic), Keybase-style.** No server-side recovery. |
| Iroh pinning in v0? | **No.** Sender must be online for receiver to fetch. Pinning is a future tier. |
| Test UI: TUI / web / native GUI? | **Web app, served by `kez-chat-server` as static files.** Built in Svelte 5 + TypeScript + Vite + Tailwind. Uses the same HTTP API native clients use, so it dogfoods the contract. Talks NATS over WebSocket (`nats.ws`). |
| Browser file transfer? | **Not in v0.** Browsers can't speak Iroh natively in 2026. The web app shows manifests and prompts "Download requires CLI" for actual files. v0.5 revisit if Iroh's WebTransport story matures. |
| Manifest format? | **Option A** — signed JSON blob, hash committed via a new `set_shared_files` sigchain op. Simpler, reuses sigchain primitives. |
| Frontend framework? | **Svelte 5 + TypeScript + Vite**. Tailwind for styling (trivially swappable). |
| In-browser key storage? | **Passphrase-encrypted IndexedDB.** Documented limitation: browsers lack a Keychain equivalent. Native clients (CLI, future GUI) use OS keychain. |
---
@ -739,18 +886,39 @@ When we start building:
No UI. Enough to prove the chat flow works end-to-end against
the server.
5. **Iroh integration in the client** (not the server).
- Client runs a local Iroh node
5. **Iroh integration in the CLI** (not the server).
- CLI runs a local Iroh node
- `kez-chat share @chris ./file.pdf`
- `kez-chat fetch <ticket>`
6. **Shared-files manifest.** New `set_shared_files` sigchain op.
`kez-chat browse @tudisco` lists his shared files.
7. **Deployment recipe.** docker-compose, systemd, deployment doc.
7. **Web app scaffold** (Svelte 5 + Vite + Tailwind). Set up
`kez-chat/web/`, wire Vite dev proxy to the running chat-server,
"hello world" SPA served by axum's `ServeDir`. Multi-stage
Dockerfile builds `web/dist/` then bakes it into the runtime image.
8. **Then** start the GUI app. Could be Tauri (Rust + web frontend),
Iced (pure Rust UI), or something else.
8. **Web app: account + contacts + identity.** Account creation in
the browser (key gen, mnemonic backup, registration, sigchain
upload). Contacts list with sigchain-based verification. Identity
view (visualize the sigchain). No chat yet.
9. **Web app: chat.** `nats.ws` connection, nkey auth, subscribe to
inbox subject, encrypt/decrypt with `@noble/curves`. Real-time
chat in the browser. End-to-end: messages sent from CLI arrive
in the web app and vice versa.
10. **Web app: manifest browse.** Fetch and decrypt the
`set_shared_files` manifest of any contact; display the entries;
show "Download requires CLI" affordances on each.
11. **Deployment recipe finalized.** Production-ready docker-compose
(chat-server with embedded SPA + nats + sig-server), Caddy config
for TLS, systemd alternative.
12. **Then** native GUI (Tauri, etc.) — if web app + CLI isn't
enough. Likely v1 stretch, not MVP.
---
@ -763,19 +931,23 @@ the handle) backed by an ed25519 primary key. The same key authenticates to a NA
(chat, presence, file tickets — broker is dumb, clients do E2E with
ChaCha20-Poly1305 over X25519-derived keys) and identifies an Iroh
node (P2P bulk transfer, content-addressed blobs, on-demand fetch).
**Our project ships two Rust services** (`kez-chat-server` for handle
registry + NATS auth callout + HTTP API, and the existing
`kez-sig-server` for sigchain storage) **plus a docker-compose recipe
that includes `nats-server`** for turn-key deployment. NATS isn't in
our Rust code — it's the official Go binary running as its own
container — but it's wired up in our compose so operators can
`docker compose up` and have everything working. Operators with
existing NATS deployments can disable the bundled service and point
us elsewhere. The chat-server does not run an Iroh node
and does not pin files in v0; file transfer is pure P2P between
online peers. Account recovery is via a 24-word paper-backup
mnemonic. Federation across home servers is deferred but the design
keeps it as a flip-the-switch future change.
**Our project ships two Rust services + a Svelte web app + a CLI:**
`kez-chat-server` (handle registry + NATS auth callout + HTTP API +
serves the SPA), the existing `kez-sig-server` (sigchain storage),
the `web/` Svelte app (the test UI, served as static files by the
chat-server, uses the same HTTP API any native client would —
dogfoods the contract), and `kez-chat-cli` (Rust binary that's
also the scripted-test surface). NATS isn't in our Rust code — it's
the official Go binary running as its own container — but it's
wired up in our docker-compose so operators can `docker compose up`
and have everything working. Operators with existing NATS deployments
can disable the bundled service. The chat-server does not run an
Iroh node and does not pin files in v0; file transfer is pure P2P
between online peers, and **the browser can't speak Iroh natively —
so the web app shows manifests but file download requires the CLI**.
Account recovery is via a 24-word paper-backup mnemonic. Federation
across home servers is deferred but the design keeps it as a
flip-the-switch future change.
---