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
|
# Stage 1: build the Svelte SPA → web/dist/
|
||||||
# context must be the *repository root* (the dir that contains both
|
# Stage 2: build the Rust binary against kez-core (path dep)
|
||||||
# `kez-chat/` and `rust/`), not `kez-chat/` itself — see the
|
# Stage 3: minimal runtime image with binary + SPA
|
||||||
# `docker-compose.yml` which sets `context: ..`.
|
#
|
||||||
|
# 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
|
FROM rust:1.86-slim AS build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
pkg-config libssl-dev ca-certificates \
|
pkg-config libssl-dev ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy what we need:
|
|
||||||
# - rust/crates/kez-core (path dep)
|
|
||||||
# - kez-chat (this project)
|
|
||||||
COPY rust/ /src/rust/
|
COPY rust/ /src/rust/
|
||||||
COPY kez-chat/ /src/kez-chat/
|
COPY kez-chat/ /src/kez-chat/
|
||||||
|
|
||||||
WORKDIR /src/kez-chat
|
WORKDIR /src/kez-chat
|
||||||
RUN cargo build --release --bin kez-chat-server
|
RUN cargo build --release --bin kez-chat-server
|
||||||
|
|
||||||
# Stage 2: minimal runtime image
|
# ─── Stage 3: runtime ──────────────────────────────────────────────────────
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& useradd -r -u 10001 -m kez
|
&& 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
|
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
|
USER kez
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
@ -36,6 +45,7 @@ ENV KEZ_CHAT_BIND=0.0.0.0:6969 \
|
|||||||
KEZ_CHAT_DB=/data/kez-chat.db \
|
KEZ_CHAT_DB=/data/kez-chat.db \
|
||||||
KEZ_CHAT_SERVER=kez.lat \
|
KEZ_CHAT_SERVER=kez.lat \
|
||||||
KEZ_CHAT_SIG_SERVER_URL=http://sig-server:7878 \
|
KEZ_CHAT_SIG_SERVER_URL=http://sig-server:7878 \
|
||||||
|
KEZ_CHAT_WEB_DIR=/app/web \
|
||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
|
|
||||||
EXPOSE 6969
|
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