Merge branch 'nostr' — redesign + verified badge + onboarding + tutorials

13 commits landing from the nostr branch. The big ones:

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

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

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

All server changes ship with passing tests. Wire-compatible across
both implementations.
This commit is contained in:
Jason Tudisco 2026-05-30 00:21:31 -06:00
commit 52fe2c225f
42 changed files with 3397 additions and 420 deletions

View File

@ -41,6 +41,7 @@ pub fn router(state: AppState) -> axum::Router {
.route("/v1/u/:handle", get(lookup))
.route("/v1/by-primary/:primary", get(lookup_by_primary))
.route("/v1/register", post(register))
.route("/v1/profile/:handle/proofs", axum::routing::put(set_proofs))
.route("/v1/messages", post(crate::messages::send_message))
.route("/v1/inbox/:handle", get(crate::messages::inbox))
.route("/v1/inbox/:handle/stream", get(crate::messages::stream_inbox))
@ -81,6 +82,9 @@ pub struct HandleResponse {
pub primary: String, // e.g. "ed25519:abc..."
pub sigchain_url: String, // where the sigchain lives
pub registered_at: String,
/// Claim subjects the user published for discovery (e.g.
/// ["github:alice"]). Peers verify each independently. Empty if none.
pub proofs: Vec<String>,
}
async fn lookup(
@ -126,6 +130,11 @@ fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
id
),
registered_at: record.registered_at.to_rfc3339(),
proofs: record
.proofs
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_default(),
}
}
@ -154,6 +163,7 @@ async fn register(
handle: req.payload.handle.clone(),
primary: req.payload.primary.clone(),
registered_at: Utc::now(),
proofs: None,
};
state.store.register(&record).await?;
@ -163,6 +173,72 @@ async fn register(
))
}
// ─────────────────────────────────────────────────────────────────────────────
// PUT /v1/profile/:handle/proofs — publish your claim subjects (authed)
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct SetProofsRequest {
/// Claim subjects, e.g. ["github:alice","dns:alice.com"].
pub proofs: Vec<String>,
}
async fn set_proofs(
State(state): State<AppState>,
Path(handle): Path<String>,
headers: axum::http::HeaderMap,
Json(req): Json<SetProofsRequest>,
) -> Result<StatusCode, ApiError> {
validate_handle(&handle)
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
let record = state.store.lookup(&handle).await?.ok_or(ApiError::NotFound)?;
// Auth: X-KEZ-Auth: <unix_ts>:<sig>, signed by the handle's primary
// over the canonical request line. Same 60s skew window as inbox.
let auth = headers
.get("X-KEZ-Auth")
.ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))?
.to_str()
.map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?;
verify_profile_auth(auth, &handle, record.primary.value(), Utc::now().timestamp())?;
// Cap to keep profiles small; reject absurd payloads.
if req.proofs.len() > 64 {
return Err(ApiError::BadRequest("too many proofs (max 64)".into()));
}
let json = serde_json::to_string(&req.proofs)?;
state.store.set_proofs(&handle, &json).await?;
Ok(StatusCode::NO_CONTENT)
}
/// Canonical message the proofs-setter signs. Distinct first line from
/// the inbox/stream auth so signatures can't be cross-replayed.
pub fn canonical_profile_message(handle: &str, ts: i64) -> String {
format!("PUT\n/v1/profile/{handle}/proofs\n{ts}")
}
fn verify_profile_auth(
auth: &str,
handle: &str,
pubkey_hex: &str,
now_ts: i64,
) -> Result<(), ApiError> {
let (ts_str, sig_hex) = auth
.split_once(':')
.ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be <ts>:<sig>".into()))?;
let ts: i64 = ts_str
.parse()
.map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?;
if (now_ts - ts).abs() > 60 {
return Err(ApiError::Unauthorized("auth header is stale".into()));
}
let message = canonical_profile_message(handle, ts);
kez_core::verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex)
.map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?;
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────────
// GET /.well-known/webfinger — fediverse-style discovery
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -15,6 +15,10 @@ pub struct HandleRecord {
pub handle: String,
pub primary: Identity,
pub registered_at: DateTime<Utc>,
/// JSON array of claim subjects the user has published to their profile
/// (e.g. ["github:alice","dns:alice.com"]). Discovery only — peers
/// independently verify each against the channel. None until set.
pub proofs: Option<String>,
}
#[derive(Clone)]
@ -69,36 +73,26 @@ impl Store {
let conn = self.inner.lock().await;
let row = conn
.query_row(
"SELECT handle, primary_id, registered_at
"SELECT handle, primary_id, registered_at, proofs
FROM handles WHERE handle = ?1",
params![handle],
|row| {
let handle: String = row.get(0)?;
let primary_id: String = row.get(1)?;
let registered_at: String = row.get(2)?;
Ok((handle, primary_id, registered_at))
},
row_to_record_parts,
)
.optional()?;
row.map(build_record).transpose()
}
match row {
None => Ok(None),
Some((handle, primary_id, registered_at)) => {
let primary = Identity::parse(primary_id).map_err(|e| {
ApiError::Internal(format!("stored primary not parseable: {e}"))
})?;
let registered_at = DateTime::parse_from_rfc3339(&registered_at)
.map_err(|e| {
ApiError::Internal(format!("stored timestamp not parseable: {e}"))
})?
.with_timezone(&Utc);
Ok(Some(HandleRecord {
handle,
primary,
registered_at,
}))
}
/// Replace the published proof-subject list for `handle`.
pub async fn set_proofs(&self, handle: &str, proofs_json: &str) -> Result<(), ApiError> {
let conn = self.inner.lock().await;
let n = conn.execute(
"UPDATE handles SET proofs = ?1 WHERE handle = ?2",
params![proofs_json, handle],
)?;
if n == 0 {
return Err(ApiError::NotFound);
}
Ok(())
}
/// Look up the record for a primary key — used by the NATS auth
@ -111,45 +105,48 @@ impl Store {
let conn = self.inner.lock().await;
let row = conn
.query_row(
"SELECT handle, primary_id, registered_at
"SELECT handle, primary_id, registered_at, proofs
FROM handles WHERE primary_id = ?1",
params![primary.to_string()],
|row| {
let handle: String = row.get(0)?;
let primary_id: String = row.get(1)?;
let registered_at: String = row.get(2)?;
Ok((handle, primary_id, registered_at))
},
row_to_record_parts,
)
.optional()?;
row.map(build_record).transpose()
}
}
match row {
None => Ok(None),
Some((handle, primary_id, registered_at)) => {
let primary = Identity::parse(primary_id).map_err(|e| {
ApiError::Internal(format!("stored primary not parseable: {e}"))
})?;
type RecordParts = (String, String, String, Option<String>);
fn row_to_record_parts(row: &rusqlite::Row) -> rusqlite::Result<RecordParts> {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
}
fn build_record(parts: RecordParts) -> Result<HandleRecord, ApiError> {
let (handle, primary_id, registered_at, proofs) = parts;
let primary = Identity::parse(primary_id)
.map_err(|e| ApiError::Internal(format!("stored primary not parseable: {e}")))?;
let registered_at = DateTime::parse_from_rfc3339(&registered_at)
.map_err(|e| {
ApiError::Internal(format!("stored timestamp not parseable: {e}"))
})?
.map_err(|e| ApiError::Internal(format!("stored timestamp not parseable: {e}")))?
.with_timezone(&Utc);
Ok(Some(HandleRecord {
Ok(HandleRecord {
handle,
primary,
registered_at,
}))
}
}
}
proofs,
})
}
fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> {
// New column for existing databases — ignore "duplicate column" so
// re-running on an already-migrated DB is a no-op.
let _ = conn.execute("ALTER TABLE handles ADD COLUMN proofs TEXT", []);
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS handles (
handle TEXT NOT NULL PRIMARY KEY,
primary_id TEXT NOT NULL UNIQUE,
registered_at TEXT NOT NULL
registered_at TEXT NOT NULL,
proofs TEXT
);
CREATE INDEX IF NOT EXISTS idx_handles_primary
ON handles (primary_id);

9
kez-chat/web/.env Normal file
View File

@ -0,0 +1,9 @@
# Nostr branch: chat transport runs over Nostr relays instead of the
# kez-chat server inbox. The code default (see src/lib/transport.ts) is
# still "server", so main/other branches are unaffected — this file is
# what flips this branch to Nostr.
VITE_TRANSPORT=nostr
# Relays to publish to / read from (comma-separated). Optional — the
# transport falls back to these same defaults if unset.
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net

167
kez-chat/web/DESIGN.md Normal file
View File

@ -0,0 +1,167 @@
# KEZ Design System
> Source of truth for the kez-chat redesign (`redesign-kez-theme` branch).
> Goal: take the chat app out of the prototype phase into a real,
> WhatsApp/Discord-caliber messenger with an iconic KEZ identity.
## Who we're designing for
Hackers, infosec people, privacy absolutists, anti-surveillance / sovereignty
folks, Meshtastic & off-grid comms operators, journalists/activists in hostile
environments — the Signal / Briar / Tails / Mullvad / Monero crowd. They trust
**verifiability over promises**, have a finely-tuned bullshit detector, and
bounce instantly from anything that smells like VC surveillance-ware.
**Positioning:** _KEZ is the sovereign identity layer + encrypted comms for
people who assume the network is hostile._
## Aesthetic direction
**Muted tactical terminal — restraint, not neon cosplay.** Mullvad's calm
authority. Monospace as identity. The first 3 seconds should feel like opening
an operational tool, not a brochure. Hard-ish edges, visible structure, a single
cold accent. No gradients-blobs, no mascots, no "delightful."
### Hard DO-NOTs
- No rounded-blob/gradient SaaS look, no mascots, no illustrations of laughing people.
- No "military-grade / bank-level" marketing adjectives. Show, don't boast.
- No surveillance tells: no third-party analytics, no social login, no email-required signup.
- No stock photography.
## Color palette (dark-first; light theme = v2, out of scope)
Tailwind v4 `@theme` tokens, in `src/app.css`.
| Token | Hex | Use |
|---|---|---|
| `--color-bg` | `#0B0C0E` | app background (neutral near-black) |
| `--color-surface` | `#16181C` | cards, conversation list, sidebars |
| `--color-elevated` | `#1E2127` | modals, menus, input wells |
| `--color-border` | `#2A2E35` | hairlines, dividers |
| `--color-text` | `#E8EAED` | primary text (neutral off-white) |
| `--color-text-secondary` | `#9BA3AD` | secondary |
| `--color-text-muted` | `#5C636D` | timestamps, meta |
| `--color-text-disabled` | `#3A4049` | disabled |
| `--color-accent` | `#28C8E8` | **the KEZ color** — electric cyan |
| `--color-accent-dim` | `#1B9DBC` | hover/pressed, accent borders |
| `--color-accent-contrast` | `#04131A` | text on accent fills |
| `--color-verified` | `#4ADE80` | proof verified (distinct from accent) |
| `--color-danger` | `#FF5C6C` | destructive, failed |
| `--color-warning` | `#FFB13D` | needs-attention |
| `--color-bubble-recv` | `#1B1F25` | received message bubble fill |
Accent is used surgically: send bubbles, focus rings, active nav, the wordmark
cursor, links, live/streaming indicators. Greys carry the weight. **Verified
green is for proofs only** — never as a general accent, so a verification badge
never camouflages into accent UI.
## Typography
- **UI/body:** `Inter``--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`
- **Monospace:** `JetBrains Mono``--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace`. Used for keys, hashes, handles, the wordmark.
Loaded via Google Fonts.
| Role | Size / weight / line-height | Notes |
|---|---|---|
| Wordmark | 22px / 700 / 1.1 mono | `-0.02em`, + cyan block cursor |
| Section header | 12px / 600 / 1.3 sans | uppercase, `+0.08em`, secondary color |
| Body / message | 15px / 450 / 1.45 sans | |
| Conversation name | 15px / 600 / 1.3 sans | |
| Timestamp/meta | 12px / 500 / 1.2 | muted |
| Mono key display | 13px / 500 / 1.5 mono | `+0.01em` |
## Spacing / radius / shadow
- **Spacing** (4px base): 4, 8, 12, 16, 20, 24, 32, 48.
- **Radius — tactical, mid-soft:** `--radius-sm: 4px` (chips/badges), `--radius-md: 8px` (inputs/buttons), `--radius-lg: 12px` (bubbles/cards), `--radius-xl: 16px` (modals). Fully-round reads consumer-soft; sharp reads unfinished; 812 says "engineered."
- **Shadows = terminal glow, not ambient drop shadows.** Surfaces use `1px solid --color-border` hairlines. Modals: `0 8px 24px -8px rgba(0,0,0,0.6)`. Accent focus/glow: `0 0 0 1px #28C8E833, 0 0 16px -2px #28C8E866`.
## Signature components
- **Message bubbles** — radius `lg`, padding `8px 12px`, max-width ~78%.
- Sent: `--color-accent` fill, `--color-accent-contrast` text, `border-bottom-right-radius: 4px` tail.
- Received: `--color-bubble-recv` fill, `--color-text`, `1px solid --color-border`, `border-bottom-left-radius: 4px`.
- **Conversation row** — 64px tall, 40px avatar (`radius-md`), name 600, preview secondary, time muted. Active = left 2px accent bar + elevated bg. Unread = accent dot + name 700.
- **Buttons** — height 40px, `radius-md`, 600. Primary: accent fill / contrast text / dim hover / 0.98 active / glow focus. Secondary: transparent, `1px solid border`, hover elevated bg + accent-dim border.
- **Inputs** — elevated bg, `1px solid border`, `radius-md`, focus → accent border + `0 0 0 3px #28C8E822` ring. Key inputs use mono.
- **Handle/identity chip** — inline mono, `radius-sm`, `2px 8px`, bg `#28C8E814`, `1px solid #28C8E833`, accent text; leading `@`/`0x` muted.
- **Verified proof badge**`radius-sm`, bg `#4ADE8014`, border `#4ADE8040`, `--color-verified` text, mono 11/600, leading ✓.
- **Avatars** — deterministic identicon generated from the ed25519 key, so every KEZ has a stable face. Eliminates the biggest "prototype" tell.
## Motion
Fast (120200ms), `cubic-bezier(0.2,0.8,0.2,1)`, on state change only, respect
`prefers-reduced-motion`.
- Received message: slide-up 6px + fade.
- Sent message: spring `scale .96→1` + brief accent-glow pulse decaying ~600ms.
- Thread push/back slide on mobile.
- Tasteful terminal flourish: a single cyan block-cursor blink on the empty
compose field + after the wordmark. No content scanlines.
## Wordmark / icon
- **Wordmark:** `kez` lowercase, JetBrains Mono 700, `-0.02em`, primary text,
followed by a blinking cyan block cursor `▌`. The cursor is the brand mark.
- **Icon:** evolve the amber key → cyan. Recolor `public/kez-icon.svg` stroke
`#fbbf24``#28C8E8` on `#0B0C0E`; reshape so the key reads as a
key-meets-cursor glyph. Drop the literal 🔑 emoji everywhere. Regenerate PWA
icon set. Manifest `theme_color` + `background_color``#0B0C0E`.
## Information architecture (the big structural change)
**Land logged-in users on Chats, not a Dashboard.** The "dashboard" as a
destination is killed; its contents redistribute.
### Navigation — 4 destinations
| Destination | Purpose |
|---|---|
| **Chats** | conversation list + threads (the home) |
| **Contacts** | known KEZs + verification status + start-new-chat |
| **Identity** | your KEZ + claims/proofs (the superpower surface) |
| **Settings** | security, backup, notifications, account, about |
- **Mobile (PWA):** fixed bottom tab bar, 4 tabs, unread badge on Chats.
Thread view pushes full-screen with a back chevron.
- **Desktop:** slim left icon rail (4 destinations) + secondary list column +
main content pane. Replaces the current top nav bar entirely.
### Feature → new home
| Existing | New home |
|---|---|
| Landing/Create/Restore/Unlock | unauthenticated flow (pre-nav), restyled |
| Messages (list+thread+compose, SSE, emoji, unread, notifications) | **Chats** (default surface) |
| Start chat by handle | **Contacts → New chat** (preview card before opening) |
| Claims list | **Identity → My proofs** (grouped verified/failed/pending) |
| AddClaim | **Identity → Add proof** |
| Identity display (handle@server, ed25519 key, registry) | **Identity** header card (avatar, copyable KEZ, fingerprint, QR) |
| Seed/key backup | **Settings → Security → Recovery phrase** (re-auth gated) |
| Biometric/passkey | **Settings → Security → App lock** |
| Notifications perm + test | **Settings → Notifications** |
| Build SHA / source link | **Settings → About** |
### New-conversation flow (the KEZ moment)
+ FAB on Chats (mobile) / "New chat" in Contacts column (desktop) →
"Enter a KEZ" (`handle@server`, paste/QR) → `lookup`**preview card**:
avatar, handle, key fingerprint, and **inline verified proofs** (✓ github:you,
✓ dns:yourdomain) → "Message". Verification is always one tap from a thread via
the contact-detail header. You see who someone is before you trust them.
### Polish signals to ship
1. Identicon avatars everywhere (from ed25519 key).
2. Message status ticks (sent / SSE-delivered) + day separators.
3. Real empty + skeleton-loading states.
4. Verification shield badge system (green verified / neutral none / amber failed), consistent across Chats, Contacts, Identity.
5. Native push/back + send transitions; smooth auto-scroll (already shipped).
## Implementation phases
0. **Foundation**`app.css` Tailwind v4 `@theme` tokens, Google Fonts, recolor icon + regenerate PWA assets, manifest colors.
1. **Shell + nav** — bottom tab bar / left rail, router lands on `/chats`, wordmark component.
2. **Chats** — restyle list + thread + bubbles, identicon avatars, empty/skeleton states. Keep SSE/emoji/notifications/auto-scroll.
3. **Identity** — identity card + proofs (migrate Claims/AddClaim).
4. **Settings** — Account / Security / Notifications / About (migrate Dashboard remainder).
5. **Contacts** — list + new-chat preview card with proofs.
6. **Auth flow restyle** — Landing/Create/Restore/Unlock to the new theme.
All existing functionality is preserved; only its placement and presentation change.

View File

@ -7,6 +7,12 @@
<title>kez-chat</title>
<meta name="description" content="End-to-end encrypted chat on top of KEZ — portable cross-app identity." />
<!-- Fonts: Inter (UI) + JetBrains Mono (keys/handles/wordmark). Loaded
here, not via CSS @import — see app.css note. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=JetBrains+Mono:wght@500;600;700&display=swap" />
<!-- Browser tab + Android Chrome favicon -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/kez-icon.svg" />
@ -22,7 +28,25 @@
<!-- Web App Manifest (generated by vite-plugin-pwa) + Android theme color -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#111827" />
<meta name="theme-color" content="#0b0c0e" />
<!-- Resolve the theme before first paint to avoid a flash of the wrong
palette. Mirrors lib/theme.svelte.ts. -->
<script>
(function () {
try {
var c = localStorage.getItem("kez-chat:theme") || "system";
var light =
c === "light" ||
(c === "system" &&
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: light)").matches);
document.documentElement.dataset.theme = light ? "light" : "dark";
} catch (e) {
document.documentElement.dataset.theme = "dark";
}
})();
</script>
</head>
<body>
<div id="app"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 718 B

View File

@ -1,17 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Solid background. 20% padding around the glyph leaves the safe
area for Android "maskable" icons (radial crops, squircle, etc.). -->
<rect width="512" height="512" rx="96" ry="96" fill="#111827"/>
<!-- A simple key shape: round bow + rectangular shaft + two teeth.
Drawn at the center; bounding box ~280px wide, well inside the 80%
safe zone (~410px diameter). -->
<g transform="translate(106 156)" fill="none" stroke="#fbbf24" stroke-width="28" stroke-linecap="round" stroke-linejoin="round">
<!-- Neutral near-black bg with 20% maskable safe-zone padding. -->
<rect width="512" height="512" rx="96" ry="96" fill="#0B0C0E"/>
<!-- Key-as-cursor glyph in KEZ cyan: round bow + shaft that terminates
in a block cursor instead of teeth — key meets terminal prompt. -->
<g transform="translate(96 156)" fill="none" stroke="#28C8E8" stroke-width="30" stroke-linecap="round" stroke-linejoin="round">
<!-- Bow (ring) -->
<circle cx="80" cy="100" r="64"/>
<!-- Shaft (rounded line from the bow to the right edge) -->
<line x1="144" y1="100" x2="300" y2="100"/>
<!-- Two teeth on the bottom of the shaft -->
<line x1="240" y1="100" x2="240" y2="140"/>
<line x1="280" y1="100" x2="280" y2="156"/>
<circle cx="84" cy="100" r="66"/>
<!-- Shaft -->
<line x1="150" y1="100" x2="286" y2="100"/>
</g>
<!-- Block cursor terminator (filled), aligned to the shaft end. -->
<rect x="286" y="74" width="34" height="52" rx="4" fill="#28C8E8"/>
</svg>

Before

Width:  |  Height:  |  Size: 926 B

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 408 B

View File

@ -4,14 +4,17 @@
import { hasStoredIdentity } from "./lib/identity-store.js";
import { session } from "./lib/store.svelte.js";
import { inboxService } from "./lib/inbox-service.svelte.js";
import Wordmark from "./lib/Wordmark.svelte";
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 Welcome from "./routes/Welcome.svelte";
import Identity from "./routes/Identity.svelte";
import Claims from "./routes/Claims.svelte";
import AddClaim from "./routes/AddClaim.svelte";
import Settings from "./routes/Settings.svelte";
import Messages from "./routes/Messages.svelte";
const routes = {
@ -19,83 +22,110 @@
"/create": CreateAccount,
"/restore": Restore,
"/unlock": Unlock,
"/dashboard": Dashboard,
"/welcome": Welcome,
"/chats": Messages,
"/identity": Identity,
"/claims": Claims,
"/claims/add": AddClaim,
"/messages": Messages,
"/settings": Settings,
};
// 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 /.
// App routes show the nav chrome; everything else (auth flow) is full-bleed.
const APP_ROUTES = ["/welcome", "/chats", "/identity", "/claims", "/claims/add", "/settings"];
const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location));
onMount(async () => {
const stored = await hasStoredIdentity();
const protectedRoutes = ["/dashboard", "/claims", "/claims/add", "/messages"];
if (!stored && protectedRoutes.includes($location)) {
// Redirect legacy paths.
if ($location === "/dashboard") return push(session.unlocked ? "/identity" : "/unlock");
if ($location === "/messages") return push(session.unlocked ? "/chats" : "/unlock");
if (!stored && APP_ROUTES.includes($location)) {
push("/");
} else if (stored && !session.unlocked && protectedRoutes.includes($location)) {
} else if (stored && !session.unlocked && APP_ROUTES.includes($location)) {
push("/unlock");
}
});
// Nav destinations — Chats / Identity / Settings.
const nav = [
{ path: "/chats", label: "Chats", badge: true },
{ path: "/identity", label: "Identity", badge: false },
{ path: "/settings", label: "Settings", badge: false },
];
function isActive(path: string): boolean {
if (path === "/identity") return $location === "/identity" || $location.startsWith("/claims");
return $location === path;
}
</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
{#if showNav}
<div class="flex h-dvh bg-bg text-text">
<!-- Desktop: left icon rail -->
<nav class="hidden sm:flex flex-col items-center w-16 shrink-0 border-r border-border bg-surface py-4 gap-2">
<a href="#/chats" class="mb-4 w-9 h-9 flex items-center justify-center" aria-label="kez home">
<img src="/kez-icon.svg" alt="kez" class="w-9 h-9" />
</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="#/messages" class="text-gray-700 hover:text-gray-900 inline-flex items-center gap-1.5">
Messages
{#if inboxService.unreadCount > 0 && $location !== "/messages"}
<span
class="inline-flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-semibold bg-red-600 text-white rounded-full"
aria-label="{inboxService.unreadCount} unread"
{#each nav as item}
{@const active = isActive(item.path)}
<a
href={`#${item.path}`}
class={`relative flex items-center justify-center w-11 h-11 rounded-lg transition-colors ${active ? "bg-elevated text-accent" : "text-text-secondary hover:bg-elevated hover:text-text"}`}
aria-label={item.label}
title={item.label}
>
{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}
</span>
{#if active}<span class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-6 rounded-r bg-accent"></span>{/if}
{#if item.path === "/chats"}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
{:else if item.path === "/identity"}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
{:else}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{/if}
{#if item.badge && inboxService.unreadCount > 0 && $location !== "/chats"}
<span class="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-accent text-accent-contrast rounded-full">{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}</span>
{/if}
</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>
{/each}
</nav>
{/if}
</div>
</header>
<main class={`mx-auto px-6 py-8 ${$location === "/messages" ? "max-w-6xl" : "max-w-3xl"}`}>
<!-- Content -->
<main class="flex-1 min-w-0 overflow-y-auto pb-16 sm:pb-0">
<div class="h-full">
<Router {routes} />
</div>
</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 flex items-center gap-2 flex-wrap">
<span>kez-chat web</span>
<a
href={`https://git.ptud.biz/DukeInc/Kez/commit/${__BUILD_SHA__}`}
target="_blank"
rel="noopener"
class="font-mono px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 no-underline"
title={`built ${__BUILD_TIME__}`}
>
{__BUILD_SHA__}
</a>
<span>·</span>
<a
href="https://git.ptud.biz/DukeInc/Kez"
target="_blank"
rel="noopener"
class="text-gray-500 hover:text-gray-700"
>
source
<!-- Mobile: bottom tab bar -->
<nav class="sm:hidden fixed bottom-0 inset-x-0 z-40 h-16 border-t border-border bg-surface flex items-stretch" style="padding-bottom: env(safe-area-inset-bottom);">
{#each nav as item}
{@const active = isActive(item.path)}
<a href={`#${item.path}`} class={`relative flex-1 flex flex-col items-center justify-center gap-0.5 ${active ? "text-accent" : "text-text-secondary"}`} aria-label={item.label}>
{#if item.path === "/chats"}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
{:else if item.path === "/identity"}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
{:else}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{/if}
<span class="text-[10px] font-medium">{item.label}</span>
{#if item.badge && inboxService.unreadCount > 0 && $location !== "/chats"}
<span class="absolute top-2 right-[calc(50%-1.5rem)] min-w-4 h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-accent text-accent-contrast rounded-full">{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}</span>
{/if}
</a>
{/each}
</nav>
</div>
</footer>
{:else}
<!-- Unauthenticated / auth flow — full-bleed, centered. -->
<div class="min-h-dvh bg-bg text-text flex flex-col">
<header class="border-b border-border">
<div class="max-w-3xl mx-auto px-6 py-4">
<a href="#/" class="no-underline"><Wordmark size={20} /></a>
</div>
</header>
<main class="flex-1 max-w-3xl w-full mx-auto px-6 py-8">
<Router {routes} />
</main>
</div>
{/if}

View File

@ -1,17 +1,170 @@
@import "tailwindcss";
/* Base typography reset on top of Tailwind v4's preflight. */
/* KEZ design tokens (see DESIGN.md). Dark-first, "muted tactical terminal."
Tailwind v4 turns each --color-* into bg-/text-/border- utilities, each
--font-* into font-*, each --radius-* into rounded-*.
NOTE: keep this @theme block free of fancy unicode comments and do NOT
put a CSS `@import url()` before it either one stops Tailwind from
transforming @theme, and Lightning CSS then drops the whole block.
Fonts load via <link> in index.html for that reason. */
@theme {
/* Elevation ramp (neutral near-black) */
--color-bg: #0b0c0e;
--color-surface: #16181c;
--color-elevated: #1e2127;
--color-border: #2a2e35;
/* Text tiers */
--color-text: #e8eaed;
--color-text-secondary: #9ba3ad;
--color-text-muted: #5c636d;
--color-text-disabled: #3a4049;
/* The KEZ color — electric cyan, used surgically */
--color-accent: #28c8e8;
--color-accent-dim: #1b9dbc;
--color-accent-contrast: #04131a;
/* Semantic */
--color-verified: #4ade80; /* proofs only */
--color-danger: #ff5c6c;
--color-warning: #ffb13d;
/* Chat */
--color-bubble-recv: #1b1f25;
/* Type */
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
/* Radius — tactical, mid-soft */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Accent glow for focus/send + modal elevation */
--shadow-glow: 0 0 0 1px #28c8e833, 0 0 16px -2px #28c8e866;
--shadow-elev: 0 8px 24px -8px rgba(0, 0, 0, 0.6);
}
/* ─── Base ─────────────────────────────────────────────────────────────── */
/* Light theme overrides the dark @theme token values. These utilities
all compile to var(--color-*), so flipping the variable values flips
the whole app. Unlayered + after @theme, so it wins regardless of
specificity. An inline script in index.html sets data-theme before
paint to avoid a flash; the Settings toggle updates it at runtime. */
:root[data-theme="light"] {
color-scheme: light;
--color-bg: #f6f7f9;
--color-surface: #ffffff;
--color-elevated: #eef0f3;
--color-border: #dce0e6;
--color-text: #15181d;
--color-text-secondary: #5a626d;
--color-text-muted: #8b929c;
--color-text-disabled: #b8bdc5;
/* Deeper cyan so accent-as-text is legible on white; fills still read cyan. */
--color-accent: #0e90b4;
--color-accent-dim: #0a7290;
--color-accent-contrast: #ffffff;
--color-verified: #15a34a;
--color-danger: #dc2626;
--color-warning: #c2700a;
--color-bubble-recv: #eceff3;
}
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
Roboto, sans-serif;
color: #1f2937;
background: #f9fafb;
font-family: var(--font-sans);
color: var(--color-text);
background: var(--color-bg);
color-scheme: dark;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
}
code, kbd, pre {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
code,
kbd,
pre,
samp {
font-family: var(--font-mono);
}
/* Dark-theme defaults for form controls so any page that doesn't set an
explicit bg (the auth + claims forms) still reads correctly. Components
that set bg-elevated/bg-surface explicitly override this. */
input,
textarea,
select {
background-color: var(--color-elevated);
color: var(--color-text);
border-color: var(--color-border);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-muted);
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: none;
border-color: var(--color-accent);
}
/* Cyan text-selection — reinforces the accent. */
::selection {
background: #28c8e840;
color: var(--color-text);
}
/* Scrollbars: thin, dark, unobtrusive. */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
background-clip: content-box;
}
/* Blinking block cursor for the wordmark + empty-compose flourish. */
@keyframes kez-blink {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
.kez-cursor {
display: inline-block;
width: 0.5em;
height: 1em;
margin-left: 0.08em;
vertical-align: text-bottom;
background: var(--color-accent);
animation: kez-blink 1.1s steps(1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.kez-cursor {
animation: none;
}
}

View File

@ -0,0 +1,73 @@
<script lang="ts">
// Deterministic identicon avatar derived from a KEZ identity (the
// ed25519 primary, or any stable string). Same identity → same glyph,
// forever, on every device. A 5×5 vertically-symmetric grid (GitHub
// style) rendered in a cyan-family hue picked from the hash, on a dark
// tile. Gives every KEZ a stable "face" in lists/headers/previews —
// the biggest single fix for the prototype look.
interface Props {
/** Stable seed — usually the ed25519 primary "ed25519:<hex>". */
seed: string;
/** Rendered size in px. */
size?: number;
/** Optional ring (e.g. for the active/own avatar). */
ring?: boolean;
}
let { seed, size = 40, ring = false }: Props = $props();
// Cheap, stable 32-bit FNV-1a hash — no crypto needed, just spreading.
function hash(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
const h = $derived(hash(seed || "kez"));
// Hue restricted to the cool cyan→teal→blue arc so avatars stay on-brand
// (160210°), with controlled saturation/lightness for legibility on dark.
const hue = $derived(160 + (h % 50));
const fg = $derived(`hsl(${hue} 70% 62%)`);
const tile = $derived(`hsl(${hue} 28% 14%)`);
// Build a 5×5 grid; mirror columns 0↔4, 1↔3 for symmetry. Cell on/off
// from successive bits of the hash (re-mixed per cell so it's not too
// sparse/dense).
const cells = $derived.by(() => {
const out: boolean[] = [];
for (let i = 0; i < 15; i++) {
// 15 unique cells (3 columns × 5 rows), mirrored to 25.
const bit = (Math.imul(h ^ (i * 0x9e3779b1), 0x85ebca6b) >>> 28) & 1;
out.push(bit === 1);
}
return out;
});
function isOn(col: number, row: number): boolean {
const c = col < 3 ? col : 4 - col; // mirror
return cells[c * 5 + row];
}
</script>
<svg
width={size}
height={size}
viewBox="0 0 5 5"
class="shrink-0"
style="border-radius: {Math.max(4, size * 0.2)}px; {ring ? `box-shadow: 0 0 0 2px var(--color-accent);` : ''}"
role="img"
aria-label="identity avatar"
>
<rect width="5" height="5" fill={tile} />
{#each [0, 1, 2, 3, 4] as col}
{#each [0, 1, 2, 3, 4] as row}
{#if isOn(col, row)}
<rect x={col} y={row} width="1" height="1" fill={fg} />
{/if}
{/each}
{/each}
</svg>

View File

@ -5,6 +5,7 @@
// clicks are instant (module cached).
import { onDestroy } from "svelte";
import { theme } from "./theme.svelte.js";
interface Props {
/** Called with the picked emoji's character. */
@ -34,8 +35,8 @@
await import("emoji-picker-element");
pickerEl = document.createElement("emoji-picker") as HTMLElement;
pickerEl.addEventListener("emoji-click", onEmojiClick as EventListener);
// Match the dark-on-light scheme of the rest of the app.
pickerEl.classList.add("light");
// Follow the app theme (emoji-picker-element honors .light/.dark).
pickerEl.classList.add(theme.effective);
} catch (e) {
loadError = (e as Error).message;
} finally {
@ -116,7 +117,7 @@
is themeable here. Width comes from the component itself (~340px). */
:global(emoji-picker) {
height: 360px;
--background: #fff;
--border-color: #e5e7eb;
--background: var(--color-elevated);
--border-color: var(--color-border);
}
</style>

View File

@ -0,0 +1,27 @@
<script lang="ts">
// Verified check — shown next to a KEZ that has ≥1 cryptographically
// verified proof (the client checked it, X/Twitter-style). Green so it
// reads as "verified", distinct from the cyan brand accent.
interface Props {
size?: number;
/** Tooltip text. */
title?: string;
}
let { size = 16, title = "Verified — controls a proven account" }: Props = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
class="inline-block shrink-0 align-text-bottom"
style="color: var(--color-verified)"
fill="currentColor"
role="img"
aria-label="verified"
{title}
>
<!-- Scalloped seal + check, the familiar verified glyph. -->
<path d="M12 1.5l2.3 1.7 2.85-.2 1 2.67 2.45 1.46-.83 2.74.83 2.74-2.45 1.46-1 2.67-2.85-.2L12 22.5l-2.3-1.7-2.85.2-1-2.67-2.45-1.46.83-2.74-.83-2.74 2.45-1.46 1-2.67 2.85.2z"/>
<path d="M10.6 14.6l-2.2-2.2-1.4 1.4 3.6 3.6 6-6-1.4-1.4z" fill="var(--color-bg)"/>
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
// The KEZ wordmark: `kez` in mono + a blinking cyan block cursor.
// The cursor IS the brand mark — "you're at a live, secure prompt."
interface Props {
/** Font size in px for the wordmark text. Cursor scales with it. */
size?: number;
/** Hide the blinking cursor (e.g. in dense contexts). */
cursor?: boolean;
}
let { size = 22, cursor = true }: Props = $props();
</script>
<span
class="font-mono font-bold tracking-tight text-text select-none inline-flex items-baseline"
style="font-size: {size}px; letter-spacing: -0.02em;"
>
kez{#if cursor}<span class="kez-cursor" style="height: {size * 0.9}px; width: {size * 0.45}px;"></span>{/if}
</span>

View File

@ -1,6 +1,8 @@
// Thin HTTP client for kez-chat-server. Same calls a native CLI would
// make — the SPA dogfoods the API surface.
import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
import type { SignedRegistration } from "./kez.js";
export interface HandleResponse {
@ -9,6 +11,8 @@ export interface HandleResponse {
primary: string;
sigchain_url: string;
registered_at: string;
/** Claim subjects the user published for discovery, e.g. ["github:alice"]. */
proofs: string[];
}
export interface ApiErrorBody {
@ -78,6 +82,32 @@ export async function lookupByPrimary(primary: string): Promise<HandleResponse>
return unwrap(resp);
}
/**
* Publish your verified claim subjects to your server profile so peers
* can discover (and independently verify) them drives the verified
* badge in chat. Authed with X-KEZ-Auth signed by your primary.
*/
export async function setProofs(
handle: string,
seed: Uint8Array,
subjects: string[],
): Promise<void> {
const ts = Math.floor(Date.now() / 1000);
const msg = `PUT\n/v1/profile/${handle}/proofs\n${ts}`;
const sig = ed25519.sign(new TextEncoder().encode(msg), seed);
const resp = await fetch(url(`/v1/profile/${encodeURIComponent(handle)}/proofs`), {
method: "PUT",
headers: {
"content-type": "application/json",
"X-KEZ-Auth": `${ts}:${bytesToHex(sig)}`,
},
body: JSON.stringify({ proofs: subjects }),
});
if (!resp.ok) {
throw new ApiError(resp.status, `setProofs → ${resp.status}`);
}
}
export async function register(
signed: SignedRegistration,
): Promise<HandleResponse> {

View File

@ -34,6 +34,10 @@ export interface Conversation {
messages: ConversationMessage[];
/** Max server-seq we've processed for any message from this peer. */
last_seq: number;
/** Whether ≥1 of the peer's published proofs verified (the badge). */
verified?: boolean;
/** ISO timestamp of the last verification check (24h cache window). */
verified_checked_at?: string;
}
interface Store {
@ -99,6 +103,19 @@ export async function ensureConversation(
return fresh;
}
/** Record a peer's verification result (drives the verified badge). */
export async function setVerified(
peer_primary: Identity,
verified: boolean,
): Promise<void> {
const s = await read();
const conv = s.by_peer[peer_primary];
if (!conv) return;
conv.verified = verified;
conv.verified_checked_at = new Date().toISOString();
await write(s);
}
export async function appendInbound(opts: {
peer_primary: Identity;
peer_handle: string;

View File

@ -27,7 +27,7 @@ import {
streamInbox,
type InboxMessage,
type StreamHandle,
} from "./messages.js";
} from "./transport.js";
import { lookupByPrimary } from "./api.js";
import {
appendInbound,

View File

@ -0,0 +1,61 @@
// Bridges the KEZ ed25519 identity onto Nostr's secp256k1 world.
//
// Nostr signs with secp256k1 (Schnorr); KEZ identities are ed25519.
// The two curves can't be cross-derived, so we can't turn someone's
// ed25519 primary into "their" Nostr pubkey. Instead:
//
// • The *signing* key is a secp256k1 key derived deterministically
// from the user's own ed25519 seed (HKDF, domain-separated). It is
// a pure transport credential — internal to this app, never the
// user's real Nostr account, and its pubkey is never advertised.
//
// • *Addressing* is done by a tag derived from the recipient's
// ed25519 PRIMARY (which is public, so any sender can compute it).
// The recipient subscribes to relays filtering on the same tag.
//
// This keeps the whole thing "internal": nothing Nostr-specific leaks
// into the UI or the KEZ identity model. Nostr is just the pipe.
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha2";
import { bytesToHex } from "@noble/hashes/utils";
import type { Identity } from "./kez.js";
/** Regular event kind (10009999 → relays persist it, which the inbox needs). */
export const KEZ_DM_KIND = 4242;
/** Tag name carrying the recipient address. `#h` filter on the relay side. */
export const ADDR_TAG = "h";
const SIGNKEY_SALT = new TextEncoder().encode("kez-chat:nostr-signkey");
const SIGNKEY_INFO = new TextEncoder().encode("v1");
const ADDR_SALT = new TextEncoder().encode("kez-chat:nostr-addr");
const ADDR_INFO = new TextEncoder().encode("v1");
/**
* Derive the secp256k1 secret key this identity signs Nostr events with.
* Deterministic from the 32-byte ed25519 seed, so the same account always
* produces the same Nostr signer but it reveals nothing about the seed.
*
* HKDF output is a uniformly random 32-byte value; the probability it is
* not a valid secp256k1 scalar ( curve order n) is ~2¹², i.e. never.
*/
export function nostrSecretFromSeed(seed: Uint8Array): Uint8Array {
if (seed.length !== 32) throw new Error(`seed must be 32 bytes, got ${seed.length}`);
return hkdf(sha256, seed, SIGNKEY_SALT, SIGNKEY_INFO, 32);
}
/**
* Deterministic 32-byte (hex) address for a recipient, derived from their
* public ed25519 primary. Both parties can compute it: the sender from the
* directory lookup, the recipient from their own primary. Used as the value
* of the `#h` tag so a relay subscription can fan messages to the right box.
*
* It is *not* the recipient's Nostr pubkey (can't be wrong curve); it is an
* opaque routing label. Using a hash also means the raw primary isn't sitting
* in a relay-queryable tag.
*/
export function addrFromPrimary(primary: Identity): string {
const bytes = new TextEncoder().encode(primary);
return bytesToHex(hkdf(sha256, bytes, ADDR_SALT, ADDR_INFO, 32));
}

View File

@ -0,0 +1,243 @@
// Nostr-relay transport. Drop-in replacement for the server inbox in
// messages.ts — same public surface (sendMessage / pollInbox /
// streamInbox / decrypt) so inbox-service and the Messages UI don't know
// or care which pipe is in use.
//
// What changes vs. the server transport: instead of POSTing the sealed
// envelope to /v1/messages and polling /v1/inbox, we publish it as the
// content of a Nostr event and subscribe to relays for events addressed
// to us. The envelope itself is byte-identical — it's still produced by
// crypto.ts (our own ed25519/x25519 + AES-GCM layer). Nostr only moves
// the bytes; our key still does the encrypting.
//
// Addressing: events carry an `#h` tag = addrFromPrimary(recipient). We
// subscribe with a `{ "#h": [myAddr] }` filter. Events are signed by a
// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so
// relays accept them — that key is never surfaced to the user.
import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools";
import { sealMessage, type SealedEnvelope } from "./crypto.js";
import { lookup } from "./api.js";
import { identityFromSeed, type Identity } from "./kez.js";
import { nostrSecretFromSeed, addrFromPrimary, KEZ_DM_KIND, ADDR_TAG } from "./nostr-id.js";
// Decryption is transport-agnostic (just our crypto), so reuse it verbatim.
import { decrypt, type InboxMessage, type StreamHandle } from "./messages.js";
export { decrypt };
export type { InboxMessage, StreamHandle };
/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv). */
const RELAYS: string[] = (
import.meta.env.VITE_NOSTR_RELAYS ??
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net"
)
.split(",")
.map((r: string) => r.trim())
.filter(Boolean);
/** One pool for the whole session — relay connections are reused. */
let _pool: SimplePool | null = null;
function pool(): SimplePool {
if (!_pool) _pool = new SimplePool();
return _pool;
}
// ─────────────────────────────────────────────────────────────────────────────
// Per-handle cursor + dedupe (localStorage, survives reloads)
// ─────────────────────────────────────────────────────────────────────────────
const SINCE_KEY = (h: string) => `kez-chat:nostr:since:${h}`;
const SEEN_KEY = (h: string) => `kez-chat:nostr:seen:${h}`;
const SEEN_CAP = 500;
/** Relay `since` filter (unix seconds). Start a little in the past so a
* fresh device still catches very recent messages. */
function readSince(handle: string): number {
try {
const v = localStorage.getItem(SINCE_KEY(handle));
return v ? parseInt(v, 10) : Math.floor(Date.now() / 1000) - 7 * 24 * 3600;
} catch {
return 0;
}
}
function bumpSince(handle: string, createdAt: number) {
try {
if (createdAt > readSince(handle)) {
localStorage.setItem(SINCE_KEY(handle), String(createdAt));
}
} catch {
/* private mode — fine */
}
}
function readSeen(handle: string): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(SEEN_KEY(handle)) ?? "[]"));
} catch {
return new Set();
}
}
/** Returns true if this id is new (and records it); false if already seen. */
function markSeen(handle: string, id: string): boolean {
const seen = readSeen(handle);
if (seen.has(id)) return false;
seen.add(id);
// Bound the set so localStorage doesn't grow forever.
const arr = [...seen].slice(-SEEN_CAP);
try {
localStorage.setItem(SEEN_KEY(handle), JSON.stringify(arr));
} catch {
/* ignore */
}
return true;
}
/**
* Map a Nostr event InboxMessage. `seq` must be monotonic across sessions
* (the inbox-service notification watermark and conversations-store dedupe
* both rely on it), so we base it on created_at and spread within a second
* using the event id to avoid same-second collisions between distinct msgs.
*/
function toInboxMessage(ev: Event): InboxMessage | null {
let envelope: SealedEnvelope;
try {
envelope = JSON.parse(ev.content) as SealedEnvelope;
} catch {
return null; // not one of ours / malformed
}
const subMilli = parseInt(ev.id.slice(0, 3), 16) % 1000;
const seq = ev.created_at * 1000 + subMilli;
return {
seq,
envelope,
created_at: new Date(ev.created_at * 1000).toISOString(),
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Send
// ─────────────────────────────────────────────────────────────────────────────
export async function sendMessage(opts: {
senderHandle: string;
senderSeed: Uint8Array;
senderPrimary: Identity;
recipient: string;
body: string;
}): Promise<{ seq: number }> {
const recipientHandle = opts.recipient.split("@")[0];
const record = await lookup(recipientHandle); // throws on 404
const recipientPrimary = record.primary as Identity;
// Our own encryption layer — identical to the server transport.
const envelope = await sealMessage({
senderSeed: opts.senderSeed,
senderPrimary: opts.senderPrimary,
recipientHandle,
recipientPrimary,
body: opts.body,
});
const sk = nostrSecretFromSeed(opts.senderSeed);
const tmpl: EventTemplate = {
kind: KEZ_DM_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [[ADDR_TAG, addrFromPrimary(recipientPrimary)]],
content: JSON.stringify(envelope),
};
const signed = finalizeEvent(tmpl, sk);
// Succeed if at least one relay accepts.
const results = await Promise.allSettled(pool().publish(RELAYS, signed));
if (!results.some((r) => r.status === "fulfilled")) {
const why = results
.map((r) => (r.status === "rejected" ? String(r.reason) : ""))
.filter(Boolean)
.join("; ");
throw new Error(`no relay accepted the message${why ? `: ${why}` : ""}`);
}
return { seq: signed.created_at };
}
// ─────────────────────────────────────────────────────────────────────────────
// Inbox poll (one-shot relay query — the heartbeat catch-up path)
// ─────────────────────────────────────────────────────────────────────────────
export async function pollInbox(opts: {
handle: string;
seed: Uint8Array;
since: number; // ignored: server uses a seq cursor, we keep a time cursor
limit?: number;
}): Promise<{ messages: InboxMessage[]; cursor: number }> {
const myPrimary = identityFromSeed(opts.seed).identity;
const addr = addrFromPrimary(myPrimary);
const since = readSince(opts.handle);
const events = await pool().querySync(RELAYS, {
kinds: [KEZ_DM_KIND],
[`#${ADDR_TAG}`]: [addr],
since,
...(opts.limit ? { limit: opts.limit } : {}),
});
const messages: InboxMessage[] = [];
let maxSeq = 0;
for (const ev of events.sort((a, b) => a.created_at - b.created_at)) {
if (!markSeen(opts.handle, ev.id)) continue;
const m = toInboxMessage(ev);
if (!m) continue;
messages.push(m);
bumpSince(opts.handle, ev.created_at);
if (m.seq > maxSeq) maxSeq = m.seq;
}
return { messages, cursor: maxSeq };
}
// ─────────────────────────────────────────────────────────────────────────────
// Live subscription (the SSE-equivalent push path)
// ─────────────────────────────────────────────────────────────────────────────
export function streamInbox(opts: {
handle: string;
seed: Uint8Array;
onMessage: (msg: InboxMessage) => void;
onStatus?: (status: "connecting" | "live" | "reconnecting") => void;
}): StreamHandle {
const myPrimary = identityFromSeed(opts.seed).identity;
const addr = addrFromPrimary(myPrimary);
let closed = false;
opts.onStatus?.("connecting");
const sub = pool().subscribeMany(
RELAYS,
{ kinds: [KEZ_DM_KIND], [`#${ADDR_TAG}`]: [addr], since: readSince(opts.handle) },
{
onevent(ev: Event) {
if (closed) return;
if (!markSeen(opts.handle, ev.id)) return;
const m = toInboxMessage(ev);
if (!m) return;
bumpSince(opts.handle, ev.created_at);
opts.onMessage(m);
},
oneose() {
// End of stored events from all relays → we're now live-tailing.
opts.onStatus?.("live");
},
},
);
return {
close() {
closed = true;
sub.close();
},
get readyState() {
// EventSource-compatible: OPEN(1) while subscribed, CLOSED(2) after.
return closed ? 2 : 1;
},
};
}
export type { SealedEnvelope };
export type { MessagePlaintext } from "./crypto.js";

View File

@ -0,0 +1,48 @@
// First-run onboarding state. Two persisted flags:
// • onboarded — user finished/skipped the Getting Started checklist
// • seedAcked — user confirmed they backed up their recovery seed
// Both in localStorage; exposed as Svelte 5 $state so the "finish setup"
// nudge reactively disappears. Everything else in the checklist derives
// from real app state (claims, biometric, notification permission).
const ONBOARDED = "kez-chat:onboarded";
const SEED_ACKED = "kez-chat:seed_acked";
function read(key: string): boolean {
try {
return localStorage.getItem(key) === "1";
} catch {
return false;
}
}
function write(key: string, val: boolean) {
try {
if (val) localStorage.setItem(key, "1");
else localStorage.removeItem(key);
} catch {
/* private mode — fine */
}
}
class Onboarding {
onboarded = $state(read(ONBOARDED));
seedAcked = $state(read(SEED_ACKED));
finish() {
this.onboarded = true;
write(ONBOARDED, true);
}
ackSeed() {
this.seedAcked = true;
write(SEED_ACKED, true);
}
/** Re-open the checklist (e.g. from Settings → Getting started). */
reopen() {
this.onboarded = false;
write(ONBOARDED, false);
}
}
export const onboarding = new Onboarding();

View File

@ -0,0 +1,73 @@
// Light / dark / system theme switching.
//
// The CSS lives in app.css: dark is the @theme default, and a
// :root[data-theme="light"] block overrides the token values. This
// module just decides which data-theme attribute to set on <html> and
// persists the user's choice.
//
// "system" follows prefers-color-scheme live (re-applies when the OS
// flips). An inline script in index.html sets the initial attribute
// before first paint to avoid a flash of the wrong theme.
export type ThemeChoice = "light" | "dark" | "system";
const KEY = "kez-chat:theme";
function systemPrefersLight(): boolean {
return (
typeof matchMedia !== "undefined" &&
matchMedia("(prefers-color-scheme: light)").matches
);
}
function effectiveOf(choice: ThemeChoice): "light" | "dark" {
if (choice === "system") return systemPrefersLight() ? "light" : "dark";
return choice;
}
function loadChoice(): ThemeChoice {
const v = (typeof localStorage !== "undefined" && localStorage.getItem(KEY)) || "";
return v === "light" || v === "dark" || v === "system" ? v : "system";
}
class ThemeStore {
choice = $state<ThemeChoice>(loadChoice());
/** The resolved theme actually applied right now. */
effective = $state<"light" | "dark">(effectiveOf(loadChoice()));
constructor() {
// Re-apply when the OS theme changes, but only while on "system".
if (typeof matchMedia !== "undefined") {
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
if (this.choice === "system") this.#apply();
});
}
}
set(choice: ThemeChoice) {
this.choice = choice;
try {
localStorage.setItem(KEY, choice);
} catch {
/* private mode / storage disabled — fine, just won't persist */
}
this.#apply();
}
#apply() {
this.effective = effectiveOf(this.choice);
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = this.effective;
// Keep the mobile browser-chrome (status bar) color in sync.
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.setAttribute(
"content",
this.effective === "light" ? "#f6f7f9" : "#0b0c0e",
);
}
}
}
}
export const theme = new ThemeStore();

View File

@ -0,0 +1,28 @@
// Transport facade. The rest of the app (inbox-service, Messages) imports
// send/poll/stream/decrypt from here and never learns which pipe carries
// the bytes. Both transports expose an identical surface and both ship the
// same sealed envelope from crypto.ts — only the delivery mechanism differs.
//
// VITE_TRANSPORT=server (default) → kez-chat server inbox over HTTP/SSE
// VITE_TRANSPORT=nostr → Nostr relays
//
// Set it in .env / .env.local. Switching transports is build-time; there's
// no reason to flip it at runtime and keeping it static lets Vite tree-shake
// the unused transport out of the bundle.
import * as server from "./messages.js";
import * as nostr from "./nostr-transport.js";
const TRANSPORT = (import.meta.env.VITE_TRANSPORT ?? "server") as "server" | "nostr";
const impl = TRANSPORT === "nostr" ? nostr : server;
export const sendMessage = impl.sendMessage;
export const pollInbox = impl.pollInbox;
export const streamInbox = impl.streamInbox;
export const decrypt = impl.decrypt;
export type { InboxMessage, StreamHandle, SealedEnvelope, MessagePlaintext } from "./messages.js";
/** Which transport this build is using — handy for a debug line in the UI. */
export const activeTransport = TRANSPORT;

View File

@ -48,4 +48,34 @@ export async function verifyClaim(claim: StoredClaim): Promise<VerifyResult> {
}
}
/**
* Verify that `primary` genuinely controls `subject` by fetching the
* proof from the subject's channel and checking the signature same
* real check as verifyClaim, but driven by just (subject, primary)
* since we don't hold a peer's envelope locally. Used for the verified
* badge on other people in chat.
*
* The channel is the subject prefix (github:, dns:, ). Verifiers read
* ctx.subject + ctx.primary; `expected` is unused by them, so a stub is
* fine here.
*/
export async function verifySubject(
subject: string,
primary: string,
): Promise<boolean> {
const channel = subject.split(":")[0];
const handler = REGISTRY[channel];
if (!handler) return false;
try {
const res = await handler({
subject,
primary,
expected: undefined as never,
});
return res.status === "ok";
} catch {
return false;
}
}
export type { VerifyResult } from "./verifiers/types.js";

View File

@ -1,6 +1,10 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";
// Side-effect: instantiate the theme store so its prefers-color-scheme
// listener is live app-wide (the no-flash initial is set by the inline
// script in index.html).
import "./lib/theme.svelte.js";
const app = mount(App, {
target: document.getElementById("app")!,

View File

@ -246,9 +246,9 @@
<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>
<h1 class="text-2xl font-bold text-text">Add a claim</h1>
<button
class="text-sm text-gray-500 hover:text-gray-900"
class="text-sm text-text-muted hover:text-text"
onclick={() => push("/claims")}
>
← Back to claims
@ -256,25 +256,25 @@
</div>
<!-- Stepper -->
<ol class="flex gap-2 text-xs text-gray-500">
<li class={step === "pick" ? "font-semibold text-gray-900" : ""}>1. Channel</li>
<ol class="flex gap-2 text-xs text-text-muted">
<li class={step === "pick" ? "font-semibold text-text" : ""}>1. Channel</li>
<li></li>
<li class={step === "identifier" ? "font-semibold text-gray-900" : ""}>2. Identifier</li>
<li class={step === "identifier" ? "font-semibold text-text" : ""}>2. Identifier</li>
<li></li>
<li class={step === "publish" ? "font-semibold text-gray-900" : ""}>3. Publish</li>
<li class={step === "publish" ? "font-semibold text-text" : ""}>3. Publish</li>
<li></li>
<li class={step === "done" ? "font-semibold text-gray-900" : ""}>4. Done</li>
<li class={step === "done" ? "font-semibold text-text" : ""}>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"
class="text-left border border-border rounded-lg p-4 bg-surface hover:border-accent-dim transition"
onclick={() => pickChannel(c)}
>
<p class="font-semibold text-gray-900">{c.label}</p>
<p class="text-xs text-gray-500 mt-1 font-mono">{c.key}:&lt;&gt;</p>
<p class="font-semibold text-text">{c.label}</p>
<p class="text-xs text-text-muted mt-1 font-mono">{c.key}:&lt;&gt;</p>
</button>
{/each}
</div>
@ -286,7 +286,7 @@
onsubmit={(e) => { e.preventDefault(); buildAndSign(); }}
>
<div>
<label class="block text-sm font-medium text-gray-700" for="ident">
<label class="block text-sm font-medium text-text-secondary" for="ident">
{selected.identifierLabel}
</label>
<input
@ -294,22 +294,22 @@
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"
class="mt-1 w-full px-3 py-2 border border-border rounded-md font-mono"
autocomplete="off"
/>
{#if selected.key === "nostr" && nip07Available}
<button
type="button"
class="mt-2 inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-purple-300 bg-purple-50 text-purple-800 rounded-md hover:bg-purple-100"
class="mt-2 inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-accent/40 bg-accent/10 text-accent rounded-md hover:bg-accent/20"
onclick={fillFromNostrExtension}
>
⚡ Use my nostr extension
</button>
<span class="ml-2 text-xs text-gray-500">Reads your pubkey via NIP-07 — no copy/paste.</span>
<span class="ml-2 text-xs text-text-muted">Reads your pubkey via NIP-07 — no copy/paste.</span>
{/if}
{#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 class="mt-1 text-xs text-text-muted">
Subject will be: <code class="bg-elevated px-1 rounded">{selected.toSubject(identifierInput)}</code>
</p>
{/if}
</div>
@ -317,14 +317,14 @@
<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"
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
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"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={identifierInput.trim().length === 0}
>
Sign claim
@ -336,30 +336,30 @@
{#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">
<h2 class="text-sm font-semibold text-text-secondary 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>
<pre class="whitespace-pre-wrap text-sm bg-elevated border border-border rounded p-4 leading-relaxed">{selected.instructions(envelope.payload.subject)}</pre>
</section>
<section class="space-y-3">
<div class="flex items-center gap-2 flex-wrap">
<h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide flex-1">
<h2 class="text-sm font-semibold text-text-secondary uppercase tracking-wide flex-1">
2. Copy this:
</h2>
<div class="flex border border-gray-300 rounded overflow-hidden text-xs">
<div class="flex border border-border rounded overflow-hidden text-xs">
<button
class={`px-2 py-1 ${format === "compact" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`}
class={`px-2 py-1 ${format === "compact" ? "bg-accent text-accent-contrast" : "bg-surface text-text-secondary hover:bg-elevated"}`}
onclick={() => (format = "compact")}
title="kez:z1: — zstd + base64url. Fits in tight places like DNS TXT records, QR codes, chat messages."
>compact</button>
<button
class={`px-2 py-1 border-l border-gray-300 ${format === "markdown" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`}
class={`px-2 py-1 border-l border-border ${format === "markdown" ? "bg-accent text-accent-contrast" : "bg-surface text-text-secondary hover:bg-elevated"}`}
onclick={() => (format = "markdown")}
title="Human-readable; embeds the JSON inside a ```kez fence. Best for gists and README files."
>markdown</button>
<button
class={`px-2 py-1 border-l border-gray-300 ${format === "json" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`}
class={`px-2 py-1 border-l border-border ${format === "json" ? "bg-accent text-accent-contrast" : "bg-surface text-text-secondary hover:bg-elevated"}`}
onclick={() => (format = "json")}
title="Raw envelope JSON. Best for .well-known/kez.json and developer tooling."
>JSON</button>
@ -367,20 +367,20 @@
</div>
{#await renderArtifact(envelope, format)}
<pre class="text-xs bg-gray-900 text-gray-100 rounded p-4 font-mono leading-relaxed max-h-96 min-h-32 flex items-center justify-center text-gray-500">Computing…</pre>
<pre class="text-xs bg-accent text-text rounded p-4 font-mono leading-relaxed max-h-96 min-h-32 flex items-center justify-center text-text-muted">Computing…</pre>
{:then text}
<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 whitespace-pre-wrap break-all">{text}</pre>
<pre class="text-xs bg-accent text-text rounded p-4 overflow-x-auto font-mono leading-relaxed max-h-96 overflow-y-auto whitespace-pre-wrap break-all">{text}</pre>
{#if format === "compact"}
<p class="text-xs text-gray-500">
<p class="text-xs text-text-muted">
{text.length} chars · zstd-compressed signed envelope, base64url-encoded.
</p>
{/if}
{:catch e}
<pre class="text-xs bg-red-50 border border-red-200 text-red-800 rounded p-4">Error: {e.message}</pre>
<pre class="text-xs bg-danger/10 border border-danger/40 text-danger rounded p-4">Error: {e.message}</pre>
{/await}
<button
class="px-3 py-2 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={copyArtifact}
>
{copied ? "✓ Copied" : "Copy to clipboard"}
@ -389,23 +389,23 @@
</div>
{#if selected.key === "nostr" && nip07Available}
<div class="border border-purple-200 bg-purple-50 rounded-lg p-4">
<p class="text-sm font-semibold text-purple-900">⚡ One-click publish via your nostr extension</p>
<p class="mt-1 text-xs text-purple-800">
<div class="border border-accent/40 bg-accent/10 rounded-lg p-4">
<p class="text-sm font-semibold text-accent">⚡ One-click publish via your nostr extension</p>
<p class="mt-1 text-xs text-accent">
Wraps the markdown block in a normal nostr post (kind 1), asks
your extension to sign it, and broadcasts to the relay pool.
Verifiers (web + Rust CLI) will pick it up automatically.
</p>
<div class="mt-3 flex items-center gap-3">
<button
class="px-3 py-1.5 text-sm bg-purple-700 text-white rounded-md hover:bg-purple-800 disabled:opacity-50"
class="px-3 py-1.5 text-sm bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
onclick={publishViaNostrExtension}
disabled={nostrPublish.status === "pending"}
>
{nostrPublish.status === "pending" ? "Publishing…" : "Publish to nostr"}
</button>
{#if nostrPublish.status === "ok"}
<span class="text-xs text-green-800">
<span class="text-xs text-verified">
✓ Posted to {nostrPublish.result.ok.length} relay(s).
<a
href={nostrPublish.result.evidence_url}
@ -415,11 +415,11 @@
>view on njump.me</a>
</span>
{:else if nostrPublish.status === "error"}
<span class="text-xs text-red-800">{nostrPublish.message}</span>
<span class="text-xs text-danger">{nostrPublish.message}</span>
{/if}
</div>
{#if nostrPublish.status === "ok" && nostrPublish.result.failed.length > 0}
<p class="mt-2 text-xs text-amber-800">
<p class="mt-2 text-xs text-warning">
{nostrPublish.result.failed.length} relay(s) didn't ack:
{nostrPublish.result.failed.map((f) => f.relay).join(", ")}
</p>
@ -427,15 +427,15 @@
</div>
{/if}
<div class="flex gap-2 pt-4 border-t border-gray-200">
<div class="flex gap-2 pt-4 border-t border-border">
<button
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={() => { step = "identifier"; }}
>
Back
</button>
<button
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim"
onclick={saveAndDone}
>
Save claim
@ -444,9 +444,9 @@
{/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">
<div class="border border-verified/40 bg-verified/10 rounded-lg p-6">
<p class="text-lg font-semibold text-verified">✓ Claim saved</p>
<p class="mt-2 text-sm text-verified">
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
@ -455,12 +455,12 @@
<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"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim 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"
class="px-4 py-2 border border-verified/40 bg-surface rounded-md text-verified hover:bg-elevated"
onclick={() => {
step = "pick";
selected = null;

View File

@ -70,10 +70,10 @@
function statusBadge(c: StoredClaim) {
const v = c.last_verify;
if (!v) return { text: "Not verified", color: "bg-gray-100 text-gray-600" };
if (v.status === "ok") return { text: "✓ Verified", color: "bg-green-100 text-green-800" };
if (v.status === "fail") return { text: "✗ Failed", color: "bg-red-100 text-red-800" };
return { text: "— Skipped", color: "bg-yellow-100 text-yellow-800" };
if (!v) return { text: "Not verified", color: "bg-elevated text-text-secondary" };
if (v.status === "ok") return { text: "✓ Verified", color: "bg-verified/20 text-verified" };
if (v.status === "fail") return { text: "✗ Failed", color: "bg-danger/20 text-danger" };
return { text: "— Skipped", color: "bg-warning/20 text-warning" };
}
function formatChecked(iso: string): string {
@ -88,11 +88,11 @@
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Claims</h1>
<h1 class="text-2xl font-bold text-text">Claims</h1>
<div class="flex gap-2">
{#if claims.length > 0}
<button
class="px-3 py-2 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 disabled:opacity-50"
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated disabled:opacity-50"
onclick={verifyAll}
disabled={verifying.size > 0}
>
@ -101,14 +101,14 @@
{/if}
<a
href="#/claims/add"
class="px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 no-underline"
class="px-3 py-2 text-sm bg-accent text-accent-contrast rounded-md hover:bg-accent-dim no-underline"
>
+ Add claim
</a>
</div>
</div>
<p class="text-sm text-gray-700">
<p class="text-sm text-text-secondary">
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
@ -117,13 +117,13 @@
</p>
{#if loading}
<p class="text-sm text-gray-500">Loading…</p>
<p class="text-sm text-text-muted">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>
<div class="border border-dashed border-border rounded-lg p-8 text-center">
<p class="text-text-muted">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"
class="mt-3 inline-block px-3 py-2 text-sm bg-accent text-accent-contrast rounded-md hover:bg-accent-dim no-underline"
>
Add your first claim
</a>
@ -134,67 +134,67 @@
{@const badge = statusBadge(c)}
{@const isVerifying = verifying.has(c.id)}
{@const isExpanded = expanded.has(c.id)}
<li class="border border-gray-200 rounded-lg p-4 bg-white">
<li class="border border-border rounded-lg p-4 bg-surface">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<p class="font-mono font-semibold text-gray-900 truncate">
<p class="font-mono font-semibold text-text truncate">
{c.envelope.payload.subject}
</p>
<span class={`text-xs px-2 py-0.5 rounded font-medium ${badge.color}`}>
{badge.text}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-text-muted">
Channel: <span class="font-mono">{c.channel}</span> ·
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
</p>
{#if c.last_verify}
<p class="mt-1 text-xs text-gray-700">
<p class="mt-1 text-xs text-text-secondary">
{c.last_verify.summary}
<span class="text-gray-400">· checked {formatChecked(c.last_verify.checked_at)}</span>
<span class="text-text-muted">· checked {formatChecked(c.last_verify.checked_at)}</span>
</p>
{#if c.last_verify.evidence_url || c.last_verify.details}
<button
class="mt-1 text-xs text-gray-500 hover:text-gray-900 underline"
class="mt-1 text-xs text-text-muted hover:text-text underline"
onclick={() => toggleExpand(c.id)}
>
{isExpanded ? "Hide" : "Show"} details
</button>
{#if isExpanded}
<div class="mt-2 text-xs bg-gray-50 border border-gray-200 rounded p-3 space-y-2">
<div class="mt-2 text-xs bg-elevated border border-border rounded p-3 space-y-2">
{#if c.last_verify.evidence_url}
<div>
<span class="text-gray-500">Evidence URL:</span>
<span class="text-text-muted">Evidence URL:</span>
<a
href={c.last_verify.evidence_url}
target="_blank"
rel="noopener noreferrer"
class="font-mono text-blue-700 hover:underline break-all"
class="font-mono text-accent hover:underline break-all"
>
{c.last_verify.evidence_url}
</a>
</div>
{/if}
{#if c.last_verify.details}
<pre class="whitespace-pre-wrap font-mono text-gray-700">{c.last_verify.details}</pre>
<pre class="whitespace-pre-wrap font-mono text-text-secondary">{c.last_verify.details}</pre>
{/if}
</div>
{/if}
{/if}
{:else if c.published_at}
<p class="mt-1 text-xs text-green-700">
<p class="mt-1 text-xs text-verified">
✓ You marked this published at {c.published_at}
</p>
{:else}
<p class="mt-1 text-xs text-amber-700">
<p class="mt-1 text-xs text-warning">
⚠ Not marked as published yet
</p>
{/if}
</div>
<div class="flex flex-col gap-2 shrink-0">
<button
class="text-xs px-3 py-1 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 disabled:opacity-50"
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-elevated disabled:opacity-50"
onclick={() => runVerify(c)}
disabled={isVerifying}
>
@ -202,14 +202,14 @@
</button>
{#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"
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-elevated"
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"
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger"
onclick={() => deleteClaim(c)}
>
Remove

View File

@ -93,27 +93,27 @@
</script>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-gray-900">Create account</h1>
<h1 class="text-2xl font-bold text-text">Create account</h1>
{#if !serverInfo}
<p class="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded p-3">
<p class="text-sm text-warning bg-warning/10 border border-warning/40 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>
<ol class="flex gap-2 text-xs text-text-muted">
<li class={step === "handle" ? "font-semibold text-text" : ""}>1. Handle</li>
<li></li>
<li class={step === "seed" ? "font-semibold text-gray-900" : ""}>2. Back up seed</li>
<li class={step === "seed" ? "font-semibold text-text" : ""}>2. Back up seed</li>
<li></li>
<li class={step === "confirm" || step === "submitting" ? "font-semibold text-gray-900" : ""}>3. Confirm</li>
<li class={step === "confirm" || step === "submitting" ? "font-semibold text-text" : ""}>3. Confirm</li>
<li></li>
<li class={step === "done" ? "font-semibold text-gray-900" : ""}>4. Done</li>
<li class={step === "done" ? "font-semibold text-text" : ""}>4. Done</li>
</ol>
{#if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
<p class="text-sm text-danger bg-danger/10 border border-danger/40 rounded p-3">
{error}
</p>
{/if}
@ -124,48 +124,48 @@
onsubmit={(e) => { e.preventDefault(); goToSeedStep(); }}
>
<div>
<label class="block text-sm font-medium text-gray-700" for="handle">
<label class="block text-sm font-medium text-text-secondary" for="handle">
Handle
</label>
<div class="mt-1 flex items-stretch border border-gray-300 rounded-md overflow-hidden bg-white">
<div class="mt-1 flex items-stretch border border-border rounded-md overflow-hidden bg-surface">
<input
id="handle"
type="text"
bind:value={handle}
placeholder="tudisco"
class="flex-1 px-3 py-2 outline-none text-gray-900"
class="flex-1 px-3 py-2 outline-none text-text"
autocomplete="off"
/>
{#if serverInfo}
<span class="px-3 py-2 text-gray-500 bg-gray-50 border-l border-gray-300">
<span class="px-3 py-2 text-text-muted bg-elevated border-l border-border">
@{serverInfo.server}
</span>
{/if}
</div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-text-muted">
Lowercase letters, digits, <code>-</code>, <code>_</code>. 332 chars.
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="pw">
<label class="block text-sm font-medium text-text-secondary" 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"
class="mt-1 w-full px-3 py-2 border border-border 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"
class="mt-2 w-full px-3 py-2 border border-border rounded-md outline-none"
autocomplete="new-password"
/>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-text-muted">
The seed itself is your real identity. The passphrase only
protects the local copy in this browser.
</p>
@ -173,7 +173,7 @@
<button
type="submit"
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={!serverInfo}
>
Continue
@ -183,19 +183,19 @@
{#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">
<div class="border border-warning/40 bg-warning/10 rounded-lg p-4 space-y-3">
<p class="font-semibold text-warning">⚠️ Back up your seed now</p>
<p class="text-sm text-warning">
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">
<div class="mt-3 p-3 bg-surface border border-warning/40 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"
class="text-xs px-3 py-1 bg-warning/10 text-accent-contrast rounded hover:bg-warning/20"
onclick={() => copyToClipboard(seedHex)}
>
Copy seed
@ -203,7 +203,7 @@
</div>
</div>
<label class="flex items-start gap-2 text-sm text-gray-700">
<label class="flex items-start gap-2 text-sm text-text-secondary">
<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.
@ -211,13 +211,13 @@
<div class="flex gap-2">
<button
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
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"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={!seedAck}
onclick={() => { step = "confirm"; }}
>
@ -229,21 +229,21 @@
{#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>
<p class="text-text-secondary">Ready to register:</p>
<div class="border border-border rounded-lg p-4 bg-elevated space-y-2 text-sm">
<div><span class="text-text-muted">Handle:</span> <span class="font-mono font-semibold">{handle}@{serverInfo?.server}</span></div>
<div><span class="text-text-muted">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"
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
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"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={working}
onclick={submitRegistration}
>
@ -254,20 +254,20 @@
{/if}
{#if step === "submitting"}
<p class="text-gray-700">Submitting registration to {serverInfo?.server}</p>
<p class="text-text-secondary">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">
<div class="border border-verified/40 bg-verified/10 rounded-lg p-6">
<p class="text-lg font-semibold text-verified">✓ Account created</p>
<p class="mt-2 text-sm text-verified">
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")}
class="mt-4 px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim"
onclick={() => push("/welcome")}
>
Go to dashboard
Get started →
</button>
</div>
{/if}

View File

@ -0,0 +1,220 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { lookup, setProofs, ApiError } from "../lib/api.js";
import { session } from "../lib/store.svelte.js";
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
import {
listClaims,
setVerifyResult,
type StoredClaim,
} from "../lib/claims-store.js";
import { verifyClaim } from "../lib/verify.js";
import Avatar from "../lib/Avatar.svelte";
let registryRecord = $state<any | null>(null);
let claims = $state<StoredClaim[]>([]);
let verifyingAll = $state(false);
let copied = $state(false);
const verified = $derived(claims.filter((c) => c.last_verify?.status === "ok"));
const failed = $derived(claims.filter((c) => c.last_verify?.status === "fail"));
const pending = $derived(claims.filter((c) => !c.last_verify));
// Verified badge requires at least this many independently-verified
// proofs. Kept in sync with VERIFY_MIN_PROOFS in Messages.svelte so the
// badge means the same thing on the profile and in chat.
const VERIFY_MIN_PROOFS = 2;
const isVerified = $derived(verified.length >= VERIFY_MIN_PROOFS);
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
claims = await listClaims();
// Publish our verified proof subjects to the server profile so peers
// can discover + independently verify them (drives our badge in their
// chat). Previously this only happened on a manual "reverify all", so
// verified users were invisible to peers until they clicked it.
if (claims.some((c) => c.last_verify?.status === "ok")) {
void publishVerifiedSubjects();
}
try {
registryRecord = await lookup(session.unlocked.handle);
} catch (e) {
if (!(e instanceof ApiError && e.status === 404)) {
console.error(e);
}
}
});
async function reverifyAll() {
verifyingAll = true;
try {
for (const c of claims) {
const result = await verifyClaim($state.snapshot(c) as StoredClaim);
await setVerifyResult(c.id, result);
}
claims = await listClaims();
await publishVerifiedSubjects();
} finally {
verifyingAll = false;
}
}
/**
* Push the subjects of our currently-verified claims to the server
* profile so peers can discover + independently verify them (drives
* their verified badge for us). Best-effort — a failure here doesn't
* affect local verification.
*/
async function publishVerifiedSubjects() {
if (!session.unlocked) return;
const subjects = claims
.filter((c) => c.last_verify?.status === "ok")
.map((c) => c.envelope.payload.subject);
try {
await setProofs(session.unlocked.handle, session.unlocked.seed, subjects);
} catch (e) {
console.error("publishVerifiedSubjects failed", e);
}
}
function channelLabel(ch: string): string {
return (
{
github: "GitHub",
dns: "DNS",
web: "Website",
nostr: "Nostr",
bluesky: "Bluesky",
ap: "ActivityPub",
} as Record<string, string>
)[ch] ?? ch;
}
async function copyKez() {
if (!session.unlocked) return;
await navigator.clipboard.writeText(
`${session.unlocked.handle}@${session.unlocked.server}`,
);
copied = true;
setTimeout(() => (copied = false), 1500);
}
function fingerprint(primary: string): string {
// ed25519:<hex> → grouped short fingerprint for human comparison.
const hex = primary.startsWith("ed25519:")
? primary.slice("ed25519:".length)
: primary;
return (hex.match(/.{1,4}/g) ?? []).slice(0, 8).join(" ");
}
</script>
{#if session.unlocked}
<div class="max-w-2xl mx-auto space-y-6">
<!-- Identity card -->
<section class="bg-surface border border-border rounded-xl p-6">
<div class="flex items-start gap-4">
<Avatar seed={session.unlocked.primary} size={64} ring />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1">
<span class="truncate">{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span></span>
{#if isVerified}<VerifiedBadge size={16} />{/if}
</span>
<button
class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0"
onclick={copyKez}
>
{copied ? "✓ copied" : "copy"}
</button>
</div>
<p class="mt-2 text-xs text-text-muted uppercase tracking-wider">Key fingerprint</p>
<p class="font-mono text-sm text-text-secondary break-all">
{fingerprint(session.unlocked.primary)}<span class="text-text-muted"></span>
</p>
{#if registryRecord}
<p class="mt-2 text-xs text-text-muted">
Registered {new Date(registryRecord.registered_at).toLocaleDateString()}
</p>
{/if}
</div>
</div>
</section>
<!-- Proofs -->
<section class="bg-surface border border-border rounded-xl p-6">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Proofs</h2>
<p class="text-sm text-text-secondary mt-1">
Other accounts cryptographically linked to your KEZ. Anyone can
verify these without trusting the server.
</p>
</div>
<div class="flex flex-col gap-2 shrink-0">
{#if claims.length > 0}
<button
class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
onclick={reverifyAll}
disabled={verifyingAll}
>
{verifyingAll ? "Verifying…" : "Re-verify"}
</button>
{/if}
<a
href="#/claims/add"
class="px-3 py-1.5 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim no-underline text-center"
>
+ Add proof
</a>
</div>
</div>
{#if claims.length === 0}
<div class="border border-dashed border-border rounded-lg p-6 text-center">
<p class="text-sm text-text-muted">No proofs yet.</p>
<p class="text-xs text-text-muted mt-1">
Link GitHub, your domain, nostr, Bluesky — prove the accounts you control.
</p>
</div>
{:else}
<ul class="space-y-2">
{#each verified as c (c.id)}
<li class="flex items-center justify-between gap-3 p-3 bg-elevated border border-border rounded-lg">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-sm bg-elevated border border-border text-text-secondary">
{channelLabel(c.channel)}
</span>
<span class="font-mono text-sm text-text truncate">{c.envelope.payload.subject}</span>
<span class="text-xs font-mono font-semibold px-1.5 py-0.5 rounded-sm" style="background:#4ade8014;border:1px solid #4ade8040;color:var(--color-verified);">✓ verified</span>
</div>
</div>
{#if c.last_verify?.evidence_url}
<a href={c.last_verify.evidence_url} target="_blank" rel="noopener noreferrer" class="text-xs text-accent hover:text-accent-dim underline shrink-0">proof ↗</a>
{/if}
</li>
{/each}
{#each failed as c (c.id)}
<li class="flex items-center gap-2 p-3 bg-elevated border border-border rounded-lg">
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-sm bg-elevated border border-border text-text-secondary">{channelLabel(c.channel)}</span>
<span class="font-mono text-sm text-text truncate flex-1">{c.envelope.payload.subject}</span>
<span class="text-xs font-mono font-semibold text-danger shrink-0">✗ failed</span>
</li>
{/each}
{#each pending as c (c.id)}
<li class="flex items-center gap-2 p-3 bg-elevated border border-border rounded-lg">
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-sm bg-elevated border border-border text-text-secondary">{channelLabel(c.channel)}</span>
<span class="font-mono text-sm text-text truncate flex-1">{c.envelope.payload.subject}</span>
<span class="text-xs font-mono text-warning shrink-0">pending</span>
</li>
{/each}
</ul>
<a href="#/claims" class="mt-3 inline-block text-xs text-text-secondary hover:text-text underline">Manage proofs →</a>
{/if}
</section>
</div>
{/if}

View File

@ -24,27 +24,27 @@
<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">
<h1 class="text-3xl font-bold text-text">Welcome to kez-chat</h1>
<p class="mt-2 text-text-secondary">
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>
<p class="text-text-muted 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">
<section class="border border-border rounded-lg p-6 bg-surface">
<p class="text-sm text-text-muted mb-1">Existing account on this device:</p>
<p class="text-xl font-mono font-semibold text-text">
{existing.handle}@{existing.server}
</p>
<p class="mt-1 text-xs font-mono text-gray-500 break-all">
<p class="mt-1 text-xs font-mono text-text-muted 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"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim"
onclick={() => push("/unlock")}
>
Unlock
@ -54,21 +54,21 @@
{: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"
class="text-left border border-border rounded-lg p-6 bg-surface hover:border-accent-dim 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">
<h2 class="text-lg font-semibold text-text">Create a new account</h2>
<p class="mt-1 text-sm text-text-secondary">
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"
class="text-left border border-border rounded-lg p-6 bg-surface hover:border-accent-dim 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">
<h2 class="text-lg font-semibold text-text">Restore from seed</h2>
<p class="mt-1 text-sm text-text-secondary">
Have a 64-char hex seed from another device? Paste it to recover
your identity.
</p>
@ -76,11 +76,11 @@
</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>
<section class="text-sm text-text-muted border-t border-border pt-6">
<p class="font-medium text-text-secondary">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>),
Account creation makes a handle (<code class="bg-elevated 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.

View File

@ -2,14 +2,19 @@
import { onMount, onDestroy } from "svelte";
import { push } from "svelte-spa-router";
import { session } from "../lib/store.svelte.js";
import { sendMessage } from "../lib/messages.js";
import { lookup, ApiError } from "../lib/api.js";
import { sendMessage } from "../lib/transport.js";
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
import { inboxService } from "../lib/inbox-service.svelte.js";
import { verifySubject } from "../lib/verify.js";
import { onboarding } from "../lib/onboarding.svelte.js";
import EmojiButton from "../lib/EmojiButton.svelte";
import Avatar from "../lib/Avatar.svelte";
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
import {
appendOutbound,
ensureConversation,
listConversations,
setVerified,
type Conversation,
} from "../lib/conversations-store.js";
import type { Identity } from "../lib/kez.js";
@ -135,6 +140,10 @@
return;
}
await refresh();
// Kick off verification for every existing conversation (24h cache per
// peer), so the verified badge shows in the list without opening each
// thread one by one.
void verifyPeers(conversations);
// Subscribe to the always-on inbox service — re-render whenever a
// new message lands. The service is already running (it started on
// session unlock in store.svelte.ts) regardless of which route the
@ -152,6 +161,49 @@
conversations = await listConversations();
}
// Verify the active peer whenever a conversation is opened (covers
// conversations started/arrived this session). The 24h per-peer cache
// makes this a no-op on repeat opens, so the post-verify refresh can't loop.
$effect(() => {
const conv = activeConv;
if (conv) void verifyPeers([conv]);
});
const VERIFY_CACHE_MS = 24 * 60 * 60 * 1000;
/** A peer earns the verified badge once at least this many of their
* published proofs independently check out. Kept in sync with the
* profile rule in Identity.svelte. */
const VERIFY_MIN_PROOFS = 2;
/**
* Verify a batch of peers' published proofs (24h cache per peer). For
* each peer we fetch their claimed subjects from the server, run the
* real channel verifiers, count how many pass, and set the verified
* badge when ≥ VERIFY_MIN_PROOFS. Refreshes the list once at the end if
* anything changed. Runs in the background; never blocks the UI.
*/
async function verifyPeers(convs: Conversation[]) {
let changed = false;
for (const conv of convs) {
const fresh =
conv.verified_checked_at &&
Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS;
if (fresh) continue;
try {
const record = await lookupByPrimary(conv.peer_primary);
let verifiedCount = 0;
for (const subject of record.proofs ?? []) {
if (await verifySubject(subject, conv.peer_primary)) verifiedCount++;
}
await setVerified(conv.peer_primary, verifiedCount >= VERIFY_MIN_PROOFS);
changed = true;
} catch {
// Peer not resolvable / offline channels — leave badge as-is.
}
}
if (changed) await refresh();
}
/** "Start chat with handle" — resolve, ensure conversation, open it. */
async function startConversation() {
if (!session.unlocked || !newPeerInput.trim()) return;
@ -245,210 +297,184 @@
}
</script>
<div class="flex h-[calc(100vh-8rem)] gap-4">
<!-- Sidebar -->
<aside class="w-80 shrink-0 border border-gray-200 rounded-lg bg-white flex flex-col">
<!-- Your KEZ (so users can share it to start chats) -->
<div class="p-3 border-b border-gray-200 bg-gray-50">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Your KEZ</p>
{#if session.unlocked}
<div class="flex h-full bg-bg">
<!-- Sidebar (conversation list). On mobile it's full-width and hides
when a conversation is open. -->
<aside class={`${activeConv ? "hidden" : "flex"} sm:flex w-full sm:w-80 shrink-0 border-r border-border bg-surface flex-col`}>
<!-- Header: your KEZ + status -->
<div class="p-3 border-b border-border">
<div class="flex items-center justify-between gap-2">
<code class="font-mono text-sm text-gray-900 truncate">
{session.unlocked.handle}@{session.unlocked.server}
</code>
<button
class="text-xs px-2 py-0.5 border border-gray-300 rounded text-gray-700 hover:bg-white shrink-0"
onclick={copyMyKez}
title="Copy your KEZ — share it with someone so they can message you"
>
{copied ? "✓" : "copy"}
</button>
<h1 class="text-sm font-semibold text-text uppercase tracking-wider">Chats</h1>
<span class="text-xs">
{#if inboxService.status === "live"}
<span class="text-accent">● live</span>
{:else if inboxService.status === "reconnecting"}
<span class="text-warning">● reconnecting</span>
{:else if inboxService.status === "connecting"}
<span class="text-text-muted">○ connecting</span>
{:else}
<span class="text-text-muted">○ off</span>
{/if}
</span>
</div>
{#if session.unlocked}
<button
class="mt-2 w-full flex items-center justify-between gap-2 px-2 py-1.5 rounded-md bg-elevated border border-border hover:border-accent-dim transition-colors"
onclick={copyMyKez}
title="Copy your KEZ to share"
>
<code class="font-mono text-xs text-accent truncate">{session.unlocked.handle}@{session.unlocked.server}</code>
<span class="text-[10px] text-text-muted shrink-0">{copied ? "✓ copied" : "copy"}</span>
</button>
{/if}
</div>
<!-- Start a new conversation -->
<div class="p-3 border-b border-gray-200">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
Start a chat
</p>
<form
class="flex gap-2"
onsubmit={(e) => {
e.preventDefault();
startConversation();
}}
>
<!-- Finish-setup nudge (until onboarding is completed/skipped) -->
{#if !onboarding.onboarded}
<a href="#/welcome" class="flex items-center gap-2 px-3 py-2 border-b border-border bg-accent/10 text-accent hover:bg-accent/20 no-underline text-sm">
<span></span><span class="flex-1">Finish setting up your account</span><span></span>
</a>
{/if}
<!-- Start a chat -->
<div class="p-3 border-b border-border">
<form class="flex gap-2" onsubmit={(e) => { e.preventDefault(); startConversation(); }}>
<input
type="text"
bind:value={newPeerInput}
placeholder="alice or alice@kez.lat"
class="flex-1 min-w-0 px-2 py-1 text-sm border border-gray-300 rounded font-mono"
placeholder="handle@kez.lat"
class="flex-1 min-w-0 px-3 py-2 text-sm bg-elevated border border-border rounded-md font-mono text-text placeholder:text-text-muted focus:border-accent focus:outline-none"
autocomplete="off"
disabled={resolving}
/>
<button
type="submit"
class="px-3 py-1 text-sm bg-gray-900 text-white rounded hover:bg-gray-700 disabled:opacity-50"
class="px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={!newPeerInput.trim() || resolving}
>
{resolving ? "…" : "+"}
</button>
</form>
{#if resolveError}
<p class="mt-2 text-xs text-red-700">{resolveError}</p>
{:else}
<p class="mt-2 text-xs text-gray-500">
Enter the KEZ of someone you want to message. Ask them to share it
with you (the button above does the same for yours).
</p>
<p class="mt-2 text-xs text-danger">{resolveError}</p>
{/if}
</div>
<!-- Conversation list -->
<div class="flex-1 overflow-y-auto">
{#if conversations.length === 0}
<p class="p-4 text-sm text-gray-500 italic">
No conversations yet.
</p>
<div class="p-6 text-center">
<p class="text-sm text-text-muted">No conversations yet.</p>
<p class="text-xs text-text-muted mt-1">Enter someone's KEZ above to start.</p>
</div>
{:else}
<ul>
{#each conversations as c (c.peer_primary)}
{@const last = c.messages[c.messages.length - 1]}
{@const active = activePrimary === c.peer_primary}
<li>
<button
class={`w-full text-left p-3 border-b border-gray-100 hover:bg-gray-50 ${activePrimary === c.peer_primary ? "bg-gray-100" : ""}`}
class={`relative w-full text-left flex items-center gap-3 px-3 py-3 border-b border-border/60 transition-colors ${active ? "bg-elevated" : "hover:bg-elevated/60"}`}
onclick={() => (activePrimary = c.peer_primary)}
>
<p class="font-mono text-sm font-semibold text-gray-900 truncate">
{displayName(c)}
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
<Avatar seed={c.peer_primary} size={40} />
<div class="min-w-0 flex-1">
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
<span class="truncate">{displayName(c)}</span>
{#if c.verified}<VerifiedBadge size={14} />{/if}
</p>
{#if last}
<p class="text-xs text-gray-500 truncate">
{last.direction === "out" ? "→ " : "← "}{last.body}
<p class="text-xs text-text-secondary truncate">
{last.direction === "out" ? "→ " : ""}{last.body}
</p>
<p class="text-xs text-gray-400 mt-0.5">{formatTime(last.ts)}</p>
{:else}
<p class="text-xs text-gray-400 italic">No messages yet</p>
<p class="text-xs text-text-muted italic">No messages yet</p>
{/if}
</div>
{#if last}<span class="text-[10px] text-text-muted shrink-0 self-start mt-0.5">{formatTime(last.ts)}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Footer status — reads from the global inbox service so it
reflects the SAME connection that's running everywhere else. -->
<div class="p-2 border-t border-gray-200 text-xs text-gray-500 space-y-0.5">
<p>
{#if inboxService.status === "live"}
<span class="text-green-700">● live</span>
{:else if inboxService.status === "reconnecting"}
<span class="text-amber-700">● reconnecting…</span>
{:else if inboxService.status === "connecting"}
<span class="text-gray-500">○ connecting…</span>
{:else}
<span class="text-gray-400">○ off</span>
{/if}
</p>
{#if inboxService.lastError}
<p class="text-red-700">{inboxService.lastError}</p>
{/if}
</div>
</aside>
<!-- Main: thread or empty state -->
<main class="flex-1 min-w-0 border border-gray-200 rounded-lg bg-white flex flex-col">
<main class={`${activeConv ? "flex" : "hidden"} sm:flex flex-1 min-w-0 bg-bg flex-col`}>
{#if !activeConv}
<div class="flex-1 flex items-center justify-center p-8">
<div class="max-w-md text-center space-y-4">
<div class="text-4xl">🔒</div>
<h2 class="text-lg font-semibold text-gray-900">
End-to-end encrypted chat
</h2>
<p class="text-sm text-gray-700">
Messages on kez-chat are encrypted between you and the recipient.
Even kez.lat (the server) can't read them — it just relays
opaque ciphertext.
<div class="text-5xl">🔒</div>
<h2 class="text-lg font-semibold text-text">End-to-end encrypted</h2>
<p class="text-sm text-text-secondary">
Messages are encrypted between you and the recipient. The server
only relays opaque ciphertext — it can't read anything.
</p>
<p class="text-sm text-gray-700">
To start, ask someone for their <strong>KEZ</strong> — their
<code class="bg-gray-100 px-1 rounded text-xs">handle@server</code>
(like an email address). Enter it on the left under
<strong>Start a chat</strong>. Or share yours with them using the
copy button.
</p>
<p class="text-xs text-gray-500 pt-2 border-t border-gray-200">
Don't know anyone yet? Open kez.lat in a second browser window,
create a different account, and message yourself between the two.
<p class="text-sm text-text-secondary">
Ask someone for their <span class="font-mono text-accent">handle@server</span>
and enter it on the left to start. Or share yours with the copy button.
</p>
</div>
</div>
{:else}
<div class="p-3 border-b border-gray-200">
<p class="font-mono text-sm font-semibold text-gray-900">
{displayName(activeConv)}
</p>
<p class="text-xs text-gray-500 break-all font-mono">
{activeConv.peer_primary}
<!-- Thread header -->
<div class="flex items-center gap-3 p-3 border-b border-border bg-surface">
<button class="sm:hidden text-text-secondary hover:text-text -ml-1" onclick={() => (activePrimary = null)} aria-label="Back">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</button>
<Avatar seed={activeConv.peer_primary} size={36} />
<div class="min-w-0">
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
<span class="truncate">{displayName(activeConv)}</span>
{#if activeConv.verified}<VerifiedBadge size={15} />{/if}
</p>
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-2" bind:this={scrollEl}>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-1.5" bind:this={scrollEl}>
{#each activeConv.messages as m (m.seq + ":" + m.direction)}
{@const boost = emojiOnlyBoost(m.body)}
<div class={`max-w-md ${m.direction === "out" ? "ml-auto" : ""}`}>
{@const out = m.direction === "out"}
<div class={`max-w-[78%] ${out ? "ml-auto" : ""}`}>
{#if boost}
<!-- Emoji-only message: drop the bubble chrome, render big. -->
<div
class={`whitespace-pre-wrap break-words leading-none ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${m.direction === "out" ? "text-right" : ""}`}
>
{m.body}
</div>
<div class={`whitespace-pre-wrap break-words leading-none py-1 ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${out ? "text-right" : ""}`}>{m.body}</div>
{:else}
<div
class={`px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words ${m.direction === "out" ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-900"}`}
>
{m.body}
</div>
class={`px-3 py-2 text-sm whitespace-pre-wrap break-words ${out ? "bg-accent text-accent-contrast rounded-lg rounded-br-sm" : "bg-bubble-recv text-text border border-border rounded-lg rounded-bl-sm"}`}
>{m.body}</div>
{/if}
<p class={`mt-1 text-xs text-gray-400 ${m.direction === "out" ? "text-right" : ""}`}>
{formatTime(m.ts)}
</p>
<p class={`mt-0.5 text-[10px] text-text-muted ${out ? "text-right" : ""}`}>{formatTime(m.ts)}</p>
</div>
{/each}
{#if activeConv.messages.length === 0}
<p class="text-gray-400 text-sm italic text-center mt-8">
No messages yet. Say hi — it's encrypted before it leaves your
browser.
<p class="text-text-muted text-sm italic text-center mt-8">
No messages yet. Say hi — it's encrypted before it leaves your browser.
</p>
{/if}
</div>
<form
class="p-3 border-t border-gray-200 flex gap-2 items-center"
onsubmit={(e) => {
e.preventDefault();
send();
}}
>
<!-- Compose -->
<form class="p-3 border-t border-border bg-surface flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
<EmojiButton onpick={insertEmoji} />
<input
type="text"
bind:value={composeText}
bind:this={composeEl}
placeholder="Type a message…"
class="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 rounded"
class="flex-1 min-w-0 px-3 py-2 text-sm bg-elevated border border-border rounded-md text-text placeholder:text-text-muted focus:border-accent focus:outline-none"
autocomplete="off"
disabled={composing}
/>
<button
type="submit"
class="px-4 py-2 text-sm bg-gray-900 text-white rounded hover:bg-gray-700 disabled:opacity-50"
class="px-4 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={composing || !composeText.trim()}
>
{composing ? "Sending…" : "Send"}
{composing ? "…" : "Send"}
</button>
</form>
{/if}

View File

@ -62,9 +62,9 @@
</script>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-gray-900">Restore from seed</h1>
<h1 class="text-2xl font-bold text-text">Restore from seed</h1>
<p class="text-sm text-gray-700 bg-amber-50 border border-amber-200 rounded p-3">
<p class="text-sm text-text-secondary bg-warning/10 border border-warning/40 rounded p-3">
<strong>v0.1 limitation:</strong> the seed alone doesn't tell us which
handle to restore. For now this flow doesn't work end-to-end — we'll
add <code>GET /v1/by-primary/&lt;id&gt;</code> on the server in v0.2
@ -76,37 +76,37 @@
onsubmit={(e) => { e.preventDefault(); submit(); }}
>
<div>
<label class="block text-sm font-medium text-gray-700" for="seed">
<label class="block text-sm font-medium text-text-secondary" 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"
class="mt-1 w-full px-3 py-2 border border-border rounded-md font-mono text-sm"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="pw">
<label class="block text-sm font-medium text-text-secondary" 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"
class="mt-1 w-full px-3 py-2 border border-border 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"
class="mt-2 w-full px-3 py-2 border border-border rounded-md"
/>
</div>
{#if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
<p class="text-sm text-danger bg-danger/10 border border-danger/40 rounded p-3">
{error}
</p>
{/if}
@ -114,14 +114,14 @@
<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"
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
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"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={working}
>
{working ? "Restoring…" : "Restore"}

View File

@ -0,0 +1,223 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import { session } from "../lib/store.svelte.js";
import {
hasStoredBiometric,
getStoredBiometricMeta,
setupBiometricUnlock,
removeStoredBiometric,
isPlatformAuthenticatorAvailable,
} from "../lib/webauthn.js";
import {
notificationsSupported,
notificationsPermission,
requestNotificationsPermission,
fireTestNotification,
} from "../lib/inbox-service.svelte.js";
import { theme, type ThemeChoice } from "../lib/theme.svelte.js";
import { onboarding } from "../lib/onboarding.svelte.js";
function openGettingStarted() {
onboarding.reopen();
push("/welcome");
}
const themeOptions: { value: ThemeChoice; label: string }[] = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
];
let biometricSupported = $state(false);
let biometricEnrolled = $state(false);
let biometricLabel = $state<string | null>(null);
let biometricCreatedAt = $state<string | null>(null);
let biometricBusy = $state(false);
let biometricError = $state<string | null>(null);
let notifSupported = $state(false);
let notifPerm = $state<NotificationPermission | "unsupported">("default");
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
await refreshBiometricStatus();
notifSupported = notificationsSupported();
notifPerm = notificationsPermission();
});
async function refreshBiometricStatus() {
biometricSupported = await isPlatformAuthenticatorAvailable();
biometricEnrolled = await hasStoredBiometric();
const m = await getStoredBiometricMeta();
biometricLabel = m?.label ?? null;
biometricCreatedAt = m?.created_at ?? null;
}
async function enableBiometric() {
if (!session.unlocked) return;
biometricBusy = true;
biometricError = null;
try {
await setupBiometricUnlock({
handle: session.unlocked.handle,
primary: session.unlocked.primary,
seed: session.unlocked.seed,
});
await refreshBiometricStatus();
} catch (e) {
biometricError = (e as Error).message;
} finally {
biometricBusy = false;
}
}
async function disableBiometric() {
if (!confirm("Disable biometric unlock for this device? You'll need your passphrase next time.")) return;
await removeStoredBiometric();
await refreshBiometricStatus();
}
async function enableNotifications() {
notifPerm = await requestNotificationsPermission();
}
function sendTestNotification() {
testNotifResult = fireTestNotification();
setTimeout(() => (testNotifResult = null), 5_000);
}
function showSeed() {
if (!session.unlocked) return;
const hex = bytesToHex(session.unlocked.seed);
alert(`Your recovery seed (KEEP SECRET):\n\n${hex}\n\nWrite this down somewhere safe. It's the ONLY way to recover this account.`);
}
function lock() {
session.lock();
push("/unlock");
}
const buildSha = __BUILD_SHA__;
</script>
{#if session.unlocked}
<div class="max-w-2xl mx-auto space-y-6">
<!-- Appearance -->
<section class="bg-surface border border-border rounded-xl p-6">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-3">Appearance</h2>
<div class="inline-flex rounded-md border border-border overflow-hidden">
{#each themeOptions as opt, i}
<button
class={`px-4 py-1.5 text-sm transition-colors ${i > 0 ? "border-l border-border" : ""} ${theme.choice === opt.value ? "bg-accent text-accent-contrast font-semibold" : "text-text-secondary hover:bg-elevated hover:text-text"}`}
onclick={() => theme.set(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
<p class="mt-2 text-xs text-text-muted">
{#if theme.choice === "system"}
Following your device ({theme.effective}). Change your OS setting and it tracks automatically.
{:else}
Always {theme.choice}.
{/if}
</p>
</section>
<!-- Security -->
<section class="bg-surface border border-border rounded-xl p-6 space-y-5">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Security</h2>
<!-- App lock / biometric -->
<div>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-text">App lock</p>
<p class="text-sm text-text-secondary mt-0.5">
Unlock with Touch ID / Face ID / Windows Hello instead of typing
your passphrase. The passphrase still works as a backup.
</p>
</div>
{#if biometricEnrolled}
<button class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:text-danger hover:border-danger shrink-0" onclick={disableBiometric}>Disable</button>
{/if}
</div>
{#if !biometricSupported && !biometricEnrolled}
<p class="mt-2 text-sm text-text-muted italic">No platform authenticator detected in this browser.</p>
{:else if biometricEnrolled}
<p class="mt-2 text-sm" style="color:var(--color-verified)">
{biometricLabel} enrolled{#if biometricCreatedAt} · {new Date(biometricCreatedAt).toLocaleDateString()}{/if}
</p>
{:else}
<button class="mt-2 px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50" onclick={enableBiometric} disabled={biometricBusy}>
{biometricBusy ? "Setting up…" : "Enable app lock"}
</button>
{#if biometricError}<p class="mt-2 text-xs text-danger">{biometricError}</p>{/if}
{/if}
</div>
<div class="border-t border-border"></div>
<!-- Recovery phrase -->
<div>
<p class="text-sm font-medium text-text">Recovery seed</p>
<p class="text-sm text-text-secondary mt-0.5">
The only thing that can recover this account. Write it down offline.
</p>
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={showSeed}>
Reveal seed
</button>
</div>
</section>
<!-- Notifications -->
<section class="bg-surface border border-border rounded-xl p-6">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-2">Notifications</h2>
<p class="text-sm text-text-secondary">
System notification when a new message arrives while you're in another
tab or app. Silent while you're looking at this one.
</p>
{#if !notifSupported}
<p class="mt-3 text-sm text-text-muted italic">Not supported in this browser.</p>
{:else if notifPerm === "granted"}
<div class="mt-3 space-y-2">
<p class="text-sm" style="color:var(--color-verified)">✓ Enabled.</p>
<button class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={sendTestNotification}>Send test notification</button>
{#if testNotifResult?.ok === true}
<p class="text-xs text-text-muted">✓ Sent. Nothing popped? Check your OS notification settings for this browser.</p>
{:else if testNotifResult?.ok === false}
<p class="text-xs text-danger">{testNotifResult.reason}</p>
{/if}
</div>
{:else if notifPerm === "denied"}
<p class="mt-3 text-sm text-warning">Blocked. Re-enable in your browser's site settings.</p>
{:else}
<button class="mt-3 px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim" onclick={enableNotifications}>Enable notifications</button>
{/if}
</section>
<!-- Account / About -->
<section class="bg-surface border border-border rounded-xl p-6 space-y-4">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2>
<button class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={openGettingStarted}>
Getting started
</button>
<button class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:text-danger hover:border-danger" onclick={lock}>
Lock
</button>
<div class="border-t border-border"></div>
<p class="text-xs text-text-muted">
kez-chat ·
<a href={`https://git.ptud.biz/DukeInc/Kez/commit/${buildSha}`} target="_blank" rel="noopener" class="font-mono text-text-secondary hover:text-accent">{buildSha}</a>
·
<a href="https://git.ptud.biz/DukeInc/Kez" target="_blank" rel="noopener" class="text-text-secondary hover:text-accent">source</a>
</p>
</section>
</div>
{/if}

View File

@ -47,7 +47,7 @@
const id = await unlockIdentity(passphrase);
session.setUnlocked(id);
passphrase = "";
push("/dashboard");
push("/chats");
} catch (e) {
error = (e as Error).message;
} finally {
@ -62,7 +62,7 @@
const seed = await unlockWithBiometric();
const id = await unlockWithSeed(seed);
session.setUnlocked(id);
push("/dashboard");
push("/chats");
} catch (e) {
error = (e as Error).message;
// If biometric fails (user cancelled, sensor errored), fall back
@ -82,10 +82,10 @@
</script>
<div class="space-y-6 max-w-md">
<h1 class="text-2xl font-bold text-gray-900">Unlock</h1>
<h1 class="text-2xl font-bold text-text">Unlock</h1>
{#if meta}
<p class="text-sm text-gray-600">
<p class="text-sm text-text-secondary">
Unlocking <span class="font-mono font-semibold">{meta.handle}@{meta.server}</span>
</p>
@ -93,7 +93,7 @@
<div class="space-y-3">
<button
type="button"
class="w-full px-4 py-3 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50 flex items-center justify-center gap-2"
class="w-full px-4 py-3 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50 flex items-center justify-center gap-2"
onclick={submitBiometric}
disabled={working}
>
@ -103,7 +103,7 @@
{#if !showPassphrase}
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-900 underline"
class="text-xs text-text-muted hover:text-text underline"
onclick={() => (showPassphrase = true)}
>
Use passphrase instead
@ -122,31 +122,31 @@
bind:value={passphrase}
placeholder="passphrase"
autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
class="w-full px-3 py-2 border border-border rounded-md"
/>
{#if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
<p class="text-sm text-danger bg-danger/10 border border-danger/40 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"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={working || passphrase.length === 0}
>
{working ? "Unlocking…" : "Unlock"}
</button>
</form>
{:else if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
<p class="text-sm text-danger bg-danger/10 border border-danger/40 rounded p-3">
{error}
</p>
{/if}
<button
class="text-xs text-gray-500 hover:text-red-700 underline"
class="text-xs text-text-muted hover:text-danger underline"
onclick={forget}
>
Forget this account on this device

View File

@ -0,0 +1,201 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import { session } from "../lib/store.svelte.js";
import { onboarding } from "../lib/onboarding.svelte.js";
import { listClaims } from "../lib/claims-store.js";
import {
hasStoredBiometric,
setupBiometricUnlock,
isPlatformAuthenticatorAvailable,
} from "../lib/webauthn.js";
import {
notificationsSupported,
notificationsPermission,
requestNotificationsPermission,
} from "../lib/inbox-service.svelte.js";
import Avatar from "../lib/Avatar.svelte";
import Wordmark from "../lib/Wordmark.svelte";
let hasVerifiedProof = $state(false);
let biometricEnrolled = $state(false);
let biometricAvailable = $state(false);
let notifPerm = $state<NotificationPermission | "unsupported">("default");
let seedRevealed = $state(false);
let seedHex = $state("");
let seedCopied = $state(false);
let busy = $state(false);
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
const claims = await listClaims();
hasVerifiedProof = claims.some((c) => c.last_verify?.status === "ok");
biometricEnrolled = await hasStoredBiometric();
biometricAvailable = await isPlatformAuthenticatorAvailable();
notifPerm = notificationsSupported() ? notificationsPermission() : "unsupported";
});
function revealSeed() {
if (!session.unlocked) return;
seedHex = bytesToHex(session.unlocked.seed);
seedRevealed = true;
}
async function copySeed() {
await navigator.clipboard.writeText(seedHex);
seedCopied = true;
setTimeout(() => (seedCopied = false), 1500);
}
async function enableBiometric() {
if (!session.unlocked) return;
busy = true;
try {
await setupBiometricUnlock({
handle: session.unlocked.handle,
primary: session.unlocked.primary,
seed: session.unlocked.seed,
});
biometricEnrolled = true;
} catch (e) {
alert(`Couldn't enable app lock: ${(e as Error).message}`);
} finally {
busy = false;
}
}
async function enableNotifications() {
notifPerm = await requestNotificationsPermission();
}
function enterApp() {
onboarding.finish();
push("/chats");
}
// Progress = required steps done (seed + proof). Optional ones don't gate.
const done = $derived(
[onboarding.seedAcked, hasVerifiedProof].filter(Boolean).length,
);
const total = 2;
</script>
{#if session.unlocked}
<div class="max-w-xl mx-auto py-6 space-y-6">
<div class="text-center space-y-2">
<div class="flex justify-center"><Wordmark size={28} /></div>
<h1 class="text-xl font-semibold text-text">Welcome — let's get you set up</h1>
<p class="text-sm text-text-secondary">
A couple of quick steps. You can skip and come back anytime from Settings.
</p>
<div class="flex items-center justify-center gap-3 pt-1">
<Avatar seed={session.unlocked.primary} size={44} ring />
<span class="font-mono text-sm text-accent">{session.unlocked.handle}@{session.unlocked.server}</span>
</div>
<p class="text-xs text-text-muted">{done} of {total} essentials done</p>
</div>
<ol class="space-y-3">
<!-- 1. Account created (auto) -->
<li class="flex items-start gap-3 bg-surface border border-border rounded-xl p-4">
<span class="shrink-0 mt-0.5" style="color:var(--color-verified)"></span>
<div class="min-w-0">
<p class="text-sm font-semibold text-text">Account created</p>
<p class="text-xs text-text-secondary">Your identity is an Ed25519 keypair — no email, no password reset.</p>
</div>
</li>
<!-- 2. Back up seed (critical, skippable) -->
<li class={`bg-surface border rounded-xl p-4 ${onboarding.seedAcked ? "border-border" : "border-warning/50"}`}>
<div class="flex items-start gap-3">
<span class="shrink-0 mt-0.5">{onboarding.seedAcked ? "✓" : "🔑"}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Back up your recovery seed</p>
<p class="text-xs text-text-secondary">
This 32-byte seed is the <strong>only</strong> way to recover your
account. Lose it and it's gone forever — there's no reset. Write it
down offline.
</p>
{#if !seedRevealed && !onboarding.seedAcked}
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={revealSeed}>Reveal seed</button>
{/if}
{#if seedRevealed}
<div class="mt-2 p-3 bg-elevated border border-border rounded-md">
<p class="font-mono text-xs text-text break-all select-all">{seedHex}</p>
<div class="mt-2 flex gap-2">
<button class="px-2.5 py-1 text-xs border border-border rounded text-text-secondary hover:bg-surface" onclick={copySeed}>{seedCopied ? "✓ copied" : "Copy"}</button>
{#if !onboarding.seedAcked}
<button class="px-2.5 py-1 text-xs bg-accent text-accent-contrast font-semibold rounded" onclick={() => onboarding.ackSeed()}>I've saved it safely</button>
{/if}
</div>
</div>
{/if}
{#if onboarding.seedAcked}
<p class="mt-1 text-xs" style="color:var(--color-verified)">✓ Backed up</p>
{/if}
</div>
</div>
</li>
<!-- 3. Add first proof -->
<li class="flex items-start gap-3 bg-surface border border-border rounded-xl p-4">
<span class="shrink-0 mt-0.5">{hasVerifiedProof ? "✓" : "✦"}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Add your first proof</p>
<p class="text-xs text-text-secondary">
Link a GitHub, a domain, or your nostr key to earn your verified
badge — so people know it's really you.
</p>
{#if hasVerifiedProof}
<p class="mt-1 text-xs" style="color:var(--color-verified)">✓ You have a verified proof</p>
{:else}
<a href="#/claims/add" class="mt-2 inline-block px-3 py-1.5 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim no-underline">Add a proof</a>
{/if}
</div>
</li>
<!-- 4. App lock (optional) -->
{#if biometricAvailable || biometricEnrolled}
<li class="flex items-start gap-3 bg-surface border border-border rounded-xl p-4">
<span class="shrink-0 mt-0.5">{biometricEnrolled ? "✓" : "🔓"}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Enable app lock <span class="text-text-muted font-normal">· optional</span></p>
<p class="text-xs text-text-secondary">Unlock with Touch ID / Face ID / Windows Hello instead of your passphrase.</p>
{#if biometricEnrolled}
<p class="mt-1 text-xs" style="color:var(--color-verified)">✓ Enabled</p>
{:else}
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50" onclick={enableBiometric} disabled={busy}>{busy ? "…" : "Enable"}</button>
{/if}
</div>
</li>
{/if}
<!-- 5. Notifications (optional) -->
{#if notifPerm !== "unsupported"}
<li class="flex items-start gap-3 bg-surface border border-border rounded-xl p-4">
<span class="shrink-0 mt-0.5">{notifPerm === "granted" ? "✓" : "🔔"}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Turn on notifications <span class="text-text-muted font-normal">· optional</span></p>
<p class="text-xs text-text-secondary">Get pinged when a message arrives while you're away.</p>
{#if notifPerm === "granted"}
<p class="mt-1 text-xs" style="color:var(--color-verified)">✓ Enabled</p>
{:else if notifPerm === "denied"}
<p class="mt-1 text-xs text-warning">Blocked — re-enable in site settings.</p>
{:else}
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={enableNotifications}>Enable</button>
{/if}
</div>
</li>
{/if}
</ol>
<div class="flex items-center justify-between pt-2">
<button class="text-sm text-text-muted hover:text-text underline" onclick={enterApp}>Skip for now</button>
<button class="px-5 py-2.5 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim" onclick={enterApp}>Enter kez-chat →</button>
</div>
</div>
{/if}

View File

@ -50,8 +50,8 @@ export default defineConfig({
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#111827",
theme_color: "#111827",
background_color: "#0b0c0e",
theme_color: "#0b0c0e",
categories: ["social", "communication"],
icons: [
{ src: "pwa-64x64.png", sizes: "64x64", type: "image/png" },

View File

@ -17,6 +17,12 @@ nodejs/
└── README.md (this file)
```
> **New to KEZ?** Read [**`TUTORIAL.md`**](TUTORIAL.md) — a friendly
> step-by-step walkthrough that takes you from "I have a nostr `nsec`"
> to "I have a verified, published sigchain." It assumes nothing.
>
> This README is the reference; the tutorial is the on-ramp.
## Requirements
- Node.js 22+ (for the built-in WebSocket the nostr channel uses)

632
nodejs/TUTORIAL.md Normal file
View File

@ -0,0 +1,632 @@
# Tutorial — your first KEZ identity, end to end (Node.js)
This is a hands-on walkthrough. By the end you'll have:
- ✅ A KEZ identity tied to a key you already trust (your existing nostr
`nsec`, or a brand-new Ed25519 key).
- ✅ A signed proof that *you* control a GitHub account (or DNS domain, or
nostr handle, etc.) — verifiable by anyone, no central server needed.
- ✅ A sigchain that ties multiple identities together, exported in a
portable format, and published where strangers can find it.
- ✅ The ability to verify other people's identities the same way.
If you've used [Keybase](https://keybase.io), the mental model is the same.
The difference: KEZ has no required central authority. Your proofs live
wherever you publish them; the verifier just walks the links.
This is the Node.js implementation. It is **wire-compatible** with the
[Rust implementation](../rust/TUTORIAL.md) — a claim signed by `npm run cli`
verifies in `cargo run` and vice versa.
For the full protocol spec, see [`../SPEC.md`](../SPEC.md). This document
is the friendly cousin.
> **Time budget:** 1015 minutes for the first claim. A bit more if you
> want to set up DNS or a sigchain publish.
---
## 0. Install
You'll need:
- **Node.js 22+** — earlier versions don't have the global `WebSocket`
the nostr channel relies on. Check with `node --version`.
- **npm 9+** for workspaces.
Then:
```sh
git clone https://git.ptud.biz/DukeInc/Kez.git
cd Kez/nodejs
npm install
npm test # optional: run all vitest suites
```
Verify the CLI works:
```sh
npm run cli -- --help
```
You should see subcommands `identity`, `claim`, `verify`, and `sigchain`.
> **Note on `--`.** The bare `--` before the subcommand stops npm from
> swallowing flags. Every example below uses `npm run cli -- <stuff>`.
> **Want a global `kez` command instead?** From inside
> `nodejs/packages/kez-cli/` run `npm link` once. After that, plain
> `kez claim create …` works from anywhere — substitute `kez` for
> `npm run cli --` in every example below.
> **Optional but recommended:** `export GITHUB_TOKEN=ghp_...` in your
> shell before verifying github claims. Anonymous GitHub limits you to
> 60 requests/hour; with a token it's 5000/hour. Any read-only token
> works; KEZ never sends it anywhere but `api.github.com`.
---
## 1. Pick your primary key
Your **primary key** is the one private key the rest of your identity
hangs off of. It signs every claim you make. Two choices:
### Option A: use your existing nostr key (recommended if you have one)
If you already use nostr (Damus, Amethyst, primal, etc.), you already
have an `nsec1...` private key. Use it. KEZ understands nostr keys
natively as Schnorr/secp256k1.
Export the `nsec` from your nostr client (every client has a way —
usually Settings → Keys → Show / Export). Keep it secret; treat it the
same as a wallet seed.
> **Warning.** Pasting your `nsec` into a CLI is fine on a machine you
> trust. Don't do it on a shared box, and consider whether you want
> shell history to remember it (`unset HISTFILE` for the session, or
> prefix the command with a space if `HISTCONTROL=ignorespace`).
You don't need any command to "register" an existing nsec — just pass
it with `--nsec` on the first claim you sign.
### Option B: generate a fresh primary
A new nostr keypair:
```sh
npm run cli -- identity new
```
Or a new Ed25519 keypair:
```sh
npm run cli -- identity new --key-type ed25519
```
Output (Ed25519):
```
Primary: ed25519:7a3b4c…
Public: 7a3b4c… (hex)
Secret: 9e3f51… (32-byte seed)
```
> **Save the secret.** It's the only thing that can sign as this
> identity. There's no recovery flow — lose it and the identity is
> gone. Write it down offline, or paste it into a password manager.
> From here on this tutorial assumes you stored it.
For the rest of this tutorial we'll use a nostr key for examples and
write the secret as `nsec1FAKE...` — substitute your real one.
---
## 2. Sign your first claim
A **claim** is just a signed sentence: *"the key I signed this with also
controls `<subject>`."* The subject is a `system:identifier` string —
`github:tudisco`, `dns:tud.ink`, `nostr:npub1…`, etc.
Say you want to prove you control the GitHub username `tudisco`.
```sh
npm run cli -- claim create github:tudisco \
--nsec nsec1FAKE... \
--format markdown \
--out github-tudisco.kez.md
```
That writes a file like:
```markdown
# KEZ Proof
This account publishes a signed KEZ identity claim.
- Primary: `nostr:npub1tkf…`
- Subject: `github:tudisco`
- Created: `2026-05-27T19:21:46Z`
```kez
{
"kez": "claim",
"payload": { ... },
"signature": {
"alg": "nostr-schnorr-bip340-jcs",
"key": "nostr:npub1tkf…",
"sig": "abc123…"
}
}
```
```
### Picking the right format
Same claim, three packagings — same signature inside:
| Format | When to use | Command |
|---|---|---|
| **markdown** | Anywhere you can paste rich text — gists, profile READMEs, social posts. Most human-readable. | `--format markdown` |
| **compact** | Tight places: DNS TXT records, QR codes, chat messages. One-liner that decompresses back to the full envelope. | `--format compact` |
| **json** | Self-hosted `.well-known/kez.json`, developer tooling, anything that wants the raw envelope. | (default — no flag needed) |
If you skip `--out`, the proof prints to stdout — handy for piping.
---
## 3. Publish the proof
This is where KEZ does its job: you put the signed claim in a place that
only *that specific account* could have put it. Anyone who can fetch
that place can then verify it themselves.
Pick the section that matches the subject system you claimed.
### GitHub
You signed `github:tudisco`. Publish the markdown block to either:
**A public gist named `kez.md`** — easiest.
1. Go to <https://gist.github.com/>.
2. New gist → filename `kez.md` → paste the contents of
`github-tudisco.kez.md`.
3. Click **Create public gist**.
**Or your profile README** — fancier but you only get one.
1. Make a repo named the same as your username (e.g.
`tudisco/tudisco`). GitHub treats it as your profile README.
2. Add the markdown block to `README.md`.
3. Push.
KEZ's GitHub verifier checks public gists first, then the profile
README.
### DNS — your own domain
You signed `dns:tud.ink`. The CLI generates a ready-to-paste zone-file
line for you:
```sh
npm run cli -- claim dns tud.ink --nsec nsec1FAKE...
```
Output (abbreviated):
```
_kez.tud.ink. 3600 IN TXT
"kez:z1:KLUv_WAsACUHAD…<chunk 1>…"
"<chunk 2>…"
```
Add that TXT record at `_kez.<your-domain>` in your DNS provider's
console (Cloudflare, Route 53, Gandi, Porkbun — wherever you registered
the domain). Most providers will accept the whole compact string in one
field and split it for you; the multi-chunk form above is the safe one
for providers that don't.
Wait a minute or two for propagation, then you can verify it.
### Nostr — your own npub
You signed `nostr:npub1...`. Three places work (verifiers check all of
them):
- **Profile `about` field** (kind-0 event) — easiest, one-time. Edit
your nostr profile and paste the markdown block into your bio.
- **A normal post** (kind-1) containing the markdown block — quickest if
you're already active.
- **A NIP-78 kind-30078 event** with `d` tag = `kez` — cleanest for
tooling, but most clients don't expose it.
### Bluesky
Post the markdown block (or just the compact `kez:z1:…` string) as a
public post on the account you claimed. The verifier scans your recent
posts.
### Mastodon / ActivityPub
You signed `ap:@user@instance`. Add the markdown block to your profile
**metadata** field (most instances expose 4 of them), or post it as a
pinned toot. The verifier resolves via WebFinger → actor JSON → checks
those fields.
### Your own website
You signed `web:https://example.com`. Upload the JSON form to
`https://example.com/.well-known/kez.json`:
```sh
npm run cli -- claim create web:https://example.com --nsec nsec1FAKE... > kez.json
scp kez.json youruser@example.com:/var/www/.well-known/kez.json
```
Make sure it's publicly fetchable (no auth gate).
---
## 4. Verify it
This is the moment of truth. Pretend you're a stranger checking that the
claim is real:
```sh
npm run cli -- verify id github:tudisco
```
Output:
```
Primary: nostr:npub1tkf...
Verified identities:
- github:tudisco
Status: valid
Confidence: strong
```
Same shape for any channel:
```sh
npm run cli -- verify id dns:tud.ink
npm run cli -- verify id nostr:npub1tkf...
npm run cli -- verify id bluesky:tudisco.bsky.social
npm run cli -- verify id ap:@tudisco@mastodon.social
npm run cli -- verify id web:https://tud.ink
```
The verifier:
1. Figured out which channel from the prefix.
2. Fetched the proof from where you published it (gist, TXT, etc.).
3. Decoded the envelope.
4. Verified the cryptographic signature against the key inside.
**No KEZ server was involved.** Each side of the conversation independently
proves the claim — that's the whole point.
### Cross-implementation verification
This is wire-compatible with the [Rust CLI](../rust/TUTORIAL.md). You
can sign in one and verify in the other:
```sh
# Sign in Node…
npm run cli -- claim create github:tudisco --nsec nsec1FAKE... --out p.kez.md
# …verify the same file in Rust
cd ../rust && cargo run -p kez-cli -- verify file ../nodejs/p.kez.md
```
Same bytes, same signature, both implementations agree.
### If verification fails
A few common ones:
- **`not_found`** — the proof isn't where the verifier looked. For
GitHub, check the gist is public and the filename contains `kez`. For
DNS, the TXT record is at `_kez.<domain>`, not `<domain>` itself; give
propagation a minute.
- **`subject_mismatch`** — you published a proof for one subject but
asked the verifier to check a different one. The claim's `subject`
must equal the identifier you're verifying.
- **`invalid_signature`** — the proof was tampered with, or you
re-signed with a different key after publishing. Re-sign and
re-publish.
- **GitHub `403 rate_limited`** — anonymous gets 60 req/hr; export
`GITHUB_TOKEN`.
- **Nostr "WebSocket is not defined"** — your Node is older than 22.
Upgrade.
---
## 5. Sigchain — link multiple identities together
A **sigchain** is an append-only log of "this key controls X" events,
each signed by your primary. Once you have more than one claim, you
want a sigchain so:
- Verifiers can discover your full identity graph from a single
starting point.
- You can later **revoke** a claim (e.g., you lost access to that
github account) without invalidating the others.
- Old events stay verifiable; the chain head is the current truth.
Chains live at `~/.kez/sigchains/<safe-primary>.jsonl`. The CLI creates
the directory on first use; you don't manage it manually.
Add the github claim you already signed:
```sh
npm run cli -- sigchain add github:tudisco --nsec nsec1FAKE...
```
Add a DNS claim too:
```sh
npm run cli -- sigchain add dns:tud.ink --nsec nsec1FAKE...
```
You can optionally include a `--proof-url` pointing to where you
published this claim's proof (your gist URL, etc.). Verifiers can use
it to skip discovery.
Inspect what you've got:
```sh
npm run cli -- sigchain show --nsec nsec1FAKE...
```
Output:
```
Primary: nostr:npub1tkf...
Path: /home/you/.kez/sigchains/nostr_npub1tkf….jsonl
Length: 2 events
Head: sha256:9c3a…
Events:
1. add github:tudisco proof_url=https://gist.github.com/tudisco/abc
2. add dns:tud.ink
```
Read-only view of a published chain (no secret needed):
```sh
npm run cli -- sigchain show --primary nostr:npub1tkf...
```
This is what other people will do to inspect your identity graph.
### Revoking
If you ever lose control of an account (your github gets hacked, you
sell a domain), revoke that subject:
```sh
npm run cli -- sigchain revoke github:tudisco --nsec nsec1FAKE...
```
That appends a revoke event. Subsequent verifications treat that subject
as "no longer claimed" by your primary, even if the old proof is still
out there.
---
## 6. Publish your sigchain
Now make your chain discoverable so anyone with your primary can walk
it. Options, in rough order of how much infra they need:
### To a kez-sig-server (zero setup)
If you have access to a [`kez-sig-server`](../rust-sig-server/) (one
runs at `https://sig.kez.lat`):
```sh
npm run cli -- sigchain publish --nsec nsec1FAKE... \
--server https://sig.kez.lat
```
Each event is POSTed to the server, which exposes them at predictable
URLs. Cheap, fast, but you're trusting that server to stay up. Mitigate
by also publishing to one of the channels below.
### To your own website (self-sovereign)
Export the chain bundle and host it yourself:
```sh
npm run cli -- sigchain publish --nsec nsec1FAKE... \
--web --out kez-sigchain.jsonl
```
Then upload `kez-sigchain.jsonl` to
`https://<your-domain>/.well-known/kez-sigchain.jsonl`. Verifiers
fetch it directly. Hardest to censor; you own it.
### To DNS
```sh
npm run cli -- sigchain publish --nsec nsec1FAKE... --dns tud.ink
```
Prints a TXT record at `_kez-chain.<domain>` containing the
compressed chain. Add it to your zone. Works for short chains; for
long chains, prefer `--web` (TXT records are size-limited).
### To nostr
```sh
npm run cli -- sigchain publish --nsec nsec1FAKE... \
--nostr wss://relay.damus.io
```
Publishes the compact bundle as a kind-30078 event on that relay. Any
nostr client / verifier subscribed can find it.
### Pick more than one
`publish` accepts any combination of these flags — you can mirror to
all four in one shot:
```sh
npm run cli -- sigchain publish --nsec nsec1FAKE... \
--server https://sig.kez.lat \
--web --out kez-sigchain.jsonl \
--dns tud.ink \
--nostr wss://relay.damus.io
```
Redundancy is good. If one channel goes down, the others still serve
your identity graph.
### Export-only (no publish)
If you want to see the bundle without publishing:
```sh
npm run cli -- sigchain export --nsec nsec1FAKE... --format compact > my-chain.txt
npm run cli -- sigchain export --nsec nsec1FAKE... --format jsonl > my-chain.jsonl
```
---
## 7. Verifying someone else
You've done the publishing side. Here's the receiving side — how to
verify someone *else's* identity:
```sh
# Start from any identifier they've published a proof for.
npm run cli -- verify id github:linus
# Or walk their chain from any known endpoint:
npm run cli -- sigchain show --primary nostr:npub1abc...
```
If you have the chain bundle on disk:
```sh
npm run cli -- verify file ./their-chain.jsonl
```
`verify id` is the friendly day-to-day verb. `sigchain show
--primary <id>` is what you'd reach for to see the whole graph at once.
---
## 8. Programmatic use — embedding KEZ in a Node app
You don't have to go through the CLI. The same logic is exported as a
library by the `@kez/core` and `@kez/channels` workspace packages.
```ts
import {
Identity,
NostrSecret,
newClaimPayload,
signClaim,
toMarkdown,
} from "@kez/core";
import { defaultRegistry } from "@kez/channels";
// Sign a claim
const secret = NostrSecret.fromNsec("nsec1FAKE...");
const subject = Identity.parse("github:tudisco");
const payload = newClaimPayload(subject, secret.identity(), new Date());
const claim = signClaim(payload, secret);
console.log(toMarkdown(claim));
// Verify a peer
const registry = await defaultRegistry();
const hit = await registry.verify(Identity.parse("dns:tud.ink"));
console.log(hit.status); // "valid"
```
For testing without hitting the live channels, every channel takes an
injectable fetcher (`TxtResolver`, `NostrFetcher`, etc.) — see the
package READMEs and `__tests__/` folders for the exact shapes. The
implementations themselves are <300 lines each.
---
## 9. Quick reference card
```sh
# Generate a fresh primary
npm run cli -- identity new
npm run cli -- identity new --key-type ed25519
# Sign a claim
npm run cli -- claim create <subject> --nsec <nsec> # nostr key
npm run cli -- claim create <subject> --ed25519-seed <hex> # ed25519 key
npm run cli -- claim create <subject> --nsec <nsec> --format markdown --out file.md
npm run cli -- claim create <subject> --nsec <nsec> --format compact # one-liner
npm run cli -- claim dns <domain> --nsec <nsec> # zone-file output
# Verify
npm run cli -- verify id <subject> # live channel fetch
npm run cli -- verify file <path> # local file
# Sigchain
npm run cli -- sigchain add <subject> --nsec <nsec> [--proof-url <url>]
npm run cli -- sigchain revoke <subject> --nsec <nsec>
npm run cli -- sigchain show --nsec <nsec> # your own
npm run cli -- sigchain show --primary <id> # someone else's
npm run cli -- sigchain export --nsec <nsec> --format jsonl|compact [--out file]
npm run cli -- sigchain publish --nsec <nsec> \
[--server <url>] [--web --out <path>] [--dns <domain>] [--nostr <relay>]
```
---
## 10. Common confusions
**"Do I need a sigchain to use KEZ?"** No. A single signed claim,
published, works on its own. The sigchain is for when you have several
claims and want them discoverable together (and revocable).
**"Why two key types — nostr and ed25519?"** Different ecosystems use
different curves. Nostr is secp256k1/Schnorr; the rest of the world
mostly likes Ed25519. KEZ supports both natively so you can use the
key you already have rather than spinning up a new one for KEZ
specifically.
**"Is my `nsec` sent to KEZ servers?"** No, never. The CLI uses it
locally to sign things. Only the *signed envelope* (public key + claim
+ signature) ever leaves your machine.
**"What if I publish a proof and then someone else copies it and
publishes it as theirs?"** They can copy the bytes, but the signature
inside is over *your* primary. Their primary won't match, so any
verifier sees through it immediately.
**"What if my key is compromised?"** Append a `sigchain revoke
<subject>` for the affected subjects, and ideally rotate to a new
primary by signing a final "this primary is succeeded by <new>" event
(planned for the spec; not yet enforced by the CLI in v0.1).
**"Is the Node version slower than Rust?"** For everything but
sigchain export of large chains, no — both use the same Noble curves
underneath and the verifier is I/O-bound on the channel HTTP call.
For batch sigchain work, the Rust binary will be a touch faster.
---
## 11. Where to go next
- The web client at <https://kez.lat> — same protocol, no CLI.
Useful for showing non-technical friends.
- [`../SPEC.md`](../SPEC.md) — the formal protocol, if you want to know
exactly what every byte means.
- [`../rust/TUTORIAL.md`](../rust/TUTORIAL.md) — the same tutorial for
the Rust implementation. Identical surface; faster binary.
- [`../rust-sig-server/`](../rust-sig-server/) — run your own
sig-server, federate with others.
- The channel plugin interface in
[`packages/kez-channels/src/index.ts`](packages/kez-channels/src/index.ts) —
~40 lines, add a new channel in an afternoon.
That's the whole tutorial. Welcome to KEZ.

View File

@ -41,6 +41,12 @@ Three crates, ~2,500 lines of Rust, **99 tests**.
## Quick start
> **New to KEZ?** Read [**`TUTORIAL.md`**](TUTORIAL.md) — a friendly
> step-by-step walkthrough that takes you from "I have a nostr `nsec`"
> to "I have a verified, published sigchain." It assumes nothing.
>
> This README is the reference; the tutorial is the on-ramp.
```sh
# Build, test, and install the `kez` binary to ~/.cargo/bin (one time)
cargo build

557
rust/TUTORIAL.md Normal file
View File

@ -0,0 +1,557 @@
# Tutorial — your first KEZ identity, end to end
This is a hands-on walkthrough. By the end you'll have:
- ✅ A KEZ identity tied to a key you already trust (your existing nostr
`nsec`, or a brand-new Ed25519 key).
- ✅ A signed proof that *you* control a GitHub account (or DNS domain, or
nostr handle, etc.) — verifiable by anyone, no central server needed.
- ✅ A sigchain that ties multiple identities together, exported in a
portable format, and published where strangers can find it.
- ✅ The ability to verify other people's identities the same way.
If you've used [Keybase](https://keybase.io), the mental model is the same.
The difference: KEZ has no required central authority. Your proofs live
wherever you publish them; the verifier just walks the links.
For the full protocol spec, see [`../SPEC.md`](../SPEC.md). This document
is the friendly cousin.
> **Time budget:** 1015 minutes for the first claim. A bit more if you
> want to set up DNS or a sigchain publish.
---
## 0. Install
```sh
git clone https://git.ptud.biz/DukeInc/Kez.git
cd Kez/rust
cargo build --release
cargo install --path crates/kez-cli # puts `kez` in ~/.cargo/bin
```
Verify:
```sh
kez --help
```
You should see subcommands `identity`, `claim`, `verify`, and `sigchain`.
> **Don't want to install globally?** Replace every `kez` below with
> `cargo run -p kez-cli --` (from the `rust/` directory). Slower to
> start each time, but no install side effects.
> **Optional but recommended:** `export GITHUB_TOKEN=ghp_...` in your
> shell before verifying github claims. Anonymous GitHub limits you to
> 60 requests/hour; with a token it's 5000/hour. Any read-only token
> works; KEZ never sends it anywhere but `api.github.com`.
---
## 1. Pick your primary key
Your **primary key** is the one private key the rest of your identity
hangs off of. It signs every claim you make. Two choices:
### Option A: use your existing nostr key (recommended if you have one)
If you already use nostr (Damus, Amethyst, primal, etc.), you already
have an `nsec1...` private key. Use it. KEZ understands nostr keys
natively as Schnorr/secp256k1.
Export the `nsec` from your nostr client (every client has a way —
usually Settings → Keys → Show / Export). Keep it secret; treat it the
same as a wallet seed.
> **Warning.** Pasting your `nsec` into a CLI is fine on a machine you
> trust. Don't do it on a shared box, and consider whether you want
> shell history to remember it (`unset HISTFILE` for the session, or
> prefix the command with a space if `HISTCONTROL=ignorespace`).
You can confirm KEZ accepts your key without signing anything yet:
```sh
kez identity new --key-type nostr # only if you want a NEW key
# vs.
# (no command needed to "register" an existing nsec — just pass it
# directly with --nsec on the first claim you sign)
```
### Option B: generate a fresh Ed25519 primary
If you'd rather start clean, generate a new Ed25519 key:
```sh
kez identity new --key-type ed25519
```
Output:
```
Primary: ed25519:7a3b4c…
Public: 7a3b4c… (hex)
Secret: 9e3f51… (hex — 64 chars, KEEP SECRET)
```
> **Save the secret.** It's the only thing that can sign as this
> identity. There's no recovery flow — lose it and the identity is
> gone. Write it down offline, or paste it into a password manager.
> From here on this tutorial assumes you stored it.
For the rest of this tutorial we'll use a nostr key for examples and
write the secret as `nsec1FAKE...` — substitute your real one.
---
## 2. Sign your first claim
A **claim** is just a signed sentence: *"the key I signed this with also
controls `<subject>`."* The subject is a `system:identifier` string —
`github:tudisco`, `dns:tud.ink`, `nostr:npub1…`, etc.
Say you want to prove you control the GitHub username `tudisco`.
```sh
kez claim create github:tudisco \
--nsec nsec1FAKE... \
--format markdown \
--out github-tudisco.kez.md
```
That writes a file like:
```markdown
# KEZ Proof
This account publishes a signed KEZ identity claim.
- Primary: `nostr:npub1tkf…`
- Subject: `github:tudisco`
- Created: `2026-05-27T19:21:46Z`
```kez
{
"kez": "claim",
"payload": { ... },
"signature": {
"alg": "ed25519-sha512-jcs" / "nostr-schnorr-bip340-jcs",
"key": "nostr:npub1tkf…",
"sig": "abc123…"
}
}
```
```
### Picking the right format
Same claim, three packagings — same signature inside:
| Format | When to use | Command |
|---|---|---|
| **markdown** | Anywhere you can paste rich text — gists, profile READMEs, social posts. Most human-readable. | `--format markdown` |
| **compact** | Tight places: DNS TXT records, QR codes, chat messages. One-liner that decompresses back to the full envelope. | `--format compact` |
| **json** | Self-hosted `.well-known/kez.json`, developer tooling, anything that wants the raw envelope. | (default — no flag needed) |
If you skip `--out`, the proof prints to stdout — handy for piping.
---
## 3. Publish the proof
This is where KEZ does its job: you put the signed claim in a place that
only *that specific account* could have put it. Anyone who can fetch
that place can then verify it themselves.
Pick the section that matches the subject system you claimed.
### GitHub
You signed `github:tudisco`. Publish the markdown block to either:
**A public gist named `kez.md`** — easiest.
1. Go to <https://gist.github.com/>.
2. New gist → filename `kez.md` → paste the contents of
`github-tudisco.kez.md`.
3. Click **Create public gist**.
**Or your profile README** — fancier but you only get one.
1. Make a repo named the same as your username (e.g.
`tudisco/tudisco`). GitHub treats it as your profile README.
2. Add the markdown block to `README.md`.
3. Push.
KEZ's GitHub verifier checks public gists first, then the profile
README.
### DNS — your own domain
You signed `dns:tud.ink`. The CLI generates a ready-to-paste zone-file
line for you:
```sh
kez claim dns tud.ink --nsec nsec1FAKE...
```
Output (abbreviated):
```
_kez.tud.ink. 3600 IN TXT
"kez:z1:KLUv_WAsACUHAD…<chunk 1>…"
"<chunk 2>…"
```
Add that TXT record at `_kez.<your-domain>` in your DNS provider's
console (Cloudflare, Route 53, Gandi, Porkbun — wherever you registered
the domain). Most providers will accept the whole compact string in one
field and split it for you; the multi-chunk form above is the safe one
for providers that don't.
Wait a minute or two for propagation, then you can verify it.
### Nostr — your own npub
You signed `nostr:npub1...`. Three places work (verifiers check all of
them):
- **Profile `about` field** (kind-0 event) — easiest, one-time. Edit
your nostr profile and paste the markdown block into your bio.
- **A normal post** (kind-1) containing the markdown block — quickest if
you're already active.
- **A NIP-78 kind-30078 event** with `d` tag = `kez` — cleanest for
tooling, but most clients don't expose it.
### Bluesky
Post the markdown block (or just the compact `kez:z1:…` string) as a
public post on the account you claimed. The verifier scans your recent
posts.
### Mastodon / ActivityPub
You signed `ap:@user@instance`. Add the markdown block to your profile
**metadata** field (most instances expose 4 of them), or post it as a
pinned toot. The verifier resolves via WebFinger → actor JSON → checks
those fields.
### Your own website
You signed `web:https://example.com`. Upload the JSON form to
`https://example.com/.well-known/kez.json`:
```sh
kez claim create web:https://example.com --nsec nsec1FAKE... > kez.json
scp kez.json youruser@example.com:/var/www/.well-known/kez.json
```
Make sure it's publicly fetchable (no auth gate).
---
## 4. Verify it
This is the moment of truth. Pretend you're a stranger checking that the
claim is real:
```sh
kez verify id github:tudisco
```
Output:
```
Primary: nostr:npub1tkf...
Verified identities:
- github:tudisco
Status: valid
Confidence: strong
```
Same shape for any channel:
```sh
kez verify id dns:tud.ink
kez verify id nostr:npub1tkf...
kez verify id bluesky:tudisco.bsky.social
kez verify id ap:@tudisco@mastodon.social
kez verify id web:https://tud.ink
```
The verifier:
1. Figured out which channel from the prefix.
2. Fetched the proof from where you published it (gist, TXT, etc.).
3. Decoded the envelope.
4. Verified the cryptographic signature against the key inside.
**No KEZ server was involved.** Each side of the conversation independently
proves the claim — that's the whole point.
### If verification fails
A few common ones:
- **`not_found`** — the proof isn't where the verifier looked. For
GitHub, check the gist is public and the filename contains `kez`. For
DNS, the TXT record is at `_kez.<domain>`, not `<domain>` itself; give
propagation a minute.
- **`subject_mismatch`** — you published a proof for one subject but
asked the verifier to check a different one. The claim's `subject`
must equal the identifier you're verifying.
- **`invalid_signature`** — the proof was tampered with, or you
re-signed with a different key after publishing. Re-sign and
re-publish.
- **GitHub `403 rate_limited`** — anonymous gets 60 req/hr; export
`GITHUB_TOKEN`.
---
## 5. Sigchain — link multiple identities together
A **sigchain** is an append-only log of "this key controls X" events,
each signed by your primary. Once you have more than one claim, you
want a sigchain so:
- Verifiers can discover your full identity graph from a single
starting point.
- You can later **revoke** a claim (e.g., you lost access to that
github account) without invalidating the others.
- Old events stay verifiable; the chain head is the current truth.
Chains live at `~/.kez/sigchains/<safe-primary>.jsonl`. The CLI creates
the directory on first use; you don't manage it manually.
Add the github claim you already signed:
```sh
kez sigchain add github:tudisco --nsec nsec1FAKE...
```
Add a DNS claim too:
```sh
kez sigchain add dns:tud.ink --nsec nsec1FAKE...
```
You can optionally include a `--proof-url` pointing to where you
published this claim's proof (your gist URL, etc.). Verifiers can use
it to skip discovery.
Inspect what you've got:
```sh
kez sigchain show --nsec nsec1FAKE...
```
Output:
```
Primary: nostr:npub1tkf...
Path: /home/you/.kez/sigchains/nostr_npub1tkf….jsonl
Length: 2 events
Head: sha256:9c3a…
Events:
1. add github:tudisco proof_url=https://gist.github.com/tudisco/abc
2. add dns:tud.ink
```
Read-only view of a published chain (no secret needed):
```sh
kez sigchain show --primary nostr:npub1tkf...
```
This is what other people will do to inspect your identity graph.
### Revoking
If you ever lose control of an account (your github gets hacked, you
sell a domain), revoke that subject:
```sh
kez sigchain revoke github:tudisco --nsec nsec1FAKE...
```
That appends a revoke event. Subsequent verifications treat that subject
as "no longer claimed" by your primary, even if the old proof is still
out there.
---
## 6. Publish your sigchain
Now make your chain discoverable so anyone with your primary can walk
it. Options, in rough order of how much infra they need:
### To a kez-sig-server (zero setup)
If you have access to a [`kez-sig-server`](../rust-sig-server/) (one
runs at `https://sig.kez.lat`):
```sh
kez sigchain publish --nsec nsec1FAKE... \
--server https://sig.kez.lat
```
Each event is POSTed to the server, which exposes them at predictable
URLs. Cheap, fast, but you're trusting that server to stay up. Mitigate
by also publishing to one of the channels below.
### To your own website (self-sovereign)
Export the chain bundle and host it yourself:
```sh
kez sigchain publish --nsec nsec1FAKE... \
--web --out kez-sigchain.jsonl
```
Then upload `kez-sigchain.jsonl` to
`https://<your-domain>/.well-known/kez-sigchain.jsonl`. Verifiers
fetch it directly. Hardest to censor; you own it.
### To DNS
```sh
kez sigchain publish --nsec nsec1FAKE... --dns tud.ink
```
Prints a TXT record at `_kez-chain.<domain>` containing the
compressed chain. Add it to your zone. Works for short chains; for
long chains, prefer `--web` (TXT records are size-limited).
### To nostr
```sh
kez sigchain publish --nsec nsec1FAKE... \
--nostr wss://relay.damus.io
```
Publishes the compact bundle as a kind-30078 event on that relay. Any
nostr client / verifier subscribed can find it.
### Pick more than one
`publish` accepts any combination of these flags — you can mirror to
all four in one shot:
```sh
kez sigchain publish --nsec nsec1FAKE... \
--server https://sig.kez.lat \
--web --out kez-sigchain.jsonl \
--dns tud.ink \
--nostr wss://relay.damus.io
```
Redundancy is good. If one channel goes down, the others still serve
your identity graph.
### Export-only (no publish)
If you want to see the bundle without publishing:
```sh
kez sigchain export --nsec nsec1FAKE... --format compact > my-chain.txt
kez sigchain export --nsec nsec1FAKE... --format jsonl > my-chain.jsonl
```
---
## 7. Verifying someone else
You've done the publishing side. Here's the receiving side — how to
verify someone *else's* identity:
```sh
# Start from any identifier they've published a proof for.
kez verify id github:linus
# Or walk their chain from any known endpoint:
kez sigchain show --primary nostr:npub1abc...
```
If you have the chain bundle on disk:
```sh
kez verify file ./their-chain.jsonl
```
`verify id` is the friendly day-to-day verb. `sigchain show
--primary <id>` is what you'd reach for to see the whole graph at once.
---
## 8. Quick reference card
```sh
# Generate a fresh primary
kez identity new
kez identity new --key-type ed25519
# Sign a claim
kez claim create <subject> --nsec <nsec> # nostr key
kez claim create <subject> --ed25519-seed <hex-seed> # ed25519 key
kez claim create <subject> --nsec <nsec> --format markdown --out file.md
kez claim create <subject> --nsec <nsec> --format compact # one-liner
kez claim dns <domain> --nsec <nsec> # zone-file output
# Verify
kez verify id <subject> # live channel fetch
kez verify file <path> # local file
# Sigchain
kez sigchain add <subject> --nsec <nsec> [--proof-url <url>]
kez sigchain revoke <subject> --nsec <nsec>
kez sigchain show --nsec <nsec> # your own
kez sigchain show --primary <id> # someone else's
kez sigchain export --nsec <nsec> --format jsonl|compact [--out file]
kez sigchain publish --nsec <nsec> \
[--server <url>] [--web --out <path>] [--dns <domain>] [--nostr <relay>]
```
---
## 9. Common confusions
**"Do I need a sigchain to use KEZ?"** No. A single signed claim,
published, works on its own. The sigchain is for when you have several
claims and want them discoverable together (and revocable).
**"Why two key types — nostr and ed25519?"** Different ecosystems use
different curves. Nostr is secp256k1/Schnorr; the rest of the world
mostly likes Ed25519. KEZ supports both natively so you can use the
key you already have rather than spinning up a new one for KEZ
specifically.
**"Is my `nsec` sent to KEZ servers?"** No, never. The CLI uses it
locally to sign things. Only the *signed envelope* (public key + claim
+ signature) ever leaves your machine.
**"What if I publish a proof and then someone else copies it and
publishes it as theirs?"** They can copy the bytes, but the signature
inside is over *your* primary. Their primary won't match, so any
verifier sees through it immediately.
**"What if my key is compromised?"** Append a `sigchain revoke
<subject>` for the affected subjects, and ideally rotate to a new
primary by signing a final "this primary is succeeded by <new>" event
(planned for the spec; not yet enforced by the CLI in v0.1).
---
## 10. Where to go next
- The web client at <https://kez.lat> — same protocol, no CLI.
Useful for showing non-technical friends.
- [`../SPEC.md`](../SPEC.md) — the formal protocol, if you want to know
exactly what every byte means.
- [`../rust-sig-server/`](../rust-sig-server/) — run your own
sig-server, federate with others.
- The channel plugin trait in
[`crates/kez-channels/src/lib.rs`](crates/kez-channels/src/lib.rs) —
~40 lines, add a new channel in an afternoon.
That's the whole tutorial. Welcome to KEZ.