Swap the chat transport from the kez-chat server inbox to Nostr relays
without touching the identity model or the E2E crypto. The existing
SealedEnvelope (ed25519/x25519 + AES-GCM, our own key) is unchanged and
becomes the content of a Nostr event — Nostr only moves the bytes.
- nostr-id.ts: derive a secp256k1 signing key from the ed25519 seed
(HKDF, domain-separated — internal transport credential, never the
user's real Nostr account); route by a hash of the recipient's public
ed25519 primary since the curves can't be cross-derived.
- nostr-transport.ts: send/poll/stream mirroring messages.ts, via
SimplePool; per-handle time cursor + seen-id dedupe in localStorage.
- transport.ts: facade selecting server vs nostr via VITE_TRANSPORT
(code default stays "server"; this branch's .env flips it to nostr).
- inbox-service + Messages import from the facade.
Directory lookup (handle->primary) still runs on the kez-chat server;
identity stays internal. Metadata privacy is at parity with the server
transport (relay sees the from/to graph, body stays confidential).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New-account success screen sent users straight to /chats; first-run users
never saw the Getting Started checklist. Route to /welcome instead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New accounts land on /welcome instead of an empty Chats. A resumable,
skippable checklist (not a forced wizard) orients them and drives the
key first-run tasks. Essentials-only scope.
• Welcome.svelte: progress checklist —
1. Account created (auto ✓)
2. Back up recovery seed — highlighted as critical (warning border),
inline reveal + copy + "I've saved it safely". SKIPPABLE per
decision; no hard gate.
3. Add your first proof → /claims/add (✓ once a proof verifies)
4. Enable app lock (optional, only if a platform authenticator exists)
5. Turn on notifications (optional)
Steps derive from real state (claims, biometric, notif permission),
so checkmarks are truthful. "Skip for now" + "Enter kez-chat →"
both finish onboarding.
• lib/onboarding.svelte.ts: $state-backed flags (onboarded, seedAcked)
persisted to localStorage; reopen() for Settings.
• CreateAccount → /welcome after registration (was → /chats).
• Chats: dismissible "Finish setting up your account" nudge shown
until onboarding is completed/skipped.
• Settings → Account → "Getting started" reopens the checklist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A green check next to any KEZ that controls a proven account. Unlike
Twitter's "we say so," the badge means YOUR browser independently
verified ≥1 of the peer's published proofs against the channel.
Server:
• handles.proofs column (JSON array of claim subjects) + ALTER for
existing DBs. Returned in /v1/u/:handle and /v1/by-primary as
`proofs` — pure discovery; peers verify each themselves.
• PUT /v1/profile/:handle/proofs (authed X-KEZ-Auth, signed over
"PUT\n/v1/profile/<h>/proofs\n<ts>", distinct line from inbox/stream
so sigs can't cross-replay; 60s skew; max 64 subjects).
• All 20 existing http tests still pass.
Client:
• api.ts: HandleResponse.proofs + setProofs() (signs + PUTs).
• verify.ts: verifySubject(subject, primary) — runs the real channel
verifier given just subject+primary (no local envelope needed).
• conversations-store: cache verified + verified_checked_at per peer.
• Messages: on conversation open, fetch the peer's proof subjects and
verify them in the background (24h cache → snappy, rate-limit
friendly). VerifiedBadge in the conversation row + thread header.
• Identity: reverify now publishes your verified subjects to your
profile (so peers can discover them) + shows the badge on your own
card.
• VerifiedBadge.svelte: scalloped-seal check in verified-green
(distinct from the cyan brand accent).
Flow: you reverify your proofs on Identity → they publish to your
profile → when someone opens a chat with you, their client fetches +
verifies them → you get the check on their screen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Some users want light. Dark stays the default/brand; light is a
first-class option.
• app.css: :root[data-theme="light"] overrides the @theme token values
(deeper cyan accent so it's legible as text on white; light elevation
ramp + text tiers + semantic colors). Every utility is var(--color-*),
so flipping the vars flips the whole app — no per-component work.
• lib/theme.svelte.ts: choice (light/dark/system) persisted to
localStorage; "system" follows prefers-color-scheme live; sets
<html data-theme> + syncs the mobile theme-color meta.
• index.html: inline pre-paint script resolves the theme before first
render to avoid a flash of the wrong palette.
• Settings: new Appearance section with a Light/Dark/System segmented
control + a hint line ("Following your device (dark)").
• EmojiButton: picker now follows the app theme (was hardcoded white).
• main.ts: side-effect import so the system-theme listener is always
live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Unlock (passphrase + biometric) and CreateAccount still pushed to
/dashboard, which the redesign removed from the routes map. svelte-spa-
router matched nothing and rendered a blank page. Point them at /chats
(the new home).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The deployed redesign rendered dark-bg but unstyled, low-contrast text
because none of the --color-* tokens or token utilities were in the
output CSS. Two compounding causes, both fixed:
1. A CSS @import url(google-fonts) AFTER @import "tailwindcss" becomes a
misplaced import once Tailwind inlines itself; Lightning CSS drops it
and everything after — including @theme. Fonts now load via <link> in
index.html.
2. A box-drawing-unicode comment immediately before @theme stopped
Tailwind v4 from transforming the block. Replaced with plain ASCII.
CSS 21.8KB → 26.3KB; tokens + utilities now present; theme applies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Completes visual consistency across the whole app — every surface now
uses the tactical-terminal token set, so the redesign can ship without
a light-on-dark login screen.
• app.css: dark defaults for input/textarea/select (bg-elevated, token
text/border/placeholder, accent focus) so forms that don't set an
explicit bg still read correctly.
• Landing / CreateAccount / Restore / Unlock: light utility classes →
tokens (bg-white→surface, text-gray-*→text tiers, gray-900 buttons→
accent, red/green/amber→danger/verified/warning).
• Claims / AddClaim: same swap, plus the nostr publish panel + format
toggle + status badges remapped (purple→accent, blue→accent,
yellow→warning).
Now consistent end to end. Remaining polish (message ticks, day
separators, contact preview-card, skeletons, emoji-picker dark theme)
tracked for a follow-up; ready to deploy for a look.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Kills the dashboard-as-home. Logged-in users now land on Chats like a
real messenger. Implements the design-team's IA recommendation.
Navigation:
• Desktop: slim left icon rail (Chats / Identity / Settings) with the
cyan key-cursor logo, active-state accent bar, unread badge.
• Mobile: fixed bottom tab bar, same 3 destinations, safe-area inset.
• Unauthenticated flow renders full-bleed with a wordmark header.
• Legacy /dashboard + /messages redirect to /identity + /chats.
Chats (restyled Messages):
• Two-pane on desktop; on mobile the list is full-screen and the
thread pushes over it with a back chevron.
• Conversation rows get identicon avatars, active accent bar, truncated
previews. Thread header shows avatar + handle + key.
• Bubbles: sent = cyan accent fill / near-black text / tail; received =
dark bubble-recv + hairline border. Emoji-only boost retained.
• Compose + "start a chat" inputs use the dark token styling; live/
reconnecting status moved into the list header.
• All functionality preserved: SSE, emoji picker, auto-scroll,
notifications, unread badge.
Identity (new — was Dashboard's identity + claims):
• Identity card: identicon avatar (ring), copyable handle@server chip,
key fingerprint, registration date.
• Proofs grouped verified / failed / pending with verified-green
badges; Add proof + Manage links.
Settings (new — was Dashboard's remainder):
• Security (app lock / biometric, reveal seed), Notifications (perm +
test), Account (lock + build sha + source).
Dashboard.svelte is now unused (left in tree, removed from routes;
cleanup later). Claims/AddClaim + auth pages (Landing/Create/Restore/
Unlock) still use the old light classes — restyle is the next phase.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 0 of the redesign (see DESIGN.md). Establishes the visual
foundation; route restyling + IA reorg follow in subsequent commits.
Design direction (decided with a 3-agent design-team debate):
• Audience: hackers, privacy absolutists, anti-surveillance, Meshtastic
/ off-grid, journalists in hostile environments.
• Aesthetic: "muted tactical terminal" — Mullvad-calm restraint, not
neon cyberpunk cosplay. Monospace as identity. Hard-ish edges.
• Signature color: electric cyan #28C8E8 on neutral near-black #0B0C0E
(chosen over signal-amber and phosphor-green — ages better, reads
"serious infrastructure" without shouting). Verified-green reserved
for proofs only.
Changes:
• app.css: full Tailwind v4 @theme token set — elevation ramp, text
tiers, accent + dim + contrast, semantic colors, Inter + JetBrains
Mono via Google Fonts, tactical radius scale, accent glow, dark
color-scheme, cyan text-selection, thin dark scrollbars, and the
kez-cursor blink keyframe (respects prefers-reduced-motion).
• Wordmark.svelte: `kez▌` mono wordmark with blinking cyan block
cursor — the cursor is the brand mark.
• Avatar.svelte: deterministic 5×5 symmetric identicon from the
ed25519 key, cyan-arc hue. Every KEZ gets a stable face.
• kez-icon.svg: amber key → cyan key-meets-cursor glyph; regenerated
the full PWA icon set + apple-touch-icon from it.
• manifest + index.html theme/background color → #0B0C0E.
• DESIGN.md: the full system + IA plan as source of truth.
Note: existing route components still use light-theme utility classes
and will look inconsistent until restyled in the next phases — that
work lands next (shell/nav → Chats → Identity → Settings → Contacts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The bug you hit: minimize Chrome → SSE stream gets throttled by the
background-tab policy → eventually disconnects → next message arrives
via the 30s heartbeat poll instead of SSE push → my code skipped the
notification because of a `viaPush` guard.
Removed the guard. Now notifications fire for ANY incoming message
the user hasn't seen yet, regardless of transport (SSE vs poll). To
avoid notification-storm on startup catch-up:
• inbox-service now tracks #notifiedThroughSeq, seeded from the
persisted global cursor at start().
• #ingest only fires badge++ + system notification when m.seq is
strictly greater than the watermark — startup re-reading the
cache doesn't blow up the UI.
Also added a "Send test notification" button on Dashboard (visible
once permission is granted). Lets you sanity-check OS + browser
settings without needing a second device:
• Fires regardless of visibilityState
• Reports failure reason if Notification() throws
• Auto-clears after 5s so the panel doesn't grow stale
If the test fires successfully but real chat notifications still
don't appear when minimized, the fault is probably OS-level
(System Settings → Notifications → Chrome on macOS) — the success
message now tells the user where to look.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two rules, picked to feel natural:
1. Conversation just opened (peer_primary changed) → always scroll to
bottom. You expect to see the latest exchange first.
2. New message landed in the current conversation → scroll to bottom
IF you were already within 120px of the bottom. If you're scrolled
up reading older history, the auto-scroll doesn't yank you back
down. (Slack / Telegram / iMessage all do this; getting yanked out
of history when you're searching for something is infuriating.)
Implementation: a single $effect tracks activeConv.messages.length +
activeConv.peer_primary. Compares against two cursor vars (prevPrimary,
prevMessageCount) to distinguish "opened a new conversation" from
"new message in current one". queueMicrotask after the DOM updates so
scrollHeight reflects the just-rendered message.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The footer was showing "dev" instead of the commit sha because vite
runs inside the Docker build context, which doesn't have .git (only
kez-chat/ gets copied in, not the parent .git/). git rev-parse failed
in the try/catch and fell back to the "dev" sentinel.
Fix: vite.config now resolves the sha from, in order:
1. process.env.BUILD_SHA — set by deploy script
2. ./BUILD_SHA file in web/ — set by deploy script
3. git rev-parse --short HEAD — local dev
4. "dev" — give up
Deploy script writes the file before rsync; .gitignored so it doesn't
accidentally get committed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously the SSE stream only ran while the Messages component was
mounted. Navigate to Dashboard or Claims and new messages just piled
up server-side until you came back. Now the stream runs for the whole
session, drives an unread badge in the nav, and (with permission)
fires a system notification when a message lands while you're in
another tab.
inboxService (lib/inbox-service.svelte.ts):
• Singleton Svelte 5 $state class. session.setUnlocked() starts it,
session.lock() stops it. Holds the SSE stream + the 30s heartbeat
poll for the entire session lifetime.
• Reactive state read by anyone: status (off/connecting/live/
reconnecting), unreadCount (since last visit to /messages), and
lastError (surfaced in the Messages footer).
• onMessage(fn) lets components subscribe to repaint when ingest
succeeds — Messages page uses this instead of owning its own
stream.
• #fireSystemNotification fires Notification API on inbound when
Notification.permission === "granted" AND document.visibilityState
!== "visible". Silent while you're actively looking at the tab.
Uses tag="kez-chat-inbox" so multiple notifications collapse.
Messages.svelte:
• Stripped its own stream/poll. Now just subscribes to inboxService.
onMount also calls markAllRead() — landing on /messages = you've
seen the new stuff.
• Footer status indicator reads from inboxService instead of local
state.
App.svelte nav:
• Messages link grows a red unread-count badge (1, 2, …, 9+) when
inboxService.unreadCount > 0 and the user isn't already on the
Messages route.
Dashboard:
• New "Notifications" section between Quick unlock and Backup with
the standard 3-state UX: granted (green confirm), denied (amber
"fix in site settings"), default (button to request).
• Helpers in inbox-service.ts wrap the Notification API so non-
supporting browsers (older Safari, Firefox in some configs) get
graceful "not supported" copy.
Caveat (for v0.3): notifications only fire while the tab is open in
SOME state (background-but-not-closed). Closing the tab kills the
SSE stream so nothing arrives at the page to notify about. True
background push (Web Push API + VAPID + server-side push) is a
separate piece of work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: I was appending the <emoji-picker> custom element to the OUTER
wrapper div (which is part of the flex compose row), so when the picker
opened it became a flex sibling of the button and pushed the text
input + Send button down to a tiny strip.
Fix: append into the absolute-positioned popover container (new
popoverEl ref) instead of the wrapper. The popover is taken out of
flow so the compose row stays put and the picker floats above it.
Also:
- Outer wrapper gets shrink-0 so it doesn't expand even if the picker
somehow leaks.
- Click-outside check now looks at both wrapEl AND popoverEl (since
the picker is no longer a descendant of the wrapper).
- Popover anchors bottom-full left-0 — picker grows up and to the
right of the 😀 button.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related changes — both aimed at "can you tell what's deployed?"
1. SW auto-update (no more "refresh twice")
The default vite-plugin-pwa autoUpdate behavior was: new SW
downloads on first reload, activates on second reload. Users
refresh after a deploy, still see old bundle, get confused.
Now:
• workbox: skipWaiting + clientsClaim → new SW activates and
takes control of existing pages immediately on install.
• main.ts listens for `controllerchange` and calls reload() once.
New SW takes over → page reloads → new bundle loads.
Net: deploys land on the FIRST refresh after the new bundle is
reachable. (Caveat: the SW that's currently running has to
download the new SW first, so the very first refresh after a
deploy may serve stale + then auto-reload a beat later.)
2. Visible build sha in the footer
vite.config.ts now runs `git rev-parse --short HEAD` at build
time and injects __BUILD_SHA__ + __BUILD_TIME__ via Vite's
`define`. App.svelte's footer renders the sha as a small monospace
chip linking to the commit on gitea, with the build time on
hover.
"kez-chat web v0.1" → "kez-chat [abc1234] · source"
So when you refresh and the chip changes value, you know the new
build landed. When it doesn't, you know the SW is still serving
the old bundle.
3. Killed the `apple-mobile-web-app-capable` deprecation warning by
adding the standard `mobile-web-app-capable` next to it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Touch ID / Face ID / Windows Hello / Android fingerprint / YubiKey
(PRF-capable) can now unlock the local seed without typing the
passphrase. Fully client-side — the server has zero visibility into
the credential or the derived key.
How it works (WebAuthn PRF extension):
1. Setup (Dashboard → "Quick unlock" → "Set up biometric unlock"):
• Register a platform credential with prf:{} in extensions.
• If the authenticator returns prf.enabled, immediately
getAssertion() with a random 32-byte salt to retrieve a
deterministic 32-byte secret (the "PRF output").
• AES-GCM(seed) under that secret → store the blob, salt, nonce,
and credentialId in a separate IDB entry from the passphrase
blob.
2. Unlock (Unlock page → big "Unlock with Touch ID" button):
• getAssertion() with the stored credentialId + salt → same
32-byte secret → AES-GCM decrypt → seed.
• unlockWithSeed() (new helper in identity-store) merges the
seed with handle/server/primary metadata to rebuild the
UnlockedIdentity session shape.
Trust properties (intentional):
• Passphrase blob stays in place as the authoritative backup.
Biometric is purely additive — wipe your browser profile or lose
the device, passphrase still works on any device where you
re-import the seed.
• PRF output never leaves the browser. The authenticator is the
only thing that can produce it, and only with the matching salt
+ credentialId we stored.
• Disable → just deletes the IDB entry; the registered credential
on the device still exists but is unused. (User can also clear
it from their OS / passkey manager.)
Browser support gating:
• Dashboard panel renders "no platform authenticator detected" if
isUserVerifyingPlatformAuthenticatorAvailable() returns false.
• Setup fails with a clear error if PRF isn't supported by the
authenticator (older YubiKeys, some password managers).
• Unlock page falls back to passphrase form automatically if
biometric fails (cancelled, sensor error, etc.).
Live at https://kez.lat (asset index-Df_F5lEP.js).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Compose bar gets a 😀 button. Click → emoji-picker-element (the
canonical web component, ~140 KB) lazy-loads on first open and stays
cached after. Pick → inserts at cursor position, focus returns to
input. Click-outside closes the popover.
Bonus: emoji-only messages render iMessage-style — no bubble, larger
text. Uses Intl.Segmenter to count grapheme clusters so 👨👩👧 reads
as 1 not 5 code points.
• 1 emoji → text-5xl
• 2-3 → text-4xl
• 4-6 → text-3xl
• 7+ or any letters/digits/punct → normal bubble
Bundle: emoji picker chunked separately via dynamic import (38 KB
gzipped). Initial Messages-page JS only nudged ~159→162 KB.
Native emoji input (macOS ⌃⌘Space, iOS keyboard, Android long-press)
still works — the picker is just for discoverability.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chat was polling every 5s, which felt sluggish with two users online.
Switched to Server-Sent Events for push delivery. Polling now runs as
a 30s heartbeat just to catch anything missed during reconnect windows.
NATS is still bundled in docker-compose but no Rust code talks to it
yet — that lands in v0.2 for cross-instance fanout. The migration is
"swap the in-process broker for nats.publish/subscribe against
kez.chat.inbox.<handle>"; SSE subscribers don't notice.
Server (kez-chat-server):
• New broker module: per-recipient tokio::sync::broadcast channels,
in-process pub/sub. 64-slot buffer per channel; lagging subscribers
drop on the floor and resync via the polling heartbeat. 4 unit
tests cover subscribe/publish, multi-subscriber fanout, per-handle
isolation, no-op on no-subscribers.
• POST /v1/messages now publishes to broker after persisting → any
open SSE stream for the recipient gets the envelope immediately.
• New GET /v1/inbox/:handle/stream — SSE endpoint, ?auth=<ts>:<sig>
query param (EventSource can't set headers). Signed message is
distinct from the polling header ("GET\n/v1/inbox/<h>/stream\n<ts>"
vs "GET\n/v1/inbox/<h>\nsince=<n>\n<ts>") so a captured poll sig
can't be replayed as a stream sig and vice versa.
• 15s SSE keep-alive ping so Cloudflare/NAT/load balancers don't
drop idle connections.
• 3 new stream-auth unit tests, including the cross-endpoint replay
rejection. 19 unit + 20 integration tests all green.
• New deps: tokio-stream (sync feature for BroadcastStream),
futures (for the Stream trait the Sse handler returns).
Browser (kez-chat/web):
• streamInbox() in lib/messages.ts: long-lived EventSource,
auto-reconnects on error with fresh auth (tears down on `error`,
re-opens after 3s — EventSource's native retry uses the stale URL).
Exposes onMessage + onStatus callbacks.
• Messages.svelte: opens SSE on mount, decrypts pushed envelopes
inline via the new shared ingest() helper. Polling dropped from
5s → 30s heartbeat.
• Sidebar footer shows live status:
● live (green)
● reconnecting… (amber)
○ connecting… (gray)
Verified live: /v1/inbox/<registered>/stream?auth=bad returns 401,
no-auth returns 400. Asset index-C1ogRtUG.js serving.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Now installable on iOS (Safari → Share → Add to Home Screen) and
Android/desktop Chrome (install prompt or Settings → Install app).
Launches in standalone mode with a dark theme color matching the
lock-icon palette.
Stack:
• vite-plugin-pwa with workbox in generateSW mode, registerType
'autoUpdate' — new SW activates on next page load, no upgrade prompt
(chat needs to stay fresh).
• @vite-pwa/assets-generator for icon variants from a single SVG.
Source kez-icon.svg = dark squircle (#111827) + amber key glyph,
drawn inside the 80% maskable safe zone.
Caching:
• Precaches the SPA shell (~635 KB inc. the zstd WASM, well under
the 5 MB per-file cap).
• runtimeCaching 'NetworkOnly' for /v1/* — never cache authenticated
chat data; every poll must hit the network.
• navigateFallback to index.html so /messages, /claims, /dashboard
survive a refresh while offline. The /v1/, /internal/, /.well-known/
paths are explicitly denylisted from this fallback.
Meta tags (index.html):
• <link rel="manifest"> + theme-color for Android Chrome.
• apple-touch-icon-180x180 + apple-mobile-web-app-* meta for iOS,
including status-bar-style=black-translucent so the dark header
flows into the notch area in standalone.
• viewport-fit=cover so safe-area-inset works on notched devices.
Generated artifacts committed under web/public/:
kez-icon.svg, pwa-{64,192,512}.png, maskable-icon-512x512.png,
apple-touch-icon-180x180.png, favicon.ico.
Verified live: /manifest.webmanifest serves application/manifest+json,
/sw.js serves text/javascript, all icons return 200.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous Messages page assumed you knew what a "handle" was and showed
truncated ed25519 hex everywhere. Reframed it so a newcomer can figure
out what to do without having read the spec.
Server:
• GET /v1/by-primary/:primary — reverse lookup, ed25519:<hex> →
handle record. Used by the SPA to render @alice instead of the
truncated hex when an inbound envelope arrives from a peer we
haven't chatted with yet. 3 new integration tests cover round-trip,
NotFound, BadRequest-on-garbage.
Web — sidebar:
• "Your KEZ" panel at top — handle@server with a copy button. The
whole point: someone needs your KEZ to message you, so make
sharing it one click.
• "Start a chat" input accepts `alice` or `alice@kez.lat`. Resolves
via /v1/u/:handle before adding — explicit error if unregistered,
friendly "that's you" guard for self.
• Conversation rows show resolved handles, not hex blobs.
Web — empty state:
• 🔒 + "End-to-end encrypted chat" headline + plain-English paragraph
explaining that even the server can't read messages.
• Concrete starter hint: "open kez.lat in a second browser, create
another account, message yourself between the two."
Conversation cache redesign:
• Now keyed by peer_primary (canonical KEZ identity) with peer_handle
as display metadata. Resolves the same-person-as-two-threads bug
you'd hit when you sent to "alice" then alice replied (her primary
didn't match the "alice" key).
• IDB key bumped to :v2 — old shape abandoned (was placeholder data).
• On inbound, ensureConversation refreshes the cached handle if we
just resolved a fresher one.
Followups still queued: cross-server lookups, NATS push, group chats,
"find someone by their published claim" (paste their gist / dns proof
to discover their handle).
Live at https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Time to actually chat. Server is a dumb relay storing opaque envelopes;
recipients decrypt client-side. Everything below is end-to-end encrypted,
the server can't read anything it stores.
Server (kez-chat-server):
• New messages table (seq autoinc, recipient_handle, envelope blob,
created_at). Indexed by (recipient, seq) for cursor paging.
• POST /v1/messages
body: { to: handle, envelope: <opaque JSON> }
validates recipient exists; rejects > 256 KB envelopes.
• GET /v1/inbox/:handle?since=<seq>&limit=<n>
auth: X-KEZ-Auth: <unix_ts>:<sig_hex>
sig = ed25519(handle's primary,
"GET\n/v1/inbox/<handle>\nsince=<n>\n<ts>")
60s clock-skew tolerance; signed message includes cursor so a
captured header can't page through history.
• New ApiError::Unauthorized → 401.
• kez-core: verify_ed25519_hex is now pub so the auth handler can
use it for arbitrary-message verification (outside JCS envelopes).
Crypto (browser):
• ed25519 seed → x25519 priv via Montgomery conversion
(ed25519.utils.toMontgomerySecret).
• ed25519 pubkey → x25519 pubkey for the recipient (toMontgomery).
• ECDH → 32-byte shared secret → HKDF-SHA256(salt=nonce, info=
"kez-chat-msg-v1") → AES-256-GCM key.
• Per-message random 12-byte nonce; each message gets a unique AES key.
• Sender signs envelope-minus-sig with their ed25519 primary so the
recipient can confirm the sender authored the ciphertext + binding.
SPA UI:
• /messages route, two-pane layout (sidebar conversations, thread view,
compose box).
• 5-second poller against /v1/inbox using the global cursor; new
messages get decrypted + appended to the right thread.
• Local IDB cache (lib/conversations-store.ts) so decrypted history
survives reloads. Dedupes by seq+direction.
• Page-specific max-w-6xl so the two-pane layout has room.
Tests:
• 6 new unit tests in messages.rs covering auth header verification
(stale ts, wrong handle, wrong cursor, malformed).
• 4 new integration tests in tests/http.rs: full send + inbox round-
trip, wrong-signer rejected, missing header rejected, unknown
recipient → 404.
• All 17 chat-server tests pass.
Followups (deferred):
• NATS WebSocket push (live messages without 5s poll lag).
• Group chats with proper member-key rotation.
• Reverse handle resolution (/v1/by-primary) so the UI can show
"@alice" instead of the truncated ed25519 hex.
• At-rest encryption for the IDB conversations cache.
• Sender spam mitigation on POST /v1/messages.
Live at https://kez.lat — try /messages with two browsers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The dashboard's Claims section used to be a "here's what claims are"
teaser with a link to /claims. Now it shows the actual verified rows:
• Empty state → "+ Add your first claim" CTA
• Claims exist, none verified → amber callout pointing at Re-verify
• Verified claims → green rows, one per verified claim:
[GitHub] github:tudisco
✓ Verified via public gist proof ↗
• Trailing summary if relevant: "1 failed verification. 2 not yet
checked. See the claims page for details."
A "Re-verify all" button at the section header re-runs every verifier
in place, so the dashboard stays fresh without a round-trip to /claims.
Uses $derived to bucket claims by verification status. $state.snapshot
before passing each claim to the verifier (same proxy/structuredClone
gotcha as the Claims page).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
If a NIP-07 nostr extension (Alby, nos2x, Flamingo, …) is loaded into
the page, the Add Claim flow detects it and offers two shortcuts:
• Identifier step (nostr): "⚡ Use my nostr extension" button reads
window.nostr.getPublicKey() and fills the npub via nip19.npubEncode
— no copy/paste from Damus/primal needed.
• Publish step (nostr): "⚡ One-click publish via your nostr extension"
wraps the kez markdown block in a kind-1 nostr post, asks the
extension to sign it (the user's nostr key never enters this app —
the extension gates each signature behind its own UX), and
broadcasts to the same 5-relay pool the verifier reads from.
Result UI shows ✓ N relays ok + an njump.me evidence link, and
lists any relay that didn't ack.
New module lib/nip07.ts holds the wrapper — hasNip07, getNostrNpub,
publishKezClaimToNostr — so future flows (Dashboard, sigchain push)
can reuse the same plumbing.
Initial JS: 130→134 KB. nostr-tools stays in its own dynamic-import
chunk so the extension code only loads when the user clicks one of
these buttons.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Most users won't manually craft a NIP-78 kind-30078 event with a d=kez
tag — that needed a nostr client most folks don't have. So verifiers
now look in all three sensible spots and the user picks whichever is
easiest to publish:
1. Kind 0 (profile metadata) — kez fence in the `about` field
2. Kind 1 (text note) — kez fence in the post body
3. Kind 30078 (NIP-78) — envelope as event content (advanced)
Web (kez-chat/web):
• New verifier implementation (replaces the v0.1 stub). Adds nostr-
tools (~108 KB) under dynamic import so it lands in its own chunk
— initial JS only grew 128→130 KB.
• SimplePool.querySync against five public relays (Damus, nos.lol,
primal, snort, nostr.wine), 4s timeout, kinds [0,1,30078] in one
REQ. Returns ✓ on first match, with an evidence_url to njump.me.
• AddClaim instructions for nostr rewritten — "pick whichever is
easiest" with concrete steps for each.
Rust (kez-channels):
• Filter now includes kinds [0, 1, 30078], limit bumped to 200.
• extract_proof_body() pulls the right candidate out of each event:
- kind 0 → JSON-decode content, return `about`
- kind 1 / 30078 → return content as-is
• 4 new unit tests (extract_proof_body for each kind incl. malformed
profile) + 2 new integration tests:
- verifies_proof_from_profile_about_field
- verifies_proof_from_kind_1_post
• Updated existing integration tests for the new filter shape.
All 11 unit + 7 integration nostr tests pass. Live at https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DNS verifier used to say "no envelope found" even when a kez:z1: TXT
was sitting there but failed to parse (DNS providers can mangle bytes
at 255-char segment boundaries). GitHub verifier said "no proof found"
even when the gists API returned 403 — rate-limited from the browser
(unauthenticated GitHub allows only 60 req/hr/IP).
Now:
- DNS: distinguishes "found a kez record but it's corrupted" from
"no kez record exists." Calls out provider-side segment mangling
and tells the user to re-publish.
- GitHub: returns the actual HTTP status and rate-limit reset time
when the gists API rejects the request.
- Both: when an envelope's primary doesn't match the local key, the
error explicitly notes "probably signed with an older build — re-sign
and re-publish" (relevant to anything created before cd8dda6).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AddClaim.svelte passed session.unlocked (an UnlockedIdentity, shape
{handle, server, primary, seed}) to signClaim, which expects an
Ed25519Identity ({seed, publicKey, identity}). Different fields:
session.unlocked.identity is undefined.
Result: payload.primary was undefined → JCS omits it → signature was
valid over a payload-without-primary, and signature.key was also
undefined. Verifiers correctly rejected these envelopes — and the
markdown header read "Primary: undefined".
Fix:
- AddClaim: derive a real Ed25519Identity via identityFromSeed(session.
unlocked.seed) before calling signClaim. The seed is the canonical
source of truth — publicKey + identity are derived deterministically.
- signWith: throw if signer.identity is missing or seed is malformed.
Belt-and-suspenders so a future caller passing the wrong shape gets
a loud error instead of producing silently unverifiable envelopes.
Note: any claims signed before this fix have invalid signatures and
must be re-created. Remove them on the Claims page and re-add.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Plugin layout (one file per channel — easy to extend):
lib/verifiers/{dns,web,github,nostr,bluesky,ap}.ts
lib/verifiers/types.ts — VerifyResult + ok/fail/skipped builders
lib/verify.ts — dispatcher routing on claim.channel
Live verifiers (browser-native, no CORS proxy):
• DNS — Cloudflare DoH /dns-query, TXT at _kez.<domain>
• web — fetch <base>/.well-known/kez.json
• github — public gists API for kez.md + <user>/<user> README
Deferred to v0.2 (stubs return "skipped" with a hint):
• nostr — needs ws relay pool + NIP-19
• bluesky — needs AT-Proto client
• ap — WebFinger CORS hostile from browsers
Verification flow (all channels):
1. Fetch the published artifact via the channel's transport
2. parseAnyEnvelope() handles kez:z1: compact, ```kez fences, or raw
3. Check subject + primary against the stored claim
4. Re-canonicalize payload (JCS) and verify ed25519 signature
UI changes on /claims:
• Status badge per claim: ✓ Verified / ✗ Failed / — Skipped / Not verified
• Per-claim "Verify" button + a "Verify all" button at the top
• Expandable details panel showing the evidence URL and any error info
• Latest result persists in IndexedDB (with $state.snapshot for cloning)
kez.ts gains verifyEnvelope() and parseAnyEnvelope() — also useful to
any future verifier (CLI, sig-server, third-party).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Save claim was silently failing — button click did nothing. Cause:
\`envelope\` lives in \$state, which wraps the value in a deep Proxy;
idb-keyval calls structuredClone internally, which can't clone proxies
and throws DataCloneError. Without a try/catch the error vanished into
the console and the step transition never ran.
- Pass \$state.snapshot(envelope) to addClaim so IDB sees a plain object.
- Wrap in try/catch + alert so future IDB failures surface to the user
instead of dying silently.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add @bokuweb/zstd-wasm; replace the kez:zd1: deflate-raw placeholder
with spec-compliant kez:z1: zstd(JSON envelope) compact form.
- Dynamic import keeps the WASM (~348 KB) in its own Vite chunk so the
initial bundle only grows from 113 KB to 116 KB; the WASM is fetched
the first time a user picks compact format.
- AddClaim.svelte: 3-way format toggle (compact / markdown / JSON).
DNS defaults to compact since TXT records want the shortest payload.
- Drop the v0.1 apology in DNS instructions — kez:z1: is the spec form
and verifiers can decompress it directly.
- Cross-impl interop verified: browser-generated kez:z1: decompresses
cleanly in the Rust CLI and the Node port, byte-for-byte modulo
JSON key-order whitespace.
Deployed live to https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First real UI for kez-chat. Served by the chat-server as static
files; uses the same HTTP API a native client would (dogfoods the
contract).
Stack: Svelte 5 + TypeScript + Vite + Tailwind 4 + @noble/curves +
@scure/base + canonicalize + idb-keyval + svelte-spa-router.
Bundle: 113 KB JS / 14 KB CSS (gzip: 42 KB / 4 KB).
Pages (all behind hash routing):
/ Landing — sign up or restore from seed
/create Account creation flow:
1. Pick handle, set passphrase
2. Show seed for paper backup, require ack
3. Confirm
4. POST /v1/register, save passphrase-encrypted seed
to IndexedDB
/restore Stub for restore-from-seed (v0.2: needs
GET /v1/by-primary endpoint on the server)
/unlock Enter passphrase to derive the AES-GCM key,
decrypt the seed, populate session state
/dashboard Show handle, primary, registered_at, sigchain URL
/claims List locally-cached claims (with publication status)
/claims/add Add-a-claim wizard:
1. Pick channel (github/dns/web/nostr/bluesky/ap)
2. Enter identifier
3. SignedClaimEnvelope built + signed in-browser
using Ed25519 + JCS, matching the spec exactly
4. Show channel-appropriate publish instructions +
copyable markdown or JSON artifact
5. User marks it published (purely a local note —
actual verification is the verifier's job)
Crypto / KEZ helpers (src/lib/kez.ts):
- generateIdentity / identityFromSeed (32-byte Ed25519)
- canonicalBytes (RFC 8785 JCS via the `canonicalize` package — same
one our Node port uses; produces byte-identical output to Rust)
- signClaim, signRegistration (build envelopes; sign with
ed25519-sha512-jcs; same alg / key / sig shape as kez-core)
- toPrettyJson, toMarkdown (the same wire encodings the CLI emits)
Key storage (src/lib/identity-store.ts):
- IndexedDB via idb-keyval
- Seed encrypted under user passphrase: PBKDF2-SHA256
(600,000 iterations, OWASP 2024 guidance) → AES-GCM-256
- Documented limitation: browsers don't have an OS-keychain
equivalent. Native clients (future CLI/Tauri) will use the OS
keychain for better protection.
Bundle includes:
- Workaround for TS 5.6+ Uint8Array<ArrayBufferLike> vs ArrayBuffer
strictness (small asBuffer() helper that copies into a plain
ArrayBuffer for WebCrypto + Response calls).
Dockerfile updated: now multi-stage with a Node `webbuild` stage
that runs `npm run build` before the Rust binary stage. SPA dist
is copied into the runtime image at /app/web; chat-server's
KEZ_CHAT_WEB_DIR points at it so the SPA is served at /.
What works against the LIVE deployment right now (https://kez.lat):
- Open https://kez.lat → SPA loads (113 KB JS, 14 KB CSS)
- Create account → key gen happens in browser, seed shown for
backup, encrypted under passphrase, POSTed to /v1/register
- Dashboard → shows registered handle + primary + sigchain URL
- Claims wizard → sign for any of the 6 channels, get publish
instructions + the right wire format to copy
- Lock / unlock — passphrase-derived AES-GCM, no roundtrips
What's still TODO (v0.2):
- Restore-from-seed: needs GET /v1/by-primary on the server so the
SPA can discover the handle from a seed
- Actual NATS chat: needs server's auth callout (currently 501) +
nats.ws client (browser side; package is in deps but not used yet)
- Sigchain integration: append `add` event when user publishes a
claim, upload to sig-server (needs sig.kez.lat tunnel)
- Verification: in-browser channel fetches (some channels are
CORS-friendly, others need a server-side proxy)
- Compact (kez:z1:) form: the spec uses zstd, browsers don't have
native zstd CompressionStream support yet. Workaround in code
uses deflate-raw with a `kez:zd1:` prefix to make it obvious the
output isn't spec-compliant; replace with @bokuweb/zstd-wasm or
similar when we need true compact form in the SPA.