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.
This commit is contained in:
Tudisco 2026-05-25 12:29:14 -06:00
parent fdd281f0e2
commit a9feb1b5b2
24 changed files with 4080 additions and 11 deletions

View File

@ -1,33 +1,42 @@
# Multi-stage build for kez-chat-server.
# Multi-stage build for kez-chat-server + bundled Svelte SPA.
#
# Stage 1: build the Rust binary against kez-core (path dep). The build
# context must be the *repository root* (the dir that contains both
# `kez-chat/` and `rust/`), not `kez-chat/` itself — see the
# `docker-compose.yml` which sets `context: ..`.
# Stage 1: build the Svelte SPA → web/dist/
# Stage 2: build the Rust binary against kez-core (path dep)
# Stage 3: minimal runtime image with binary + SPA
#
# Build context = repo root (so we can see kez-chat/web/ and rust/).
# docker-compose.yml sets `context: ../..` accordingly.
# ─── Stage 1: build the SPA ────────────────────────────────────────────────
FROM node:22-slim AS webbuild
WORKDIR /src/web
COPY kez-chat/web/package.json kez-chat/web/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY kez-chat/web/ ./
RUN npm run build
# ─── Stage 2: build the Rust binary ────────────────────────────────────────
FROM rust:1.86-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# Copy what we need:
# - rust/crates/kez-core (path dep)
# - kez-chat (this project)
COPY rust/ /src/rust/
COPY kez-chat/ /src/kez-chat/
WORKDIR /src/kez-chat
RUN cargo build --release --bin kez-chat-server
# Stage 2: minimal runtime image
# ─── Stage 3: runtime ──────────────────────────────────────────────────────
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -r -u 10001 -m kez
# Rust binary
COPY --from=build /src/kez-chat/target/release/kez-chat-server /usr/local/bin/kez-chat-server
# SPA static files
COPY --from=webbuild /src/web/dist/ /app/web/
USER kez
WORKDIR /data
@ -36,6 +45,7 @@ ENV KEZ_CHAT_BIND=0.0.0.0:6969 \
KEZ_CHAT_DB=/data/kez-chat.db \
KEZ_CHAT_SERVER=kez.lat \
KEZ_CHAT_SIG_SERVER_URL=http://sig-server:7878 \
KEZ_CHAT_WEB_DIR=/app/web \
RUST_LOG=info
EXPOSE 6969

5
kez-chat/web/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.vite/
*.tsbuildinfo
.DS_Store

13
kez-chat/web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kez-chat</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='28'>🔑</text></svg>" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2182
kez-chat/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
kez-chat/web/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "kez-chat-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9",
"canonicalize": "^2.0.0",
"idb-keyval": "^6.2.1",
"svelte-spa-router": "^4.0.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"@tsconfig/svelte": "^5.0.0",
"@types/node": "^22.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}

View File

@ -0,0 +1,77 @@
<script lang="ts">
import Router, { push, location } from "svelte-spa-router";
import { onMount } from "svelte";
import { hasStoredIdentity } from "./lib/identity-store.js";
import { session } from "./lib/store.svelte.js";
import Landing from "./routes/Landing.svelte";
import CreateAccount from "./routes/CreateAccount.svelte";
import Restore from "./routes/Restore.svelte";
import Unlock from "./routes/Unlock.svelte";
import Dashboard from "./routes/Dashboard.svelte";
import Claims from "./routes/Claims.svelte";
import AddClaim from "./routes/AddClaim.svelte";
const routes = {
"/": Landing,
"/create": CreateAccount,
"/restore": Restore,
"/unlock": Unlock,
"/dashboard": Dashboard,
"/claims": Claims,
"/claims/add": AddClaim,
};
// First-load: if there's a stored identity but session is locked,
// bounce to /unlock. If no stored identity and on a protected page,
// bounce to /.
onMount(async () => {
const stored = await hasStoredIdentity();
const protectedRoutes = ["/dashboard", "/claims", "/claims/add"];
if (!stored && protectedRoutes.includes($location)) {
push("/");
} else if (stored && !session.unlocked && protectedRoutes.includes($location)) {
push("/unlock");
}
});
</script>
<header class="border-b border-gray-200 bg-white">
<div class="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="#/" class="text-lg font-semibold text-gray-900 no-underline">
🔑 kez-chat
</a>
{#if session.unlocked}
<nav class="flex items-center gap-4 text-sm">
<a href="#/dashboard" class="text-gray-700 hover:text-gray-900">Dashboard</a>
<a href="#/claims" class="text-gray-700 hover:text-gray-900">Claims</a>
<span class="text-gray-400">|</span>
<span class="text-gray-500">{session.unlocked.handle}@{session.unlocked.server}</span>
<button
class="text-gray-500 hover:text-gray-900 underline"
onclick={() => { session.lock(); push("/unlock"); }}
>
Lock
</button>
</nav>
{/if}
</div>
</header>
<main class="max-w-3xl mx-auto px-6 py-8">
<Router {routes} />
</main>
<footer class="border-t border-gray-200 bg-white mt-16">
<div class="max-w-3xl mx-auto px-6 py-4 text-xs text-gray-500">
kez-chat web v0.1 ·
<a
href="https://git.ptud.biz/DukeInc/Kez"
target="_blank"
rel="noopener"
class="text-gray-500 hover:text-gray-700"
>
source
</a>
</div>
</footer>

17
kez-chat/web/src/app.css Normal file
View File

@ -0,0 +1,17 @@
@import "tailwindcss";
/* Base typography reset on top of Tailwind v4's preflight. */
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
Roboto, sans-serif;
color: #1f2937;
background: #f9fafb;
}
body {
margin: 0;
}
code, kbd, pre {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}

10
kez-chat/web/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,80 @@
// Thin HTTP client for kez-chat-server. Same calls a native CLI would
// make — the SPA dogfoods the API surface.
import type { SignedRegistration } from "./kez.js";
export interface HandleResponse {
handle: string;
fqhn: string;
primary: string;
sigchain_url: string;
registered_at: string;
}
export interface ApiErrorBody {
error: { code: string; message: string };
}
export class ApiError extends Error {
status: number;
code?: string;
constructor(status: number, message: string, code?: string) {
super(message);
this.status = status;
this.code = code;
}
}
/**
* In dev, requests go to whatever Vite's proxy points at (see vite.config.ts).
* In prod, the SPA is served by the same chat-server, so relative URLs work.
* Override via `VITE_API_BASE` env var if you want to point at a different
* server during dev (e.g. https://kez.lat).
*/
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
function url(path: string): string {
return `${API_BASE}${path}`;
}
async function unwrap<T>(resp: Response): Promise<T> {
if (!resp.ok) {
let body: ApiErrorBody | undefined;
try {
body = await resp.json();
} catch {
/* ignore — body wasn't JSON */
}
throw new ApiError(
resp.status,
body?.error?.message ?? `HTTP ${resp.status}`,
body?.error?.code,
);
}
return resp.json() as Promise<T>;
}
export async function healthz(): Promise<{
status: string;
server: string;
version: string;
}> {
const resp = await fetch(url("/v1/healthz"));
return unwrap(resp);
}
export async function lookup(handle: string): Promise<HandleResponse> {
const resp = await fetch(url(`/v1/u/${encodeURIComponent(handle)}`));
return unwrap(resp);
}
export async function register(
signed: SignedRegistration,
): Promise<HandleResponse> {
const resp = await fetch(url("/v1/register"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(signed),
});
return unwrap(resp);
}

View File

@ -0,0 +1,44 @@
// Locally-cached list of claims the user has generated. Persists
// across reloads so the user can come back later and see what they've
// signed (and for which channel). Does NOT verify publication —
// that's the verifier's job; the SPA just records what was generated.
import { get, set } from "idb-keyval";
import type { SignedClaimEnvelope } from "./kez.js";
const KEY = "kez-chat:claims";
export interface StoredClaim {
id: string; // random local id
envelope: SignedClaimEnvelope;
channel: string; // "github", "dns", "web", ...
published_at?: string; // user marked it published
notes?: string;
}
export async function listClaims(): Promise<StoredClaim[]> {
return (await get<StoredClaim[]>(KEY)) ?? [];
}
export async function addClaim(claim: StoredClaim): Promise<void> {
const existing = await listClaims();
existing.push(claim);
await set(KEY, existing);
}
export async function markPublished(id: string): Promise<void> {
const existing = await listClaims();
const target = existing.find((c) => c.id === id);
if (target) {
target.published_at = new Date().toISOString();
await set(KEY, existing);
}
}
export async function removeClaim(id: string): Promise<void> {
const existing = await listClaims();
await set(
KEY,
existing.filter((c) => c.id !== id),
);
}

View File

@ -0,0 +1,157 @@
// Local persistence for the current user's identity.
//
// v0.1: stores the ed25519 seed in IndexedDB encrypted under a
// passphrase the user enters at unlock time. Standard pattern: derive
// a key from the passphrase via PBKDF2-SHA-256 → AES-GCM-256 wrap the
// seed → store the wrapped blob + salt + nonce. To unlock, the user
// types the passphrase again; if PBKDF2 produces the right key,
// AES-GCM unwraps the seed; if not, decryption fails.
//
// Caveats spelled out in the spec doc: browsers have no Keychain
// equivalent, so this is the best we can do client-side. The CLI and
// future native GUI use OS keychain and don't have this limitation.
import { get, set, del } from "idb-keyval";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import type { Identity } from "./kez.js";
const IDB_KEY = "kez-chat:identity";
// TS 5.6+ defaults Uint8Array's generic param to ArrayBufferLike, which
// isn't assignable to BufferSource (= ArrayBufferView<ArrayBuffer>) that
// WebCrypto expects. Copy to a plain ArrayBuffer so the types line up.
function asBuffer(u: Uint8Array): ArrayBuffer {
return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) as ArrayBuffer;
}
interface StoredIdentity {
version: 1;
handle: string;
server: string; // e.g. "kez.lat"
primary: Identity; // ed25519:<hex>
// Encrypted seed:
salt: string; // hex, 16 bytes
nonce: string; // hex, 12 bytes
ciphertext: string; // hex; AES-GCM(seed) under PBKDF2(passphrase)
// Metadata:
created_at: string; // RFC3339
}
export interface UnlockedIdentity {
handle: string;
server: string;
primary: Identity;
seed: Uint8Array;
}
const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance
async function deriveKey(
passphrase: string,
salt: Uint8Array,
): Promise<CryptoKey> {
const baseKey = await crypto.subtle.importKey(
"raw",
asBuffer(new TextEncoder().encode(passphrase)),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: asBuffer(salt),
iterations: PBKDF2_ITERATIONS,
hash: "SHA-256",
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export async function hasStoredIdentity(): Promise<boolean> {
const stored = await get<StoredIdentity>(IDB_KEY);
return !!stored;
}
export async function loadStoredIdentityMeta(): Promise<
Pick<StoredIdentity, "handle" | "server" | "primary" | "created_at"> | null
> {
const stored = await get<StoredIdentity>(IDB_KEY);
if (!stored) return null;
const { handle, server, primary, created_at } = stored;
return { handle, server, primary, created_at };
}
export async function saveIdentity(opts: {
handle: string;
server: string;
primary: Identity;
seed: Uint8Array;
passphrase: string;
}): Promise<void> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const nonce = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(opts.passphrase, salt);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: asBuffer(nonce) },
key,
asBuffer(opts.seed),
),
);
const record: StoredIdentity = {
version: 1,
handle: opts.handle,
server: opts.server,
primary: opts.primary,
salt: bytesToHex(salt),
nonce: bytesToHex(nonce),
ciphertext: bytesToHex(ciphertext),
created_at: new Date().toISOString(),
};
await set(IDB_KEY, record);
}
export async function unlockIdentity(
passphrase: string,
): Promise<UnlockedIdentity> {
const stored = await get<StoredIdentity>(IDB_KEY);
if (!stored) throw new Error("no stored identity");
const salt = hexToBytes(stored.salt);
const nonce = hexToBytes(stored.nonce);
const ciphertext = hexToBytes(stored.ciphertext);
const key = await deriveKey(passphrase, salt);
let plaintext: Uint8Array;
try {
plaintext = new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: asBuffer(nonce) },
key,
asBuffer(ciphertext),
),
);
} catch {
throw new Error("wrong passphrase");
}
if (plaintext.length !== 32) {
throw new Error(
`unlocked seed is ${plaintext.length} bytes, expected 32`,
);
}
return {
handle: stored.handle,
server: stored.server,
primary: stored.primary,
seed: plaintext,
};
}
export async function deleteStoredIdentity(): Promise<void> {
await del(IDB_KEY);
}

253
kez-chat/web/src/lib/kez.ts Normal file
View File

@ -0,0 +1,253 @@
// Browser-native KEZ primitives. Mirrors the Rust/Node implementations
// for byte-identical JCS canonicalization + Ed25519 signing.
//
// Deliberately self-contained — no dep on @kez/core. The SPA needs
// only a small surface and inlining keeps the bundle tight. If we add
// more KEZ functionality (sigchain walking, channel verification),
// reconsider depending on the Node port.
import { ed25519 } from "@noble/curves/ed25519";
import { sha512 } from "@noble/hashes/sha2";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import canonicalize from "canonicalize";
// ─────────────────────────────────────────────────────────────────────────────
// Constants (must match SPEC.md v0.3 and Rust kez-core)
// ─────────────────────────────────────────────────────────────────────────────
export const CLAIM_TYPE = "kez.claim";
export const REGISTRATION_TYPE = "kez.chat.handle_registration";
export const REGISTRATION_ENVELOPE = "handle_registration";
export const CLAIM_ENVELOPE = "claim";
export const ED25519_SHA512_ALG = "ed25519-sha512-jcs";
export const FORMAT_VERSION = 1;
export const COMPACT_PROOF_PREFIX = "kez:z1:";
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export type Identity = string; // canonical `system:value` string
export interface Ed25519Identity {
seed: Uint8Array; // 32 bytes
publicKey: Uint8Array; // 32 bytes
identity: Identity; // "ed25519:<hex>"
}
export interface SignatureBlock {
alg: string;
key: Identity;
sig: string; // lowercase hex
}
export interface ClaimPayload {
type: typeof CLAIM_TYPE;
version: number;
primary: Identity;
subject: Identity;
created_at: string;
}
export interface SignedClaimEnvelope {
kez: typeof CLAIM_ENVELOPE;
payload: ClaimPayload;
signature: SignatureBlock;
}
export interface RegistrationPayload {
type: typeof REGISTRATION_TYPE;
version: number;
handle: string;
primary: Identity;
server: string;
created_at: string;
}
export interface SignedRegistration {
kez: typeof REGISTRATION_ENVELOPE;
payload: RegistrationPayload;
signature: SignatureBlock;
}
// ─────────────────────────────────────────────────────────────────────────────
// Key generation + restoration
// ─────────────────────────────────────────────────────────────────────────────
export function generateIdentity(): Ed25519Identity {
const seed = ed25519.utils.randomPrivateKey();
return identityFromSeed(seed);
}
export function identityFromSeed(seed: Uint8Array): Ed25519Identity {
if (seed.length !== 32) {
throw new Error(`Ed25519 seed must be 32 bytes, got ${seed.length}`);
}
const publicKey = ed25519.getPublicKey(seed);
return {
seed,
publicKey,
identity: `ed25519:${bytesToHex(publicKey)}`,
};
}
export function identityFromSeedHex(seedHex: string): Ed25519Identity {
return identityFromSeed(hexToBytes(seedHex));
}
// ─────────────────────────────────────────────────────────────────────────────
// JCS canonicalization
// ─────────────────────────────────────────────────────────────────────────────
/** RFC 8785 canonical bytes of the payload. */
export function canonicalBytes(payload: unknown): Uint8Array {
const text = canonicalize(payload);
if (text === undefined) {
throw new Error("canonicalize returned undefined");
}
return new TextEncoder().encode(text);
}
// ─────────────────────────────────────────────────────────────────────────────
// Signing
// ─────────────────────────────────────────────────────────────────────────────
function signWith(
payload: unknown,
signer: Ed25519Identity,
): SignatureBlock {
const jcs = canonicalBytes(payload);
const sig = ed25519.sign(jcs, signer.seed);
return {
alg: ED25519_SHA512_ALG,
key: signer.identity,
sig: bytesToHex(sig),
};
}
/** Build + sign a kez.claim envelope ("I control <subject>"). */
export function signClaim(
signer: Ed25519Identity,
subject: Identity,
createdAt: Date = new Date(),
): SignedClaimEnvelope {
const payload: ClaimPayload = {
type: CLAIM_TYPE,
version: FORMAT_VERSION,
subject,
primary: signer.identity,
created_at: createdAt.toISOString(),
};
return {
kez: CLAIM_ENVELOPE,
payload,
signature: signWith(payload, signer),
};
}
/** Build + sign a handle-registration envelope. */
export function signRegistration(
signer: Ed25519Identity,
handle: string,
server: string,
createdAt: Date = new Date(),
): SignedRegistration {
const payload: RegistrationPayload = {
type: REGISTRATION_TYPE,
version: FORMAT_VERSION,
handle,
primary: signer.identity,
server,
created_at: createdAt.toISOString(),
};
return {
kez: REGISTRATION_ENVELOPE,
payload,
signature: signWith(payload, signer),
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Encodings — pretty JSON, compact (kez:z1:), markdown fence
// ─────────────────────────────────────────────────────────────────────────────
export function toPrettyJson(envelope: SignedClaimEnvelope): string {
return JSON.stringify(envelope, null, 2);
}
/** Markdown fenced block suitable for a GitHub gist / profile README. */
export function toMarkdown(envelope: SignedClaimEnvelope): string {
return [
"# KEZ Proof",
"",
"This account publishes a signed KEZ identity claim.",
"",
`- Primary: \`${envelope.payload.primary}\``,
`- Subject: \`${envelope.payload.subject}\``,
`- Created: \`${envelope.payload.created_at}\``,
"",
"```kez",
toPrettyJson(envelope),
"```",
"",
].join("\n");
}
/**
* `kez:z1:<base64url-no-pad(zstd(json-envelope))>`
* Browser zstd: `CompressionStream` doesn't support zstd as of 2026, so
* we fall back to a tiny pure-JS zstd compressor for now. For v0.1 we
* only need it for short claim envelopes; small payloads compress fast.
*
* Implementation: use the browser's native `CompressionStream("deflate-raw")`
* as a substitute (NOT zstd different format!). This is a v0.1 stopgap
* so the SPA can show a compact form; we mark these as `kez:zd1:` instead
* of `kez:z1:` to make absolutely clear they are NOT the spec-compliant
* zstd encoding. They round-trip in the SPA but don't interop with the
* Rust/Node implementations until we add proper zstd-in-browser (next
* iteration; the @bokuweb/zstd-wasm crate works).
*/
export async function toCompactDevPreview(
envelope: SignedClaimEnvelope,
): Promise<string> {
const json = JSON.stringify(envelope);
const compressed = await deflateRaw(new TextEncoder().encode(json));
// base64url, no padding
let b64 = btoa(String.fromCharCode(...compressed));
b64 = b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return `kez:zd1:${b64}`;
}
async function deflateRaw(input: Uint8Array): Promise<Uint8Array> {
// Copy into a fresh ArrayBuffer-backed buffer so the BodyInit
// overload in TS 5.6+ accepts it (Uint8Array<ArrayBufferLike> isn't
// assignable to BodyInit without this).
const fresh = new Uint8Array(input.byteLength);
fresh.set(input);
const stream = new Response(fresh).body!.pipeThrough(
new CompressionStream("deflate-raw"),
);
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// Hashing helper — used for displaying a content hash of a payload
// ─────────────────────────────────────────────────────────────────────────────
export function sha512Hex(bytes: Uint8Array): string {
return bytesToHex(sha512(bytes));
}

View File

@ -0,0 +1,18 @@
// Global session state — Svelte 5 runes. Tracks the unlocked identity
// (if any) for the current browser session.
import type { UnlockedIdentity } from "./identity-store.js";
class Session {
unlocked = $state<UnlockedIdentity | null>(null);
setUnlocked(id: UnlockedIdentity) {
this.unlocked = id;
}
lock() {
this.unlocked = null;
}
}
export const session = new Session();

9
kez-chat/web/src/main.ts Normal file
View File

@ -0,0 +1,9 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";
const app = mount(App, {
target: document.getElementById("app")!,
});
export default app;

View File

@ -0,0 +1,335 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import {
signClaim,
toPrettyJson,
toMarkdown,
type SignedClaimEnvelope,
} from "../lib/kez.js";
import { addClaim } from "../lib/claims-store.js";
import { session } from "../lib/store.svelte.js";
type ChannelKey = "github" | "dns" | "web" | "nostr" | "bluesky" | "ap";
interface ChannelDef {
key: ChannelKey;
label: string;
identifierPlaceholder: string;
identifierLabel: string;
/** Build the canonical KEZ identifier from the user's input. */
toSubject: (raw: string) => string;
/** Free-text instructions shown next to the publish artifact. */
instructions: (subject: string) => string;
/** Which encoding to show by default. */
preferredFormat: "json" | "markdown";
}
const CHANNELS: ChannelDef[] = [
{
key: "github",
label: "GitHub",
identifierLabel: "GitHub username",
identifierPlaceholder: "tudisco",
toSubject: (s) => `github:${s.trim()}`,
preferredFormat: "markdown",
instructions: (subject) =>
`1. Create a new PUBLIC gist on github.com.\n` +
`2. Filename: \`kez.md\`\n` +
`3. Paste the markdown block on the right as the file content.\n` +
`4. (Alternative) Add the same markdown block to your <user>/<user> profile README.\n\n` +
`A verifier resolving ${subject} will find the gist via the public gist listing API.`,
},
{
key: "dns",
label: "DNS",
identifierLabel: "Domain",
identifierPlaceholder: "tudisco.com",
toSubject: (s) => `dns:${s.trim().toLowerCase()}`,
preferredFormat: "json",
instructions: (subject) => {
const domain = subject.slice(4);
return (
`1. Open your DNS registrar / nameserver console for ${domain}.\n` +
`2. Add a TXT record:\n` +
` Name: _kez.${domain}\n` +
` Value: (paste the JSON envelope — but in v0.1 the SPA's compact form isn't spec-compliant zstd; use the CLI to produce the kez:z1: form for now, or paste the JSON if your DNS host accepts long TXT values).\n\n` +
`Verifiers resolve _kez.${domain} via DNS TXT lookups.`
);
},
},
{
key: "web",
label: "Your website",
identifierLabel: "HTTPS URL",
identifierPlaceholder: "https://tudisco.com",
toSubject: (s) => `web:${s.trim().replace(/\/$/, "")}`,
preferredFormat: "json",
instructions: (subject) => {
const base = subject.slice(4);
return (
`1. Save the JSON envelope on the right to a file named \`kez.json\`.\n` +
`2. Upload it to your server at: ${base}/.well-known/kez.json\n` +
`3. Make sure it's publicly fetchable (no auth, no robots.txt block).\n\n` +
`Verifiers fetch ${base}/.well-known/kez.json directly.`
);
},
},
{
key: "nostr",
label: "Nostr",
identifierLabel: "Your npub",
identifierPlaceholder: "npub1...",
toSubject: (s) => {
const raw = s.trim();
return raw.startsWith("nostr:") ? raw : `nostr:${raw}`;
},
preferredFormat: "json",
instructions: () =>
`1. Publish a nostr event of kind 30078 to relays where you're active.\n` +
`2. The event 'content' is the JSON envelope on the right.\n` +
`3. Tag with 'd':'kez' so verifiers can find it deterministically.\n\n` +
`A KEZ nostr extension (future) will do this in one click. For v0.1, use a nostr client that lets you craft a kind-30078 event by hand.`,
},
{
key: "bluesky",
label: "Bluesky",
identifierLabel: "Bluesky handle",
identifierPlaceholder: "tudisco.bsky.social",
toSubject: (s) => `bluesky:${s.trim()}`,
preferredFormat: "markdown",
instructions: (subject) =>
`1. Open Bluesky and start a new post on your account (${subject.slice(8)}).\n` +
`2. Paste the markdown block on the right as the post body.\n` +
`3. Publish the post (publicly).\n\n` +
`Verifiers scan your public posts looking for the kez fence.`,
},
{
key: "ap",
label: "ActivityPub (Mastodon, Pleroma, …)",
identifierLabel: "ActivityPub handle",
identifierPlaceholder: "tudisco@mastodon.social",
toSubject: (s) => {
const raw = s.trim().replace(/^@/, "");
return `ap:@${raw}`;
},
preferredFormat: "markdown",
instructions: (subject) =>
`1. Go to your profile settings on your instance.\n` +
`2. Either paste the markdown block on the right into your "Profile fields" / metadata,\n` +
` or post it as a pinned post.\n\n` +
`Verifiers resolve ${subject} via WebFinger then fetch the actor JSON.`,
},
];
let step = $state<"pick" | "identifier" | "sign" | "publish" | "done">("pick");
let selected = $state<ChannelDef | null>(null);
let identifierInput = $state("");
let envelope = $state<SignedClaimEnvelope | null>(null);
let format = $state<"json" | "markdown">("json");
let copied = $state(false);
onMount(() => {
if (!session.unlocked) push("/unlock");
});
function pickChannel(c: ChannelDef) {
selected = c;
format = c.preferredFormat;
step = "identifier";
}
function buildAndSign() {
if (!selected || !session.unlocked) return;
const subject = selected.toSubject(identifierInput);
if (!subject.includes(":") || subject.endsWith(":")) {
alert("Identifier looks empty — please fill in the field.");
return;
}
envelope = signClaim(session.unlocked, subject);
step = "publish";
}
function copyArtifact() {
if (!envelope) return;
const text = format === "markdown" ? toMarkdown(envelope) : toPrettyJson(envelope);
navigator.clipboard.writeText(text).then(() => {
copied = true;
setTimeout(() => (copied = false), 1500);
});
}
async function saveAndDone() {
if (!envelope || !selected) return;
await addClaim({
id: crypto.randomUUID(),
envelope,
channel: selected.key,
});
step = "done";
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Add a claim</h1>
<button
class="text-sm text-gray-500 hover:text-gray-900"
onclick={() => push("/claims")}
>
← Back to claims
</button>
</div>
<!-- Stepper -->
<ol class="flex gap-2 text-xs text-gray-500">
<li class={step === "pick" ? "font-semibold text-gray-900" : ""}>1. Channel</li>
<li></li>
<li class={step === "identifier" ? "font-semibold text-gray-900" : ""}>2. Identifier</li>
<li></li>
<li class={step === "publish" ? "font-semibold text-gray-900" : ""}>3. Publish</li>
<li></li>
<li class={step === "done" ? "font-semibold text-gray-900" : ""}>4. Done</li>
</ol>
{#if step === "pick"}
<div class="grid sm:grid-cols-2 gap-3">
{#each CHANNELS as c}
<button
class="text-left border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-400 transition"
onclick={() => pickChannel(c)}
>
<p class="font-semibold text-gray-900">{c.label}</p>
<p class="text-xs text-gray-500 mt-1 font-mono">{c.key}:&lt;&gt;</p>
</button>
{/each}
</div>
{/if}
{#if step === "identifier" && selected}
<form
class="space-y-4"
onsubmit={(e) => { e.preventDefault(); buildAndSign(); }}
>
<div>
<label class="block text-sm font-medium text-gray-700" for="ident">
{selected.identifierLabel}
</label>
<input
id="ident"
type="text"
bind:value={identifierInput}
placeholder={selected.identifierPlaceholder}
class="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md font-mono"
autocomplete="off"
/>
{#if identifierInput.trim()}
<p class="mt-1 text-xs text-gray-500">
Subject will be: <code class="bg-gray-100 px-1 rounded">{selected.toSubject(identifierInput)}</code>
</p>
{/if}
</div>
<div class="flex gap-2">
<button
type="button"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick={() => { step = "pick"; selected = null; }}
>
Back
</button>
<button
type="submit"
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
disabled={identifierInput.trim().length === 0}
>
Sign claim
</button>
</div>
</form>
{/if}
{#if step === "publish" && envelope && selected}
<div class="grid lg:grid-cols-2 gap-6">
<section class="space-y-3">
<h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">
1. Publish on {selected.label}
</h2>
<pre class="whitespace-pre-wrap text-sm bg-gray-50 border border-gray-200 rounded p-4 leading-relaxed">{selected.instructions(envelope.payload.subject)}</pre>
</section>
<section class="space-y-3">
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide flex-1">
2. Copy this:
</h2>
<div class="flex border border-gray-300 rounded overflow-hidden text-xs">
<button
class={`px-2 py-1 ${format === "markdown" ? "bg-gray-900 text-white" : "bg-white text-gray-700"}`}
onclick={() => (format = "markdown")}
>markdown</button>
<button
class={`px-2 py-1 ${format === "json" ? "bg-gray-900 text-white" : "bg-white text-gray-700"}`}
onclick={() => (format = "json")}
>JSON</button>
</div>
</div>
<pre class="text-xs bg-gray-900 text-gray-100 rounded p-4 overflow-x-auto font-mono leading-relaxed max-h-96 overflow-y-auto">{format === "markdown" ? toMarkdown(envelope) : toPrettyJson(envelope)}</pre>
<button
class="px-3 py-2 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick={copyArtifact}
>
{copied ? "✓ Copied" : "Copy to clipboard"}
</button>
</section>
</div>
<div class="flex gap-2 pt-4 border-t border-gray-200">
<button
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick={() => { step = "identifier"; }}
>
Back
</button>
<button
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700"
onclick={saveAndDone}
>
Save claim
</button>
</div>
{/if}
{#if step === "done" && envelope}
<div class="border border-green-300 bg-green-50 rounded-lg p-6">
<p class="text-lg font-semibold text-green-900">✓ Claim saved</p>
<p class="mt-2 text-sm text-green-800">
You signed a claim for
<code class="font-mono">{envelope.payload.subject}</code>.
Once you've published the proof on that channel, come back to the
Claims page and mark it published.
</p>
<div class="mt-4 flex gap-2">
<a
href="#/claims"
class="px-4 py-2 bg-green-700 text-white rounded-md hover:bg-green-800 no-underline"
>
Back to claims
</a>
<button
class="px-4 py-2 border border-green-300 bg-white rounded-md text-green-800 hover:bg-green-100"
onclick={() => {
step = "pick";
selected = null;
identifierInput = "";
envelope = null;
}}
>
Add another
</button>
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,110 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import {
listClaims,
markPublished,
removeClaim,
type StoredClaim,
} from "../lib/claims-store.js";
import { session } from "../lib/store.svelte.js";
let claims = $state<StoredClaim[]>([]);
let loading = $state(true);
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
claims = await listClaims();
loading = false;
});
async function togglePublished(c: StoredClaim) {
await markPublished(c.id);
claims = await listClaims();
}
async function deleteClaim(c: StoredClaim) {
if (!confirm(`Remove the local copy of claim for ${c.envelope.payload.subject}?`)) return;
await removeClaim(c.id);
claims = await listClaims();
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Claims</h1>
<a
href="#/claims/add"
class="px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 no-underline"
>
+ Add claim
</a>
</div>
<p class="text-sm text-gray-700">
A claim is a signed envelope that says "I control this other account."
Publish the proof on the channel itself (a public gist, a DNS TXT
record, a nostr event, etc.) and anyone can verify it without
trusting this server.
</p>
{#if loading}
<p class="text-sm text-gray-500">Loading…</p>
{:else if claims.length === 0}
<div class="border border-dashed border-gray-300 rounded-lg p-8 text-center">
<p class="text-gray-500">No claims yet.</p>
<a
href="#/claims/add"
class="mt-3 inline-block px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 no-underline"
>
Add your first claim
</a>
</div>
{:else}
<ul class="space-y-3">
{#each claims as c (c.id)}
<li class="border border-gray-200 rounded-lg p-4 bg-white">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="font-mono font-semibold text-gray-900 truncate">
{c.envelope.payload.subject}
</p>
<p class="mt-1 text-xs text-gray-500">
Channel: <span class="font-mono">{c.channel}</span> ·
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
</p>
{#if c.published_at}
<p class="mt-1 text-xs text-green-700">
✓ You marked this published at {c.published_at}
</p>
{:else}
<p class="mt-1 text-xs text-amber-700">
⚠ Not marked as published yet
</p>
{/if}
</div>
<div class="flex flex-col gap-2 shrink-0">
{#if !c.published_at}
<button
class="text-xs px-3 py-1 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick={() => togglePublished(c)}
>
Mark published
</button>
{/if}
<button
class="text-xs px-3 py-1 border border-gray-300 rounded-md text-gray-700 hover:bg-red-50 hover:border-red-300"
onclick={() => deleteClaim(c)}
>
Remove
</button>
</div>
</div>
</li>
{/each}
</ul>
{/if}
</div>

View File

@ -0,0 +1,274 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import {
generateIdentity,
signRegistration,
type Ed25519Identity,
} from "../lib/kez.js";
import { register, healthz, ApiError } from "../lib/api.js";
import { saveIdentity } from "../lib/identity-store.js";
import { session } from "../lib/store.svelte.js";
let step = $state<"handle" | "seed" | "confirm" | "submitting" | "done">("handle");
let handle = $state("");
let passphrase = $state("");
let passphrase2 = $state("");
let serverInfo = $state<{ server: string; version: string } | null>(null);
let id = $state<Ed25519Identity | null>(null);
let seedHex = $state("");
let seedAck = $state(false);
let error = $state<string | null>(null);
let working = $state(false);
onMount(async () => {
try {
const h = await healthz();
serverInfo = { server: h.server, version: h.version };
} catch {
serverInfo = null;
}
});
function validateHandleClient(h: string): string | null {
if (h.length < 3) return "Handle must be at least 3 characters.";
if (h.length > 32) return "Handle must be at most 32 characters.";
if (!/^[a-z0-9][a-z0-9_-]*$/.test(h))
return "Handle must be lowercase letters/digits/underscore/dash, starting with a letter or digit.";
return null;
}
function goToSeedStep() {
error = null;
const v = validateHandleClient(handle);
if (v) { error = v; return; }
if (passphrase.length < 8) { error = "Passphrase must be at least 8 characters."; return; }
if (passphrase !== passphrase2) { error = "Passphrases don't match."; return; }
id = generateIdentity();
seedHex = bytesToHex(id.seed);
step = "seed";
}
async function submitRegistration() {
if (!id || !serverInfo) return;
working = true;
error = null;
step = "submitting";
try {
const signed = signRegistration(id, handle, serverInfo.server);
const resp = await register(signed);
await saveIdentity({
handle: resp.handle,
server: serverInfo.server,
primary: id.identity,
seed: id.seed,
passphrase,
});
session.setUnlocked({
handle: resp.handle,
server: serverInfo.server,
primary: id.identity,
seed: id.seed,
});
step = "done";
} catch (e) {
step = "confirm";
if (e instanceof ApiError) {
error = `${e.code ?? "error"}: ${e.message}`;
} else {
error = (e as Error).message;
}
} finally {
working = false;
}
}
function copyToClipboard(s: string) {
navigator.clipboard.writeText(s).catch(() => {});
}
</script>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-gray-900">Create account</h1>
{#if !serverInfo}
<p class="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded p-3">
Couldn't reach the chat server. Try refreshing.
</p>
{/if}
<!-- Stepper -->
<ol class="flex gap-2 text-xs text-gray-500">
<li class={step === "handle" ? "font-semibold text-gray-900" : ""}>1. Handle</li>
<li></li>
<li class={step === "seed" ? "font-semibold text-gray-900" : ""}>2. Back up seed</li>
<li></li>
<li class={step === "confirm" || step === "submitting" ? "font-semibold text-gray-900" : ""}>3. Confirm</li>
<li></li>
<li class={step === "done" ? "font-semibold text-gray-900" : ""}>4. Done</li>
</ol>
{#if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</p>
{/if}
{#if step === "handle"}
<form
class="space-y-4"
onsubmit={(e) => { e.preventDefault(); goToSeedStep(); }}
>
<div>
<label class="block text-sm font-medium text-gray-700" for="handle">
Handle
</label>
<div class="mt-1 flex items-stretch border border-gray-300 rounded-md overflow-hidden bg-white">
<input
id="handle"
type="text"
bind:value={handle}
placeholder="tudisco"
class="flex-1 px-3 py-2 outline-none text-gray-900"
autocomplete="off"
/>
{#if serverInfo}
<span class="px-3 py-2 text-gray-500 bg-gray-50 border-l border-gray-300">
@{serverInfo.server}
</span>
{/if}
</div>
<p class="mt-1 text-xs text-gray-500">
Lowercase letters, digits, <code>-</code>, <code>_</code>. 332 chars.
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="pw">
Passphrase (encrypts your seed in this browser)
</label>
<input
id="pw"
type="password"
bind:value={passphrase}
class="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md outline-none"
autocomplete="new-password"
/>
<input
type="password"
bind:value={passphrase2}
placeholder="confirm passphrase"
class="mt-2 w-full px-3 py-2 border border-gray-300 rounded-md outline-none"
autocomplete="new-password"
/>
<p class="mt-1 text-xs text-gray-500">
The seed itself is your real identity. The passphrase only
protects the local copy in this browser.
</p>
</div>
<button
type="submit"
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
disabled={!serverInfo}
>
Continue
</button>
</form>
{/if}
{#if step === "seed" && id}
<div class="space-y-4">
<div class="border border-amber-300 bg-amber-50 rounded-lg p-4 space-y-3">
<p class="font-semibold text-amber-900">⚠️ Back up your seed now</p>
<p class="text-sm text-amber-800">
This is the only way to recover your account on another device
(or after clearing this browser). The server doesn't have it.
Write it down or paste into a password manager.
</p>
<div class="mt-3 p-3 bg-white border border-amber-200 rounded font-mono text-sm break-all select-all">
{seedHex}
</div>
<div class="flex gap-2">
<button
class="text-xs px-3 py-1 bg-amber-900 text-white rounded hover:bg-amber-800"
onclick={() => copyToClipboard(seedHex)}
>
Copy seed
</button>
</div>
</div>
<label class="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" bind:checked={seedAck} class="mt-1" />
I've saved this seed somewhere safe. I understand losing it means
losing my account permanently.
</label>
<div class="flex gap-2">
<button
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick={() => { step = "handle"; }}
>
Back
</button>
<button
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
disabled={!seedAck}
onclick={() => { step = "confirm"; }}
>
I've saved it — continue
</button>
</div>
</div>
{/if}
{#if step === "confirm" && id}
<div class="space-y-4">
<p class="text-gray-700">Ready to register:</p>
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50 space-y-2 text-sm">
<div><span class="text-gray-500">Handle:</span> <span class="font-mono font-semibold">{handle}@{serverInfo?.server}</span></div>
<div><span class="text-gray-500">Public key:</span> <code class="break-all">{id.identity}</code></div>
</div>
<div class="flex gap-2">
<button
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick={() => { step = "seed"; }}
>
Back
</button>
<button
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
disabled={working}
onclick={submitRegistration}
>
{working ? "Registering…" : "Register"}
</button>
</div>
</div>
{/if}
{#if step === "submitting"}
<p class="text-gray-700">Submitting registration to {serverInfo?.server}</p>
{/if}
{#if step === "done"}
<div class="border border-green-300 bg-green-50 rounded-lg p-6">
<p class="text-lg font-semibold text-green-900">✓ Account created</p>
<p class="mt-2 text-sm text-green-800">
You are <span class="font-mono font-semibold">{handle}@{serverInfo?.server}</span>.
</p>
<button
class="mt-4 px-4 py-2 bg-green-700 text-white rounded-md hover:bg-green-800"
onclick={() => push("/dashboard")}
>
Go to dashboard
</button>
</div>
{/if}
</div>

View File

@ -0,0 +1,98 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import { lookup, ApiError } from "../lib/api.js";
import { session } from "../lib/store.svelte.js";
let registryRecord = $state<any | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
try {
registryRecord = await lookup(session.unlocked.handle);
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
error = "Your handle isn't found on the server. It may have been removed.";
} else {
error = (e as Error).message;
}
} finally {
loading = false;
}
});
function showSeed() {
if (!session.unlocked) return;
const hex = bytesToHex(session.unlocked.seed);
alert(`Your seed (KEEP SECRET):\n\n${hex}\n\nCopy this somewhere safe.`);
}
</script>
{#if session.unlocked}
<div class="space-y-8">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<section class="border border-gray-200 rounded-lg p-6 bg-white">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-2">Identity</p>
<p class="text-2xl font-mono font-semibold text-gray-900">
{session.unlocked.handle}@{session.unlocked.server}
</p>
<p class="mt-2 text-xs font-mono text-gray-500 break-all">
{session.unlocked.primary}
</p>
{#if loading}
<p class="mt-4 text-sm text-gray-500">Loading server record…</p>
{:else if error}
<p class="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</p>
{:else if registryRecord}
<dl class="mt-4 grid sm:grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">Registered</dt>
<dd class="font-mono text-gray-900">{registryRecord.registered_at}</dd>
<dt class="text-gray-500">Sigchain URL</dt>
<dd class="font-mono text-gray-900 break-all">{registryRecord.sigchain_url}</dd>
</dl>
{/if}
</section>
<section class="border border-gray-200 rounded-lg p-6 bg-white">
<div class="flex items-start justify-between">
<div>
<p class="text-xs text-gray-500 uppercase tracking-wide">Claims</p>
<p class="text-sm text-gray-700 mt-1">
Link other accounts (GitHub, your domain, nostr, Bluesky, ActivityPub)
to your KEZ identity by signing claims and publishing the proofs.
</p>
</div>
<a
href="#/claims"
class="px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 no-underline"
>
Manage claims →
</a>
</div>
</section>
<section class="border border-gray-200 rounded-lg p-6 bg-white">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-2">Backup</p>
<p class="text-sm text-gray-700">
Your seed is the only thing that can recover this account.
Make sure you have it written down.
</p>
<button
class="mt-3 px-3 py-2 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick={showSeed}
>
Show seed
</button>
</section>
</div>
{/if}

View File

@ -0,0 +1,89 @@
<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>

View File

@ -0,0 +1,131 @@
<script lang="ts">
import { push } from "svelte-spa-router";
import { hexToBytes } from "@noble/hashes/utils";
import { identityFromSeed } from "../lib/kez.js";
import { lookup, healthz, ApiError } from "../lib/api.js";
import { saveIdentity } from "../lib/identity-store.js";
import { session } from "../lib/store.svelte.js";
import { onMount } from "svelte";
let seedHex = $state("");
let passphrase = $state("");
let passphrase2 = $state("");
let serverDomain = $state<string | null>(null);
let error = $state<string | null>(null);
let working = $state(false);
onMount(async () => {
try {
const h = await healthz();
serverDomain = h.server;
} catch {
/* will surface below */
}
});
async function submit() {
error = null;
working = true;
try {
const cleaned = seedHex.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(cleaned)) {
throw new Error("Seed must be 64 lowercase hex characters (32 bytes).");
}
if (passphrase.length < 8) {
throw new Error("Passphrase must be at least 8 characters.");
}
if (passphrase !== passphrase2) {
throw new Error("Passphrases don't match.");
}
const seed = hexToBytes(cleaned);
const id = identityFromSeed(seed);
if (!serverDomain) {
throw new Error("Server unreachable; refresh and try again.");
}
// Look up the primary on the server to find the associated handle.
// We try a couple of common handles? No — the registry is keyed by
// handle, not primary. So we ask the user to type their handle.
throw new Error(
"Sorry — to restore, please use a device that has your handle saved. " +
"(v0.2 will let you look up your handle by primary key.)",
);
// TODO when chat-server has GET /v1/by-primary/<id>: implement this.
// For now restoring a seed-only is incomplete because we don't know
// the handle. Workaround: regenerate identity via /create with same
// handle (server will reject as taken; not useful) OR ask the user.
} catch (e) {
error = e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
} finally {
working = false;
}
}
</script>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-gray-900">Restore from seed</h1>
<p class="text-sm text-gray-700 bg-amber-50 border border-amber-200 rounded p-3">
<strong>v0.1 limitation:</strong> the seed alone doesn't tell us which
handle to restore. For now this flow doesn't work end-to-end — we'll
add <code>GET /v1/by-primary/&lt;id&gt;</code> on the server in v0.2
so the SPA can look up the handle from the public key.
</p>
<form
class="space-y-4"
onsubmit={(e) => { e.preventDefault(); submit(); }}
>
<div>
<label class="block text-sm font-medium text-gray-700" for="seed">
Seed (64 hex characters)
</label>
<textarea
id="seed"
bind:value={seedHex}
rows="3"
class="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md font-mono text-sm"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="pw">
New passphrase for this device
</label>
<input
id="pw"
type="password"
bind:value={passphrase}
class="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md"
/>
<input
type="password"
bind:value={passphrase2}
placeholder="confirm"
class="mt-2 w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{#if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</p>
{/if}
<div class="flex gap-2">
<button
type="button"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick={() => push("/")}
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
disabled={working}
>
{working ? "Restoring…" : "Restore"}
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,82 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { loadStoredIdentityMeta, unlockIdentity, deleteStoredIdentity } from "../lib/identity-store.js";
import { session } from "../lib/store.svelte.js";
let meta = $state<{ handle: string; server: string; primary: string; created_at: string } | null>(null);
let passphrase = $state("");
let error = $state<string | null>(null);
let working = $state(false);
onMount(async () => {
meta = await loadStoredIdentityMeta();
if (!meta) push("/");
});
async function submit() {
working = true;
error = null;
try {
const id = await unlockIdentity(passphrase);
session.setUnlocked(id);
passphrase = "";
push("/dashboard");
} catch (e) {
error = (e as Error).message;
} finally {
working = false;
}
}
async function forget() {
if (!confirm("Delete the local copy of this account? You'll need your seed to restore.")) return;
await deleteStoredIdentity();
session.lock();
push("/");
}
</script>
<div class="space-y-6 max-w-md">
<h1 class="text-2xl font-bold text-gray-900">Unlock</h1>
{#if meta}
<p class="text-sm text-gray-600">
Unlocking <span class="font-mono font-semibold">{meta.handle}@{meta.server}</span>
</p>
<form
class="space-y-3"
onsubmit={(e) => { e.preventDefault(); submit(); }}
>
<input
type="password"
bind:value={passphrase}
placeholder="passphrase"
autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
{#if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</p>
{/if}
<button
type="submit"
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
disabled={working || passphrase.length === 0}
>
{working ? "Unlocking…" : "Unlock"}
</button>
</form>
<button
class="text-xs text-gray-500 hover:text-red-700 underline"
onclick={forget}
>
Forget this account on this device
</button>
{/if}
</div>

View File

@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};

View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [svelte(), tailwindcss()],
server: {
// For dev: proxy API calls to the locally-running chat-server.
// The deployed SPA (served by the same chat-server) doesn't need this.
proxy: {
"/v1": "http://localhost:6969",
"/internal": "http://localhost:6969",
"/.well-known": "http://localhost:6969",
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});