Kez/kez-chat/web/src/routes/Landing.svelte
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

90 lines
2.9 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import {
hasStoredIdentity,
loadStoredIdentityMeta,
} from "../lib/identity-store.js";
let existing = $state<{
handle: string;
server: string;
primary: string;
created_at: string;
} | null>(null);
let loading = $state(true);
onMount(async () => {
if (await hasStoredIdentity()) {
existing = await loadStoredIdentityMeta();
}
loading = false;
});
</script>
<div class="space-y-8">
<section>
<h1 class="text-3xl font-bold text-gray-900">Welcome to kez-chat</h1>
<p class="mt-2 text-gray-600">
A decentralized identity + chat system. Create an account, link your
online identities, prove who you are without trusting a central server.
</p>
</section>
{#if loading}
<p class="text-gray-500 text-sm">Checking local state…</p>
{:else if existing}
<section class="border border-gray-200 rounded-lg p-6 bg-white">
<p class="text-sm text-gray-500 mb-1">Existing account on this device:</p>
<p class="text-xl font-mono font-semibold text-gray-900">
{existing.handle}@{existing.server}
</p>
<p class="mt-1 text-xs font-mono text-gray-500 break-all">
{existing.primary}
</p>
<div class="mt-4 flex gap-3">
<button
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700"
onclick={() => push("/unlock")}
>
Unlock
</button>
</div>
</section>
{:else}
<section class="grid sm:grid-cols-2 gap-4">
<button
class="text-left border border-gray-200 rounded-lg p-6 bg-white hover:border-gray-400 transition"
onclick={() => push("/create")}
>
<h2 class="text-lg font-semibold text-gray-900">Create a new account</h2>
<p class="mt-1 text-sm text-gray-600">
Generate a fresh key pair, pick a handle, back up your seed phrase.
</p>
</button>
<button
class="text-left border border-gray-200 rounded-lg p-6 bg-white hover:border-gray-400 transition"
onclick={() => push("/restore")}
>
<h2 class="text-lg font-semibold text-gray-900">Restore from seed</h2>
<p class="mt-1 text-sm text-gray-600">
Have a 64-char hex seed from another device? Paste it to recover
your identity.
</p>
</button>
</section>
{/if}
<section class="text-sm text-gray-500 border-t border-gray-200 pt-6">
<p class="font-medium text-gray-700">What is this?</p>
<p class="mt-1">
Your identity is an Ed25519 keypair — not a username + password.
Account creation makes a handle (<code class="bg-gray-100 px-1 rounded">tudisco@kez.lat</code>),
stores your seed locally under a passphrase, and registers your public
key with this server. There's no email, no recovery flow — keep the
seed safe.
</p>
</section>
</div>