38 Commits

Author SHA1 Message Date
89cb9f11e0 feat(kez-chat): basic image attachments over nostr
Inline + chunked file transfer. No central host, no separate file
encryption key — the existing v2 envelope crypto + nostr publish
path covers it. Files up to 10 MB; larger refused with a friendly
error.

Inline path (≤80 KB raw)
  - File body is a JSON kez-file-v1 / inline payload with base64
    data, sealed by the normal envelope and published as a single
    kez-DM event. Same delivery semantics as text.

Chunked path (80 KB – 10 MB)
  - Raw bytes split into ~80 KB chunks (each fits comfortably under
    the ~256 KB envelope-content ceiling most relays enforce).
  - Each chunk is its own kez-DM event with body kez-file-chunk-v1
    (file_id + i + n + base64). One signed event broadcast to all
    5 default relays → ~99.999% per-chunk delivery.
  - Publish throttled to ~5 events/sec to keep stricter relays
    happy. A 5 MB image lands in ~12 seconds end-to-end.
  - Pointer event (kez-file-v1 / chunked mode) sent last — it's
    the user-visible message; chunks are silent plumbing.

Receive path
  - parseFileBody discriminates plain text vs inline vs pointer vs
    chunk on the existing inbox-service decrypt path. Plain text
    still routes through the regular appendInbound.
  - Pointer arrival: create a "pending" attachment row + record the
    destination on the chunk-buffer entry.
  - Chunk arrival: append to the chunk buffer keyed by file_id;
    once n/n are present AND a destination is known, finalize.
  - Finalize: assemble in-order, decode to a data URL, save to the
    local attachment store, flip the attachment.state to "ready".
  - Pointer-before-chunks and chunks-before-pointer both work — IDB
    chunk buffer survives reloads so a partial transfer resumes.

UI (Messages.svelte)
  - Paperclip button next to the emoji button. Hidden file input
    with accept=image/* (broader types easy to enable later).
  - Optimistic local echo on send: bubble + image preview appear
    instantly from the local-copy data URL. Status icon proceeds
    "sending" → "sent" → "delivered" exactly like text messages.
  - Bubble render branches on m.attachment:
      • image MIME + ready  → inline <img>
      • non-image MIME + ready → filename + size + download link
      • pending → spinner with "Receiving N/M…"
      • failed → red ⚠ chip

Storage (attachment-store.ts)
  - kez-chat:attachments:v1 — ready files keyed by
    peer_primary|seq, value = {filename, mime, data_url, size}.
  - kez-chat:chunk-buffer:v1 — in-flight chunks keyed by file_id,
    value = {n, received: {i: bytes}, destination?}.

Schema (conversations-store.ts)
  - ConversationMessage.attachment? = {filename, mime, size, state,
    file_id?, received_chunks?, total_chunks?}.
  - Helpers: appendInboundAttachment, appendOutboundAttachment,
    patchAttachmentState, findAttachmentByFileId.

Out of scope (intentional for v0.1)
  - Larger than 10 MB: refused with a friendly error.
  - Reed-Solomon erasure coding: not needed — single signed event
    broadcast to 5 relays delivers reliably enough. Recovery from
    the rare missing chunk via DM round-trip is a future hook.
  - Reverse channel ("still need chunks [3,7]"): protocol slot
    left open; not implemented.
  - HEIC → JPEG conversion: iOS Safari may hand us the original
    HEIC bytes; recipients on non-Apple devices can't render. Fix
    in the picker layer if it bites.
  - Server-transport stub: throws a clear "use VITE_TRANSPORT=nostr"
    error so the UI can't silently misroute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 14:51:58 -06:00
c8370ffdf0 feat(kez-chat): three days of security + UX + protocol work
A multi-day batch covering: a security review punch list (Day 1-3),
the visual-encryption profile-picture feature, a fast deploy
infrastructure, ack resilience + UX polish, several nostr ecosystem
alignment changes, and a key server-independence fix.

Security review (Day 1)
  - Envelope v2 (ephemeral x25519 per message; AAD-bound AES-GCM;
    no plaintext from/to). Big metadata-leak fix flagged by reviews.
    Forward secrecy at the per-message level; v1 decrypt kept for
    a one-week migration window.
  - Routing tag rename h → q (NIP-29 collision fix).
  - Web Push payload now empty (was leaking recipient handle to FCM).
  - Log demotion + process-instance salt-hash of handles so debug
    logs don't permanently encode the social graph.

Security review (Day 2)
  - Replay protection: SEEN_CAP 500 → 10_000 ids; reject events with
    impossibly old/future created_at; openMessage enforces ±7d/+5min
    freshness on plaintext sent_at.
  - Reveal Recovery Phrase now requires a fresh passphrase prompt.
    Verifies via the same unlockIdentity that the initial unlock
    uses. Bonus: works for biometric-only sessions (recovers the
    phrase that wasn't in memory before).
  - POST /v1/messages per-IP rate limit (60/min, capacity 60, with
    a periodic idle-bucket sweep). New rate_limit.rs module + tests.

Security review (Day 3 Option A)
  - Unforgeable acks: kind-4244 events now carry a `kez-sig` tag,
    the recipient's ed25519 signature over the acked event id.
    Sender verifies against the conversation peer's KEZ primary.
    Unsigned acks still accepted during the migration window.
  - Default `since=` lookback shortened 7d → 48h (matches relay
    retention).
  - Bounded concurrency on push fanout: tokio::sync::Semaphore(32).

Security review (Day 3 Option B — nostr ecosystem alignment)
  - NIP-65 (kind:10002): publish our relay list so other clients
    can discover where to find us.
  - NIP-42 AUTH: attachSigner / detachSigner wires the user's seed
    into the relay pool so AUTH-gated relays (damus.io DMs) deliver.
  - Minimal kind:0 baseline on first session unlock (unblocks
    writes on relays that reject unknown pubkeys).
  - Acks now include ["p", senderNostrPubkey] for NIP-25 / NIP-10
    routing convention.

Web Push end-to-end
  - Server-side nostr listener (nostr_listener.rs): the chat-server
    subscribes to relays for every registered handle's addr, so
    Web Push fires even when chat goes over nostr (the live default).
  - Push fanout from messages.rs spawned with bounded concurrency.
  - Empty payload (no recipient handle leaked to FCM).
  - Self-heal endpoint GET /v1/push/subscriptions/:handle —
    auto-re-registers a subscription if server lost it.
  - Auto-enable push on first unlock (was opt-in toggle hunt).
  - In-chat nudge banner + iOS PWA install hint.

Persistent sessions
  - persistent-session.ts: AES-GCM encrypt seed under a
    non-extractable IDB key, 30-day sliding-window TTL, restore on
    every boot.
  - Auto-fetch own kind:0 from nostr on a fresh device so the user
    sees their own avatar without re-setting it.

Profile pictures + visual encryption
  - Avatar component accepts a `picture` prop (data URL); falls
    back to the deterministic identicon when absent.
  - profile-store.ts: pick → resize to 256×256 JPEG → save locally
    + publish as NIP-01 kind:0.
  - Visual encryption (visual-crypto.ts): keyed Fisher-Yates pixel
    permutation + xoshiro256** PRNG. Output is a valid PNG with
    scrambled content. Salt embedded as a #kez-visual-v1:<hex>
    URL fragment.
  - Default ON for new pictures. Strangers see colored noise on
    public nostr; contacts see the real face.
  - Per-recipient AES wraps embedded in kind:0 content
    (kez_visual_keys map). The picture's symmetric key is wrapped
    via the same SealedEnvelope crypto our DMs use.
  - Self-wrap (sender wraps to their own primary too) so a fresh
    device of the same user can descramble its own picture.
  - Stranger-view preview thumbnail in Settings (the badge tucked
    into the avatar's bottom-right corner — "this is what the
    world sees").
  - Tap-to-zoom: header avatar in a thread opens a fullscreen
    overlay.

Peer-profile resolution
  - peer-profile-store.ts: IDB-cached one-shot kind:0 fetch +
    descramble.
  - peer-profile-cell.svelte.ts: reactive mirror for UI.
  - 6h bulk-scan staleness + force-refresh on thread open.
  - Avatar usages in Messages.svelte pass peer.picture through.

Local-echo + delivery receipts
  - Outbound messages render instantly with status="sending"; flip
    to "sent" when ≥1 relay accepts; "delivered" (check-in-circle)
    when recipient's client publishes an ack.
  - SVG status icons inside the bubble; "via X" footer on outbound.
  - Persistent pending-ack queue with retry on next session start.
  - Catch-up scan (fetchAcksForEventIds) self-heals delivered
    state on conversation open.
  - markDeliveredByEventId verifies the ack signature.

Active-relay tracking + reply preference
  - SimplePool.trackRelays = true. Capture first-to-accept on send
    via Promise.any over per-relay publish promises.
  - InboxMessage.via_relay set from pool.seenOn on receive.
  - Conversation.peer_via_relay persisted on every inbound DM.
  - sendMessage takes `preferRelay` and orders publish targets
    accordingly. Acks bias the same way.
  - "via relay.X" footer renders on outbound bubbles.

Conversation list polish
  - Per-conversation `unread_count` on the Conversation type.
  - Bumped on every genuinely-new inbound; reset on thread open.
  - Accent-color pill badge in the sidebar (rounds at "99+").

Server-independence fix
  - sendMessage skips the /v1/u/:handle lookup when the caller
    passes recipientPrimary (which Messages.svelte does, from the
    cached peer_primary on the conversation row). Chat over nostr
    no longer breaks when the chat-server is down — only brand-new
    conversations still need the directory lookup.

Relay set
  - Added wss://relay.snort.social and wss://nostr.wine to the
    default pool (was 3, now 5).

Fast-deploy infrastructure (new in this batch)
  - Dockerfile gains an `export` scratch stage (extracts binary +
    web/dist only).
  - Dockerfile.runtime: tiny runtime image that COPYs prebuilt
    artifacts — no rust/npm on the remote.
  - docker-compose.fast.yml: compose override pointing chat-server
    build at Dockerfile.runtime.
  - .dockerignore: excludes target/, node_modules/, prebuilt/,
    .buildx-cache/, .git, *.db. Critical: without this, an earlier
    bug had the buildx cache nested under the build context and
    blew up to 17GB by feeding itself into itself.
  - Old: ~10 min remote build. New: 3–5 min local + 5s remote
    runtime swap. Cache lives at ~/.cache/kez-chat-buildx
    (outside any project tree).

UI polish (margins, layout, banners)
  - Authenticated routes (Welcome / Settings / Identity / Dashboard
    / Claims / AddClaim) wrapped in max-w-2xl mx-auto px-4 py-6.
  - WhatsApp-style chat bubbles: shrink-wrap to content, asymmetric
    rounded corners, inline bottom-right timestamp.
  - Push-notification nudge banner at top of /chats with iOS
    install hint.
  - Relay state popover off the "● live (N)" indicator.

WebAuthn biometric fix
  - user.id now uses the raw 32-byte ed25519 pubkey (was the
    72-byte "ed25519:<hex>" identity string, which exceeded
    WebAuthn's 64-byte limit — Android Chrome rejected it with
    "user handle exceeds 64 bytes").

Documentation
  - kez-chat/TODO.md tracks every reviewer finding with status,
    file:line references, and a phased plan. All Day 1-3 items
    marked DONE; remaining roadmap items (Double Ratchet,
    WebAuthn-gated rehydrate, addr rotation, NIP-65 peer-relay
    fetch on send) documented for future sprints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 05:11:24 -06:00
60aeaedbad feat(kez-chat): Web Push notifications + WhatsApp-style chat bubbles
Server (kez-chat/src/)
  - push.rs: VAPID (PEM/PKCS#8) auto-generated on first run;
    StoredSubscription store table; PushSender using
    IsahcWebPushClient; fanout drops 410/404 subs automatically.
    Push payload carries metadata only ({type,to,seq}) — never
    plaintext or ciphertext.
  - api.rs: GET /v1/push/vapid-public-key,
    POST /v1/push/subscribe/:handle, POST /v1/push/unsubscribe/:handle.
    Auth via X-KEZ-Auth: <ts>:<sig>, canonical message binds the
    endpoint URL so headers can't be replayed against other subs.
  - messages.rs: after broker.publish, fire-and-forget
    push.fanout for offline recipients.
  - config.rs: --vapid-key-path, --vapid-subject (env-backed).
  - main.rs: load_or_generate_vapid on startup.

Web client (kez-chat/web/src/)
  - vite.config.ts: switched vite-plugin-pwa to injectManifest mode.
  - sw.ts: custom service worker with workbox precache,
    NetworkOnly for /v1/*, NavigationRoute SPA fallback, push +
    notificationclick handlers (focus existing tab via postMessage,
    or open a new one).
  - lib/push.ts: enablePush / disablePush / isPushSubscribed +
    iOS PWA-install detection.
  - routes/Settings.svelte: "Background notifications (Web Push)"
    section with toggle and iOS Add-to-Home-Screen nudge.
  - main.ts: bridge from SW navigate message to svelte-spa-router
    via location.hash.

Chat UX (routes/Messages.svelte)
  - Bubbles now shrink-wrap to content with WhatsApp-style asymmetric
    corners and inline bottom-right timestamps. Old layout used
    nested block-level divs inside max-w-[78%], which stretched
    every bubble to full width regardless of content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 21:47:07 -06:00
5ad47a917d feat(kez-chat/web): mirror local claims to chain-service sigchain
When the user adds a claim, append an `add` event to their sigchain
on the chain service (rust-sig-server); when they remove a claim,
append a `revoke`. Implements SPEC.md §8 — the sigchain is now the
canonical, verifiable record of what the user currently claims, not
a per-claim field.

  • lib/sigchain-service.ts: new module that fetches the current chain
    to compute the next seq + prev hash, signs the event locally (the
    chain service never sees the seed), and POSTs. Returns a typed
    SigchainSyncResult so the caller can record the seq + status.
  • lib/kez.ts: sigchain event types + helpers (nextChainCursor,
    sigchainEventHash, signSigchainEvent, SignedSigchainEvent /
    SigchainOp types). Mirrors the Rust + Node + Python core surface.
  • lib/api.ts: getSigchain (GET full chain) + postSigchainEvent
    (POST one signed event) wrappers against the chain service.
  • lib/claims-store: StoredClaim gains chain_service, sigchain_seq,
    sigchain_status ("synced" | "error"), sigchain_error fields +
    setSigchainSync helper.
  • routes/AddClaim: on successful claim creation, fires an `add` to
    the chain service in the background; surfaces sync errors with a
    "Retry sync" button.
  • routes/Claims: a `revoke` is posted to the chain service first
    when the user removes a claim. Best-effort — if the service is
    unreachable, asks before dropping the local copy so the chain
    doesn't silently drift. Per-row "Sync to chain" button retries
    failed adds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 22:43:16 -06:00
3fdbdc9fcf feat(kez-chat/web): 12-word recovery phrase replaces hex seed in account flow
Brings the BIP-39 mnemonic surface (CLI + libs landed in 0058d9b /
b0cc1a7) into the chat app's user-facing account flow. Match the same
SHA-256 domain-tag derivation as Rust / Node / Python — a phrase
generated in the browser verifies against the spec vectors in
python/MNEMONIC-TEST-VECTORS.md byte-for-byte.

  • New lib/mnemonic.ts: browser-native helpers (generateMnemonic12,
    seedFromMnemonic, mnemonicFromSeed24, ed25519FromMnemonic,
    generateIdentityWithMnemonic, isValidMnemonic). Uses @scure/bip39
    (same lib as Node impl) + the same domain tag "kez-bip39-12-v1".
    12-word phrases by default; restore accepts 24-word too for parity
    with the CLI.

  • lib/identity-store.ts: StoredIdentity gains optional
    phrase_nonce + phrase_ciphertext, encrypted under the SAME
    PBKDF2-derived key as the seed (fresh nonce — AES-GCM reuse is
    fatal). unlockIdentity returns the phrase when present. New
    hasStoredPhrase() helper distinguishes "phrase exists but not
    accessible in this session" (biometric unlock) from "truly legacy
    hex-only account".

  • CreateAccount: generates via generateIdentityWithMnemonic. Step 2
    now shows the 12 words in a numbered grid with a "copy all" button
    and a real ack checkbox before continuing. Step indicator updated
    to "2. Back up phrase".

  • Restore: was previously a stub that always threw "v0.1 limitation".
    Now actually works — accepts either a 12/24-word phrase OR a
    legacy 64-char hex seed (auto-detected), looks up the handle via
    /v1/by-primary, derives the seed, saves identity, unlocks, routes
    to /welcome.

  • Settings: "Reveal seed" → "Reveal phrase". Three-state output:
        - phrase in session → show 12 words
        - phrase stored but biometric session → tell user to passphrase-
          unlock to reveal
        - truly legacy → show hex seed with explanation

  • Welcome (onboarding): "Back up your recovery seed" step renders the
    phrase as a numbered grid when available, falls back to the hex
    block with a "Legacy 64-char hex" caption for pre-mnemonic accounts.

Biometric unlock continues to surface only the seed (the phrase blob is
encrypted under the passphrase-derived key, not the PRF-derived key) —
documented in the Settings UX. Encrypting under PRF too is a v0.3
follow-up.

Backwards compatible: existing accounts (which have only the
seed-ciphertext) unlock fine; their phrase fields stay undefined; the
UI falls back to the hex flow throughout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 18:14:52 -06:00
Jason Tudisco
878965924b Add nostr chat notes, update favicon, add test.txt 2026-06-01 13:31:48 -06:00
Jason Tudisco
7bbf8baf86 feat(kez-chat/web): show verified badge in chat; require 2+ proofs
The verified checkmark only appeared on the profile, never in chat, even
for clearly-verified peers. Three gaps fixed:

- Chat verified only the open conversation, so list items never showed a
  badge. Verify all conversations on load (24h per-peer cache).
- A peer's proofs were only published to the server on a manual "reverify
  all", so verified users were invisible to peers. Auto-publish verified
  subjects when the Identity page loads.
- Unify the threshold: a badge now requires >=2 independently-verified
  proofs, in both chat (VERIFY_MIN_PROOFS) and the profile (isVerified),
  so "verified" means the same thing everywhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:37:13 -06:00
Jason Tudisco
41f9442650 feat(kez-chat/web): Nostr-relay chat transport (behind VITE_TRANSPORT)
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>
2026-05-28 13:30:20 -06:00
Jason Tudisco
e1f2514fae feat(kez-chat/web): route new accounts to /welcome onboarding checklist
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>
2026-05-28 13:30:05 -06:00
Jason Tudisco
dac98486c5 feat(kez-chat/web): first-run onboarding — Getting Started checklist
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>
2026-05-28 11:33:47 -06:00
Jason Tudisco
a2538b2886 feat(kez-chat): verified-user badge in chat (X/Twitter-style, but real)
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>
2026-05-27 23:40:11 -06:00
Jason Tudisco
fc75b27ac6 feat(kez-chat/web): light theme + Light/Dark/System toggle
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>
2026-05-27 22:31:27 -06:00
Jason Tudisco
0d7e48bed0 fix(kez-chat/web): blank page after login — redirect to /chats not /dashboard
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>
2026-05-27 22:17:36 -06:00
Jason Tudisco
7bbe336f6b fix(kez-chat/web): @theme block was being dropped — theme never applied
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>
2026-05-27 22:06:55 -06:00
Jason Tudisco
a9ef611622 design(kez-chat/web): restyle auth + claims pages to the dark theme (phase 6)
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>
2026-05-27 21:54:26 -06:00
Jason Tudisco
40ebd63ed7 design(kez-chat/web): new IA + nav shell, Chats/Identity/Settings (phase 1-2)
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>
2026-05-27 21:50:10 -06:00
Jason Tudisco
60ff82b4a2 design(kez-chat/web): redesign foundation — tactical-terminal theme + tokens
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>
2026-05-27 21:45:21 -06:00
Jason Tudisco
4b01c2296d fix(kez-chat/web): notify on poll-delivered messages too + add test button
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>
2026-05-27 00:33:15 -06:00
Jason Tudisco
4eeedb38fb feat(kez-chat/web): auto-scroll thread on new message + on conversation open
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>
2026-05-27 00:27:33 -06:00
Jason Tudisco
de120f7d6c fix(kez-chat/web): pass build sha into Docker build via BUILD_SHA file
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>
2026-05-27 00:19:12 -06:00
Jason Tudisco
76fcaa1d3c feat(kez-chat/web): always-on inbox stream + unread badge + browser notifications
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>
2026-05-27 00:10:29 -06:00
Jason Tudisco
ca5290dc0f fix(kez-chat/web): emoji picker pops up as overlay instead of squeezing compose
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>
2026-05-27 00:03:38 -06:00
Jason Tudisco
d789e872b1 feat(kez-chat/web): SW auto-reload on deploy + visible build sha in footer
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>
2026-05-26 23:58:26 -06:00
Jason Tudisco
ea139641e3 feat(kez-chat/web): biometric / passkey unlock via WebAuthn PRF
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>
2026-05-26 23:25:15 -06:00
Jason Tudisco
46cb58307c feat(kez-chat/web): emoji picker + emoji-only message style boost
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>
2026-05-26 23:19:59 -06:00
Jason Tudisco
bd8c8bf606 feat(kez-chat): real-time messages via SSE — sub-second delivery
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>
2026-05-26 23:06:17 -06:00
Jason Tudisco
6c0f5e2fd5 feat(kez-chat/web): make the SPA installable as a PWA
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>
2026-05-25 22:23:14 -06:00
Jason Tudisco
7e9dc0773a feat(kez-chat): Messages UX rebuild — Keybase-style, friendly handles, explainer
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>
2026-05-25 22:12:46 -06:00
Jason Tudisco
5cb46e2aa1 feat(kez-chat): v0.1 chat — encrypted 1:1 messages (server + web client)
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>
2026-05-25 16:10:43 -06:00
Jason Tudisco
109852ed75 feat(kez-chat/web): dashboard shows verified claims
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>
2026-05-25 15:34:52 -06:00
Jason Tudisco
a8036cc392 feat(kez-chat/web): NIP-07 extension support — autofill npub + one-click publish
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>
2026-05-25 15:18:28 -06:00
Jason Tudisco
c133be0589 feat(kez,kez-chat/web): nostr verifier checks profile, posts, AND kind-30078
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>
2026-05-25 15:10:10 -06:00
Jason Tudisco
21d9b705b7 fix(kez-chat/web): verifiers surface real reasons instead of silent fall-through
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>
2026-05-25 14:52:59 -06:00
Jason Tudisco
cd8dda681c fix(kez-chat/web): signClaim was producing envelopes without primary/key
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>
2026-05-25 14:33:40 -06:00
Jason Tudisco
41f66ae366 feat(kez-chat/web): in-browser claim verification with per-channel plugins
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>
2026-05-25 13:40:07 -06:00
Jason Tudisco
8622da2ba4 fix(kez-chat/web): snapshot envelope before storing in IndexedDB
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>
2026-05-25 13:18:33 -06:00
Jason Tudisco
17d68dbb75 feat(kez-chat/web): real zstd compact form + 3-way format toggle on AddClaim
- 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>
2026-05-25 12:48:44 -06:00
Tudisco
a9feb1b5b2 feat(kez-chat/web): Svelte SPA — account creation + claims wizard
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.
2026-05-25 12:29:14 -06:00