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:
parent
fdd281f0e2
commit
a9feb1b5b2
@ -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
5
kez-chat/web/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
13
kez-chat/web/index.html
Normal file
13
kez-chat/web/index.html
Normal 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
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
31
kez-chat/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
77
kez-chat/web/src/App.svelte
Normal file
77
kez-chat/web/src/App.svelte
Normal 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
17
kez-chat/web/src/app.css
Normal 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
10
kez-chat/web/src/app.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
80
kez-chat/web/src/lib/api.ts
Normal file
80
kez-chat/web/src/lib/api.ts
Normal 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);
|
||||
}
|
||||
44
kez-chat/web/src/lib/claims-store.ts
Normal file
44
kez-chat/web/src/lib/claims-store.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
157
kez-chat/web/src/lib/identity-store.ts
Normal file
157
kez-chat/web/src/lib/identity-store.ts
Normal 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
253
kez-chat/web/src/lib/kez.ts
Normal 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));
|
||||
}
|
||||
18
kez-chat/web/src/lib/store.svelte.ts
Normal file
18
kez-chat/web/src/lib/store.svelte.ts
Normal 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
9
kez-chat/web/src/main.ts
Normal 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;
|
||||
335
kez-chat/web/src/routes/AddClaim.svelte
Normal file
335
kez-chat/web/src/routes/AddClaim.svelte
Normal 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}:<…></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>
|
||||
110
kez-chat/web/src/routes/Claims.svelte
Normal file
110
kez-chat/web/src/routes/Claims.svelte
Normal 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>
|
||||
274
kez-chat/web/src/routes/CreateAccount.svelte
Normal file
274
kez-chat/web/src/routes/CreateAccount.svelte
Normal 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>. 3–32 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>
|
||||
98
kez-chat/web/src/routes/Dashboard.svelte
Normal file
98
kez-chat/web/src/routes/Dashboard.svelte
Normal 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}
|
||||
89
kez-chat/web/src/routes/Landing.svelte
Normal file
89
kez-chat/web/src/routes/Landing.svelte
Normal 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>
|
||||
131
kez-chat/web/src/routes/Restore.svelte
Normal file
131
kez-chat/web/src/routes/Restore.svelte
Normal 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/<id></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>
|
||||
82
kez-chat/web/src/routes/Unlock.svelte
Normal file
82
kez-chat/web/src/routes/Unlock.svelte
Normal 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>
|
||||
5
kez-chat/web/svelte.config.js
Normal file
5
kez-chat/web/svelte.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
19
kez-chat/web/tsconfig.json
Normal file
19
kez-chat/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
kez-chat/web/vite.config.ts
Normal file
20
kez-chat/web/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user