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. downloaded when a recipient actually wants them. No background sync.
- Identity is portable: the underlying key + sigchain survives the home - Identity is portable: the underlying key + sigchain survives the home
server going dark. Handles can be migrated to other servers later. 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: This is the Keybase model rebuilt on a decentralized substrate:
- **Identity layer** → KEZ (instead of Keybase's central account system) - **Identity layer** → KEZ (instead of Keybase's central account system)
@ -198,6 +201,7 @@ microservices (NATS broker, sigchain server).
| **NATS auth callout** | ✅ Yes | | **NATS auth callout** | ✅ Yes |
| **WebFinger endpoint** | ✅ Yes | | **WebFinger endpoint** | ✅ Yes |
| **HTTP API for clients** | ✅ 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) | | **Sigchain storage** | ❌ No — defer to `kez-sig-server` (separate container) |
| **NATS broker** | ❌ No — separate `nats-server` (Go) 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. | | **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. 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. 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 ```conf
# nats.conf — patched into whichever NATS deployment is used # 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 { authorization {
auth_callout { auth_callout {
issuer: "<our auth-callout signing nkey public part>" issuer: "<our auth-callout signing nkey public part>"
auth_users: ["AUTHUSER"] # placeholder identity NATS uses auth_users: ["AUTHUSER"] # placeholder identity NATS uses
account: "DEFAULT" account: "DEFAULT"
} }
} }
@ -316,12 +328,88 @@ authorization {
The chat-server signs auth-callout responses with a long-lived nkey The chat-server signs auth-callout responses with a long-lived nkey
that NATS trusts. When a client connects to NATS with their KEZ that NATS trusts. When a client connects to NATS with their KEZ
ed25519 key, NATS forwards the auth request to our chat-server, ed25519 key — whether via native protocol (CLI) or WebSocket
which checks the handle registry and signs a yes/no response. (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/healthz
GET /v1/u/:handle handle → { primary, sigchain_url, endpoints } GET /v1/u/:handle handle → { primary, sigchain_url, endpoints }
POST /v1/register claim a handle (signed body) 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 POST /internal/nats/auth verify nkey signature, return permissions
``` ```
Sigchain endpoints are **not** on this server — clients talk directly to Sigchain endpoints are **not** on this server — both web and native
`kez-sig-server` for those. 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. the unwrapped content key, verifies BLAKE3 hash. File appears.
``` ```
**v0 limitation:** If tudisco is offline at step 10, chris waits. **v0 limitations:**
Iroh will retry; download starts when tudisco's node comes back.
Pinning (the server holding a copy) is **not** in v0 — we accept this 1. If tudisco is offline at step 10, chris waits. Iroh will retry;
limitation in exchange for zero server-side storage cost and the download starts when tudisco's node comes back. Pinning (the
simplest possible architecture. 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) ### 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 ├── kez-chat/ ← THIS PROJECT
│ ├── document.md (this file) │ ├── document.md (this file)
│ ├── Cargo.toml │ ├── Cargo.toml
│ ├── src/ │ ├── src/ Rust server
│ │ ├── main.rs binary entry │ │ ├── main.rs binary entry
│ │ ├── handles.rs handle registry (sqlite-backed) │ │ ├── handles.rs handle registry (sqlite-backed)
│ │ ├── nats_auth.rs NATS auth callout endpoint │ │ ├── nats_auth.rs NATS auth callout endpoint
│ │ ├── webfinger.rs WebFinger discovery endpoint │ │ ├── webfinger.rs WebFinger discovery endpoint
│ │ ├── static_files.rs serves the built web app (axum::ServeDir)
│ │ └── api.rs axum routes + state │ │ └── 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/ │ ├── deploy/
│ │ ├── docker-compose.yml chat-server + nats + sig-server │ │ ├── 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 │ │ └── systemd/ alternative deployment
│ └── tests/ │ └── tests/
│ └── http.rs integration tests │ └── http.rs integration tests
@ -522,6 +630,8 @@ import cleanly from the start.
### 6.3 Dependencies (planned) ### 6.3 Dependencies (planned)
**Rust server (`kez-chat-server`):**
| Crate | Why | | Crate | Why |
|---|---| |---|---|
| `kez-core` (path) | Identity types, ed25519, signing | | `kez-core` (path) | Identity types, ed25519, signing |
@ -533,13 +643,28 @@ import cleanly from the start.
| `serde` / `serde_json` | Standard | | `serde` / `serde_json` | Standard |
| `thiserror` / `anyhow` | Standard | | `thiserror` / `anyhow` | Standard |
| `tracing` / `tracing-subscriber` | Logging | | `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 | | `clap` | CLI args |
**Not** depended on by the chat-server: **Not** depended on by the chat-server:
- `iroh` — server doesn't run an Iroh node in v0 (no pinning) - `iroh` — server doesn't run an Iroh node in v0 (no pinning)
- nats-server (Go) — separate container, not a Rust dep - 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 ### 6.4 NATS broker — bundled in compose, not in code
NATS is **not embedded in the Rust binary** — it's the official Go 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) - [ ] Registration signature validation (uses kez-core)
- [ ] WebFinger endpoint - [ ] WebFinger endpoint
- [ ] NATS auth callout (POST /internal/nats/auth) - [ ] NATS auth callout (POST /internal/nats/auth)
- [ ] Static-file serving for the SPA (`tower-http` `ServeDir`)
- [ ] Healthz / metrics - [ ] Healthz / metrics
- [ ] Integration tests against real nats-server + sig-server in a - [ ] Integration tests against real nats-server + sig-server in a
test docker-compose test docker-compose
### Deployment ### Web app (`web/`)
- [ ] docker-compose.yml (chat + nats + sig-server) - [ ] Project scaffold (Svelte 5 + Vite + TypeScript + Tailwind)
- [ ] nats.conf with auth_callout configured - [ ] Account creation flow (key gen in-browser, mnemonic prompt,
- [ ] systemd alternative deployment recipe registration POST, sigchain upload)
- [ ] README with TLS / reverse proxy guidance - [ ] 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** Same Rust core powers both the CLI and (later) a native GUI. CLI gets
at least a CLI client that does: the **file** capabilities the web app can't have:
- [ ] Account creation (key gen + mnemonic backup + handle registration) - [ ] Account creation (key gen + mnemonic backup + handle registration)
- [ ] Contact lookup + verification - [ ] 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) - [ ] 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) ## 8. Out of scope (v0)
- **Iroh pinning** (sender must be online for receiver to fetch) - **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) - **Group chat** (only 1:1 for v0)
- **Forward secrecy / ratcheting** (Double Ratchet, MLS) — chat is - **Forward secrecy / ratcheting** (Double Ratchet, MLS) — chat is
encrypted but each message uses the same X25519-derived key per pair 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). | | 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. | | 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. | | 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 No UI. Enough to prove the chat flow works end-to-end against
the server. the server.
5. **Iroh integration in the client** (not the server). 5. **Iroh integration in the CLI** (not the server).
- Client runs a local Iroh node - CLI runs a local Iroh node
- `kez-chat share @chris ./file.pdf` - `kez-chat share @chris ./file.pdf`
- `kez-chat fetch <ticket>` - `kez-chat fetch <ticket>`
6. **Shared-files manifest.** New `set_shared_files` sigchain op. 6. **Shared-files manifest.** New `set_shared_files` sigchain op.
`kez-chat browse @tudisco` lists his shared files. `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), 8. **Web app: account + contacts + identity.** Account creation in
Iced (pure Rust UI), or something else. 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 (chat, presence, file tickets — broker is dumb, clients do E2E with
ChaCha20-Poly1305 over X25519-derived keys) and identifies an Iroh ChaCha20-Poly1305 over X25519-derived keys) and identifies an Iroh
node (P2P bulk transfer, content-addressed blobs, on-demand fetch). node (P2P bulk transfer, content-addressed blobs, on-demand fetch).
**Our project ships two Rust services** (`kez-chat-server` for handle **Our project ships two Rust services + a Svelte web app + a CLI:**
registry + NATS auth callout + HTTP API, and the existing `kez-chat-server` (handle registry + NATS auth callout + HTTP API +
`kez-sig-server` for sigchain storage) **plus a docker-compose recipe serves the SPA), the existing `kez-sig-server` (sigchain storage),
that includes `nats-server`** for turn-key deployment. NATS isn't in the `web/` Svelte app (the test UI, served as static files by the
our Rust code — it's the official Go binary running as its own chat-server, uses the same HTTP API any native client would —
container — but it's wired up in our compose so operators can dogfoods the contract), and `kez-chat-cli` (Rust binary that's
`docker compose up` and have everything working. Operators with also the scripted-test surface). NATS isn't in our Rust code — it's
existing NATS deployments can disable the bundled service and point the official Go binary running as its own container — but it's
us elsewhere. The chat-server does not run an Iroh node wired up in our docker-compose so operators can `docker compose up`
and does not pin files in v0; file transfer is pure P2P between and have everything working. Operators with existing NATS deployments
online peers. Account recovery is via a 24-word paper-backup can disable the bundled service. The chat-server does not run an
mnemonic. Federation across home servers is deferred but the design Iroh node and does not pin files in v0; file transfer is pure P2P
keeps it as a flip-the-switch future change. 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.
--- ---