7 Commits

Author SHA1 Message Date
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
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
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
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
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
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