56 Commits

Author SHA1 Message Date
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
b0cc1a74a0 feat(python,crosstest): mirror BIP-39 mnemonic to Python + add interop scenarios
Completes the three-way BIP-39 mnemonic surface (Rust + Node landed in
0058d9b) and pins down byte-for-byte agreement with crosstest scenarios.

Python (mirrors rust/crates/kez-core/src/mnemonic.rs + nodejs's mnemonic.ts):
  • python/kez/mnemonic.py — generate_mnemonic, seed_from_mnemonic,
    mnemonic_from_seed_24, ed25519_from_mnemonic,
    generate_ed25519_with_mnemonic. Same 24-word-bijection / 12-word-
    SHA-256-domain-tagged semantics. Uses Trezor's `mnemonic` library
    (v0.21) for the BIP-39 wordlist + entropy parsing; deliberately does
    NOT use BIP-39's PBKDF2 to_seed function.
  • python/kez/keys.py — Ed25519Secret.from_mnemonic() +
    generate_with_mnemonic() classmethods; signer_from_flags widened to
    accept --mnemonic.
  • python/kez/cli.py — identity new --mnemonic-words, identity
    mnemonic [--words], identity from-mnemonic; --mnemonic flag on
    claim create/dns and sigchain add/revoke/show/export. Output format
    matches Rust + Node verbatim so the crosstest harness can grep
    Primary/Public/Secret/Mnemonic lines.
  • python/tests/test_mnemonic.py — 19 tests covering all three
    canonical vectors (exact-match Secret + Public hex), round-trip,
    determinism, whitespace tolerance, bad-checksum, bad-word-count,
    the literal domain-tag bytes, and the 12-vs-24 entropy-overlap
    non-collision case.

Note: --mnemonic is NOT added to `sigchain publish` because that
subcommand doesn't exist in the Python CLI yet (rust + node only). When
the publish surface is ported, --mnemonic should follow it the same way.

Ground truth — python/MNEMONIC-TEST-VECTORS.md:
  V1: 24-word zero-entropy phrase ("abandon… art")
      seed   = 0000…0000
      pubkey = 3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
  V2: 12-word zero-entropy phrase ("abandon… about")
      seed   = 09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da
      pubkey = 9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70
  V3: 12-word "legal winner thank year wave sausage worth useful legal winner thank yellow"
      seed   = 9df434a2bd5dc767ee949d8ab95ca09c4ebbb88cefc3d0b1523f6b2a744ca824
      pubkey = cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03

  The domain tag for the 12-word derivation is exactly the 15 ASCII
  bytes of "kez-bip39-12-v1", documented in the spec doc.

crosstest.sh — new "BIP-39 mnemonic interop" section:
  • Vector match: each impl × each vector × Public hex == expected (9
    scenarios). Catches any silent derivation drift.
  • Cross-impl claim signing via --mnemonic: every signer ↔ verifier
    pair (rust↔node, rust↔py, node↔py), every format (json/compact/
    markdown). 6 pairings × 3 formats = 18 scenarios.
  • Bijection sanity: the 24-word phrase printed by `identity from-
    mnemonic` round-trips to itself byte-for-byte (rust + node).
  • Python-involving scenarios auto-skip if `python/.venv/bin/python
    kez_cli.py identity from-mnemonic` returns non-zero, so the harness
    stays runnable on machines where Python isn't set up.

Verified end-to-end: `bash crosstest.sh` reports
  "All 84 scenarios passed."

Test totals across implementations:
  Rust:   114 (9 mnemonic-specific in kez-core)
  Node:    99 (8 mnemonic-specific in @kez/core)
  Python:  19 (mnemonic only; was no test suite before)
  Crosstest: 84 scenarios end-to-end

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 17:50:34 -06:00
0058d9b421 feat(rust,nodejs): BIP-39 mnemonic phrases for Ed25519 identities
Adds the canonical wallet-style backup form (12 or 24 BIP-39 English
words) to both implementations. Wire-compatible — bit-identical seed
derivation across Rust and Node.

Semantics:
  • 24 words ↔ 32 bytes of entropy ↔ Ed25519 seed (bijection).
    Phrase ↔ seed round-trips exactly.
  • 12 words → 16 bytes of entropy → seed via
    SHA-256("kez-bip39-12-v1" || entropy). Deterministic but one-way;
    you can't recover a 12-word phrase from a seed.

The 12-word case is KEZ-specific (not interoperable with hardware-
wallet BIP-32 derivations). The 24-word case is. Both use the BIP-39
English wordlist so users can paper-back-up alongside other wallets.

We deliberately do NOT use BIP-39's PBKDF2 to_seed(passphrase) — that
produces a 64-byte seed for BIP-32 hierarchical derivation, which is
the wrong primitive for KEZ's single-identity-per-phrase model.

Rust (kez-core):
  • New mod mnemonic with MnemonicWords, generate_mnemonic,
    seed_from_mnemonic, mnemonic_from_seed_24.
  • Ed25519Secret::{from_mnemonic, generate_with_mnemonic}.
  • Dep: bip39 v2.0 with the `rand` feature for OS-RNG generation.
  • 9 unit tests, all green.

Rust (kez-cli):
  • `identity new --key-type ed25519` now also prints a 24-word phrase
    (default), with --mnemonic-words 12 to use 12 instead.
  • `identity mnemonic [--words 12|24]` — print a fresh phrase only.
  • `identity from-mnemonic "<phrase>"` — derive the key from a phrase.
  • `--mnemonic <phrase>` is now accepted everywhere `--ed25519-seed
    <hex>` was (claim create/dns, sigchain add/revoke/show/export/
    publish), mutually exclusive with --ed25519-seed and --nsec via
    clap conflicts_with_all.

Node (@kez/core):
  • New mnemonic.ts with the parallel API:
    generateMnemonic, seedFromMnemonic, mnemonicFromSeed24,
    ed25519FromMnemonic, generateEd25519WithMnemonic.
  • Dep: @scure/bip39 v2.x (note: import path is
    "@scure/bip39/wordlists/english.js" with the .js suffix in v2).
  • 8 vitest cases mirroring the Rust tests, all green.

Node (@kez/cli):
  • Same CLI surface added: identity new --mnemonic-words 12|24,
    identity mnemonic --words 12|24, identity from-mnemonic "<phrase>".
  • --mnemonic flag accepted alongside --nsec / --ed25519-seed in the
    flag parser, with mutex enforcement; loadSigner dispatches it.

Verified cross-implementation interop:
  • Same 24-word phrase → identical Ed25519 pubkey in Rust and Node.
  • Same 12-word phrase → identical pubkey (proves the SHA-256
    domain-tagged derivation matches byte-for-byte).
  • A claim signed in Rust with --mnemonic verifies in Node (Status:
    valid).

Tests: 114 Rust + 99 Node total, zero regressions.

TUTORIAL.md updated in both rust/ and nodejs/ with the new section in
"Pick your primary key" plus a callout that --mnemonic can substitute
for --ed25519-seed throughout the rest of the tutorial.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 17:41:01 -06:00
Jason Tudisco
878965924b Add nostr chat notes, update favicon, add test.txt 2026-06-01 13:31:48 -06:00
Jason Tudisco
b1240c13e5 Add Python implementation and cross-test interop
Add a Python port of the KEZ CLI under python/, mirroring the Rust and
Node implementations command-for-command and byte-for-byte:

- Pure-Python JCS (RFC 8785), BIP-340 Schnorr, and Bech32; cryptography
  for Ed25519 and zstandard for the compact zstd framing.
- Full CLI: identity new, claim create/dns, verify file, and
  sigchain add/revoke/show/export.

Wire Python into crosstest.sh with 35 new scenarios covering Python
against both Rust and Node, in every direction, across all wire formats,
both key types, DNS proofs, and sigchains (incl. JSONL byte parity).
All 55 scenarios pass.

Update root README and .gitignore for the new implementation.
2026-06-01 13:29:45 -06:00
Jason Tudisco
52fe2c225f Merge branch 'nostr' — redesign + verified badge + onboarding + tutorials
13 commits landing from the nostr branch. The big ones:

DESIGN (kez-chat/web)
  • 60ff82b foundation — tactical-terminal theme + tokens (cyan #28C8E8
    on near-black, Inter + JetBrains Mono, identicon avatars, kez▌
    wordmark cursor, DESIGN.md as source of truth)
  • 40ebd63 new IA + nav shell — dashboard-as-home killed; lands on
    Chats; left rail (desktop) / bottom tabs (mobile); Identity +
    Settings split out from the old Dashboard
  • a9ef611 auth + claims pages restyled to the dark theme (phase 6)
  • 7bbe336 @theme block was being dropped — fixed the misplaced
    google-fonts @import + unicode-box-drawing comment that killed
    Tailwind's @theme transform
  • 0d7e48b login no longer blanks — redirects to /chats not /dashboard
  • fc75b27 light theme + Light/Dark/System toggle (deeper cyan accent
    for light, no-flash via inline pre-paint script)

FEATURES (kez-chat)
  • a2538b2 verified-user badge in chat (X-style, but the client
    actually verifies a peer's published proofs against the channel;
    24h cache; server stores subjects for discovery; PUT /v1/profile/
    :handle/proofs authed with X-KEZ-Auth)
  • dac9848 / e1f2514 first-run onboarding — Getting Started checklist
    routed to after CreateAccount; resumable, skippable
  • 41f9442 Nostr-relay chat transport behind VITE_TRANSPORT
  • 7bbf8ba verified badge tightened (require 2+ proofs)

DOCS
  • d10dfb9 rust/TUTORIAL.md — friendly step-by-step for first-time
    users (zero to verified published sigchain in ~15 min)
  • b1f8b3a nodejs/TUTORIAL.md — same tutorial mirrored for the Node
    implementation

All server changes ship with passing tests. Wire-compatible across
both implementations.
2026-05-30 00:21:31 -06:00
Jason Tudisco
b1f8b3a5fb docs(nodejs): add TUTORIAL.md — Node.js mirror of the Rust tutorial
Parallel of rust/TUTORIAL.md (d10dfb9), adapted for the Node.js
implementation. Same end-state for the reader: from "I have a nostr
nsec" to "I have a verified, published sigchain" in ~15 minutes.

Node-specific adaptations:
  • Install (Node 22+ note for the built-in WebSocket the nostr
    channel needs, npm 9+ workspaces, optional `npm link` for global
    `kez` instead of `npm run cli --`).
  • Every command uses `npm run cli --` to match the README's
    existing convention; explicit "-- swallowed flags" callout.
  • New section 8 "Programmatic use" — short snippet showing how to
    sign + verify via @kez/core + @kez/channels for embedding in a
    Node app. Cross-checked against the real exports
    (newClaimPayload(subject, primary, date), signClaim(payload,
    signer), await defaultRegistry(), registry.verify(...)).
  • Cross-implementation interop callout: sign in Node, verify in
    Rust (wire-compatible by design).
  • Common-confusions FAQ gets one extra entry — "Is the Node version
    slower than Rust?" (answer: I/O-bound on channels, both fine for
    interactive use; Rust faster only for batch sigchain work).
  • Troubleshooting adds "WebSocket is not defined → upgrade Node" for
    the nostr channel.

README now points to TUTORIAL.md as the on-ramp, matching the Rust
README's structure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 00:19:18 -06:00
Jason Tudisco
d10dfb93f2 docs(rust): add TUTORIAL.md — friendly step-by-step for first-time users
The existing README is a solid reference but assumes you already know
what KEZ is and what each subcommand does. Add a parallel TUTORIAL.md
that takes a complete newcomer from "I have a nostr nsec" to "I have
a published, verified sigchain" in ~15 minutes.

Sections (~500 lines):
  0. Install (incl. cargo-run alternative + GITHUB_TOKEN tip)
  1. Pick your primary key — use your existing nsec (recommended) OR
     generate a fresh ed25519. Concrete warnings about nsec handling.
  2. Sign your first claim — full markdown/compact/json walkthrough
     with a real github:tudisco example.
  3. Publish the proof — separate concrete how-tos per channel:
     github (gist + profile README), DNS (zone-file output), nostr
     (3 places it can live), bluesky, ActivityPub, your own website.
  4. Verify it — `kez verify id` + a full "if verification fails"
     troubleshooting block (not_found, subject_mismatch, bad sig,
     github rate limit).
  5. Sigchain basics — when you actually need one, add/show/revoke,
     where chain files live on disk.
  6. Publish your sigchain — server, web (.well-known), DNS,
     nostr (kind-30078), and how to combine destinations.
  7. Verify someone else — the reverse direction (verify id, walk
     a chain by --primary, verify a chain bundle from disk).
  8. Quick-reference command card.
  9. Common confusions FAQ — sigchain optional? two key types?
     nsec leakage? proof copying? key rotation?
  10. Where to go next — kez.lat, SPEC.md, sig-server, channel plugin
      trait.

All commands cross-checked against crates/kez-cli/src/main.rs (every
flag and output format quoted in the tutorial actually exists in the
binary).

README now points to TUTORIAL.md as the on-ramp; the existing reference
content stays put.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:54:10 -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
Tudisco
fdd281f0e2 deploy(nats): comment out auth_callout for v0.1
The auth_callout block required a real account nkey for the issuer
field and we don't have one yet — chat-server's callout endpoint is
a 501 stub for v0.1 anyway. NATS was crash-looping on startup
rejecting the placeholder nkey:

  Expected callout user to be a valid public account nkey,
  got "ABACVOI4POPS3SBFLDQYTQHHHACRVMCM2HK7PXX4UTI7XYWQHQGOA3PX"

Commented the block out with clear notes on how to re-enable in
v0.2 once we run `nsc generate nkey` for real issuer + user keys.

In v0.1 NATS runs with no auth, which is fine because:
  - the deployment is behind a Cloudflare tunnel (not directly
    internet-exposed)
  - no KEZ client exists yet to connect
  - even if one did, the chat-server's callout endpoint is a stub

Deployment verified live at tudisco@10.5.2.5:
  chat-server :6969  → {"server":"kez.lat","status":"ok","version":"0.1.0"}
  sig-server  :7878  → {"status":"ok"}
  nats        :4222  → INFO frame, v2.14.1, JetStream on
              :8222  → /varz monitoring
              :8443  → WebSocket transport for browser SPA
2026-05-25 11:41:44 -06:00
Tudisco
3d85b8e775 deploy(kez-chat): untrack personal deploy.sh; gitignore it
deploy.sh has tudisco@10.5.2.5 + /home/tudisco/kez-chat baked into
its defaults — it's a personal deploy script, not a generic project
artifact. Same goes for any future *.local.sh / .env / .env.local
files in kez-chat/deploy/.

What stays in git:
  - Dockerfile / Dockerfile.sig-server  (project infrastructure)
  - docker-compose.yml                  (project infrastructure)
  - nats.conf                            (project infrastructure)
  - install-docker.sh                    (generic Ubuntu setup, no
                                          host-specific info)

What's now gitignored:
  - deploy.sh                            (personal — kept locally)
  - *.local.sh                           (any other personal scripts)
  - .env / .env.local                    (any local config)
2026-05-24 23:46:11 -06:00
Tudisco
f79979669c deploy(kez-chat): add deploy.sh + install-docker.sh
Two helper scripts in kez-chat/deploy/ so deployment is one command
once SSH access is set up:

- install-docker.sh — run once on a fresh Ubuntu host. Installs
  Docker Engine + Compose plugin from Docker's apt repo, adds the
  current user to the docker group, enables the systemd unit.
  Idempotent (safe to re-run).

- deploy.sh — run from a workstation. Rsyncs the three subdirs we
  need (rust/, kez-chat/, rust-sig-server/) to the target host,
  excludes build artifacts (target/, node_modules/, *.db), then
  SSHes in to run docker compose up -d --build, waits for the
  chat-server healthcheck.

Defaults match what we agreed:
  host  = tudisco@10.5.2.5
  path  = /home/tudisco/kez-chat
  server domain = kez.lat

Overridable via flags or env vars.
2026-05-24 23:38:58 -06:00
Tudisco
111b23b94b feat(kez-chat): scaffold the home server (v0.1)
First runnable kez-chat-server binary plus its docker-compose deploy
recipe. Implements steps 2-3 of the document.md sequenced plan; the
rust-lib refactor (step 1) is deferred — chat-server path-deps on
rust/crates/kez-core for now, which works and matches what
rust-sig-server already does.

What's in this commit:

kez-core (1-line change)
- New public `verify_envelope<T>(payload, signature)` helper that
  dispatches Schnorr / Ed25519 / future suites by signature.alg.
  Used by chat-server's registration verifier; downstream value
  beyond chat-server too.

kez-chat-server (new crate)
- src/main.rs: tokio + axum + tracing entry; clap config; graceful
  Ctrl-C shutdown.
- src/lib.rs: re-exports so tests can drive the same router.
- src/config.rs: env/flag config (bind, db, server, sig_server_url,
  web_dir) with defaults sane for both dev and prod.
- src/error.rs: typed ApiError → structured JSON responses with
  stable error codes.
- src/store.rs: SQLite-backed handle registry, UNIQUE on both
  (handle) and (primary_id); race-safe via SQL primary key.
- src/handles.rs: username validation (length, charset, reserved
  list, must start with letter/digit).
- src/registration.rs: SignedRegistration envelope sharing KEZ's
  JCS canonical-bytes pattern; signature verification via the new
  kez-core helper; replay protection via ±5-minute clock skew check.
- src/api.rs: all six routes in one file —
    GET  /v1/healthz
    GET  /v1/u/:handle
    POST /v1/register
    GET  /.well-known/webfinger
    POST /internal/nats/auth   (501 stub for v0.1; wired up in v0.2)
    GET  /                     (placeholder HTML; ServeDir when web/dist exists)

tests/http.rs — 13 integration tests
- Stands up the real router on a random port; uses reqwest.
- Coverage: healthz, lookup-404, full register→lookup round-trip,
  duplicate-handle conflict, wrong-server rejection, reserved-name
  rejection, tampered-signature rejection, stale-timestamp rejection,
  WebFinger success + wrong-server-404, placeholder SPA renders,
  NATS callout 501, JCS determinism sanity.

deploy/
- Dockerfile: multi-stage build (rust:1.86-slim → debian:bookworm-slim).
  Build context is repo root so the path dep on kez-core resolves.
  Runtime image ~50 MB; runs as non-root uid 10001.
- Dockerfile.sig-server: same pattern for the existing
  rust-sig-server, so the stack builds from one git pull.
- docker-compose.yml: three services (chat-server + nats + sig-server)
  with named volumes for persistence. Ports: 6969 (chat HTTP),
  4222/8443/8222 (NATS native/ws/monitoring), 7878 (sig-server).
- nats.conf: WebSocket on 8443 for the browser SPA, JetStream
  enabled, auth_callout pointing at chat-server's
  /internal/nats/auth endpoint (issuer nkey is a placeholder — must
  be replaced with a real one before going live).

README.md
- Documents all endpoints with example bodies.
- Quick-start for both local dev and full Docker compose.
- Honest list of what's in v0.1 vs what's still stubbed.

Smoke-tested running on 127.0.0.1:6969:
  GET /v1/healthz       → {"server":"kez.lat","status":"ok","version":"0.1.0"}
  GET /                 → placeholder HTML rendering
  GET /v1/u/ghost       → 404
  POST /internal/nats/auth → 501 with "wired up in v0.2"

cargo test  → 13 passed.
cargo build --release → 19.6s, clean.
2026-05-24 23:36:53 -06:00
Tudisco
a1d1aa6983 plan(kez-chat): add web app design — Svelte SPA served by chat-server
The test UI is a Svelte 5 + TypeScript + Vite + Tailwind single-page
app served as static files by kez-chat-server. The web app uses the
exact same HTTP API a native client would use, so every action in the
UI dogfoods the API contract.

Architecture changes:

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

New / updated sections in document.md:

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

Three forms now spelled out in §3.1:

  Storage / wire    tudisco@kez.lat        (always fully qualified)
  Display (UI)      tudisco                (when default server; full when cross-server)
  Mention (chat)    @tudisco               (in-message convention; UI resolves)

Specifically updated:
- §1 opener mentions the email-style form + note about mention syntax
- §3.1 fully qualified form, no leading @, with the three-forms table
- §5.1 account creation heading and step 12 now use tudisco@kez.lat
- §5.2 local cache key is "chris@kez.lat" not "@chris@kez.lat"
- §12 summary updated

ActivityPub identifiers in SPEC.md (ap:@jason@mastodon.social) are
unchanged — that's the ActivityPub convention for a different
addressing system.

In-text narrative mentions like "@tudisco shares a file with @chris"
and CLI examples like `kez-chat add @chris` are intentionally
preserved — those use the mention syntax, which the CLI resolves
to the full handle.
2026-05-24 22:55:08 -06:00
Tudisco
6dfd5a6938 spec: v0.3 — restructure, add glossary, worked example, primitives,
versioning policy, changelog

Full sweep across the three buckets discussed:

Bucket A (quick wins — staleness/bugs):
- Fix §3 (was §2): drop wrong mastodon row with double-@; ActivityPub
  channel formalized as `ap:` with mastodon as alias; consolidated
  with current channel set.
- §7 channels table now matches the actually-shipped channel adapters
  in rust-channels and node-channels.
- Drop §12 Test Vectors (the directory never existed). Replaced with
  one paragraph in §15 pointing at the crosstest.sh harness, which
  is what we actually use for inter-implementation conformance.
- Replace §10 historical "MVP Scope (v0.2)" with §14 Changelog.
- §15 Implementation Layout now points at actual repos (rust/,
  nodejs/, rust-sig-server/) rather than the never-existed kez-web.

Bucket B (simplifications):
- Folded §9 Starting Points into §10.1 (one paragraph).
- Consolidated §1 Core Concepts and §13 One-Sentence Summary into
  the new opener (§1 Summary + §2 Glossary).
- §3.1 Canonicalization inlined into §4.2 (where it actually applies).
- §8 Verification trimmed from 9 conflated steps to 5 clean phases.
- §8.5 "MUST" softened to "expected" for libraries; complete verifiers
  do network, helpers don't.

Bucket C (real improvements + restructure):
- §2 Glossary added (primary key, claim, subject, proof, channel,
  sigchain, signature envelope, identity graph — all in one place).
- §11 Cryptographic Primitives table — every algo we use, its role.
- §12 Worked Example with REAL reproducible bytes: fixed Ed25519
  seed (4242... — clearly labeled TEST ONLY), specific subject and
  timestamp, the exact JCS bytes, the exact deterministic Ed25519
  signature, the exact compact form. Generated against the reference
  Rust implementation; any conforming implementation should produce
  identical bytes.
- §13 Versioning & Wire Compatibility policy — what bumps major,
  what bumps minor, how implementations handle unknown ops.
- §14 Changelog — v0.1 / v0.2 / v0.3 with notable changes.
- §8.4 Sigchain in pictures — ASCII diagram showing 5 events with
  hash chaining and rotation.

Structural reorganization:
- §1 summary → §2 glossary → §3 identifiers → §4 signature envelope
  → §5 payload shapes → §6 wire encodings → §7 channels → §8 sigchain
  → §9 storage → §10 verification → §11 crypto → §12 worked example
  → §13 versioning → §14 changelog → §15 implementation layout.
- The envelope (the unit of transport) is now described before the
  payloads it wraps, matching what's actually on the wire.

Also: added §6.5 documenting `kez:zc1:` (compact sigchain bundle)
that exists in the implementations but was missing from the spec.
2026-05-24 22:51:51 -06:00
Tudisco
7b8b136e92 plan(kez-chat): NATS is bundled in docker-compose, not in Rust code
Correcting an overcorrection. Previous version pushed NATS fully
external — "operator brings their own, we don't ship it." That went
too far. The right line is:

- NATS isn't *Rust code we wrote* — it's the official Go nats-server,
  separate process. We don't embed it. ✓ (unchanged)
- NATS *is* part of our deployment recipe — docker-compose includes a
  `nats` service alongside chat-server and sig-server so operators
  can `docker compose up` and have everything working.

This is the standard "we ship docker-compose with the dependencies
wired up" pattern (like projects that include Postgres in their
compose). Operators with existing NATS deployments can disable the
bundled service and set NATS_URL to their own broker.

Changes:

- §4.2 process diagram: NATS back inside the "our deployment" box,
  with a note that it's bundled-but-separable
- §4.3 docker-compose: nats service restored alongside chat-server
  and sig-server. Reference nats.conf path documented. Instructions
  for swapping in your own NATS broker.
- §6.4 NATS section retitled from "external dependency" to
  "bundled in compose, not in code." Same requirements (NATS 2.10+,
  JetStream, auth_callout) but framed as turn-key by default.
- Decisions-locked NATS row updated: "not in Rust code, yes in
  docker-compose; swap-able by config."
- §11 sequenced plan step 3: wire up the bundled nats service rather
  than "spin up a separate broker for dev."
- §12 summary: "we ship two Rust services PLUS a docker-compose
  recipe that includes nats-server."
- Appendix A trimmed: now just "running NATS standalone if you're
  iterating on chat-server in cargo watch and don't want the full
  compose stack." The full compose IS the standard dev setup.
2026-05-24 22:45:29 -06:00
Tudisco
f0aa86f71a plan(kez-chat): NATS is external infrastructure, not part of our stack
Sharpen the framing: our project doesn't ship, embed, supervise, or
even sit-next-to NATS. NATS is external infrastructure the operator
provides (their own server, Synadia Cloud, whatever) and we connect
to it the way an app connects to a database.

Changes:

- §4.2 process model: redraw the diagram showing NATS *outside* our
  deployment boundary (with a dashed line for "external"), our two
  services on one side, chat-server reaches out to the operator's
  NATS via the auth callout.

- §4.3 docker-compose sketch: remove the nats container entirely.
  Our compose ships chat-server + sig-server only. NATS_URL is an
  environment variable the operator sets. We document the nats.conf
  snippet the operator needs to add to their own NATS deployment.

- §6.4 NATS broker section rewritten as "external dependency" — what
  we require from the operator's NATS (version, JetStream, callout
  config), and why we don't bundle it (NATS is its own ops problem;
  operators may already have one; we shouldn't lock them in).

- §11 sequenced plan step 3: developers spin up a local NATS for
  testing via Appendix A, not "run nats-server in a sibling container."

- Decisions-locked row for NATS now explicit: "We don't ship, embed,
  or supervise it. We connect to whatever broker NATS_URL points at."

- New Appendix A: "running a NATS broker locally for development" —
  one-liner docker run for testing, with explicit "this is dev only,
  not the production deployment recipe."

- §12 one-paragraph summary updated to reflect "our project ships two
  services" (chat-server + sig-server), NATS is external.
2026-05-24 22:40:15 -06:00