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.
@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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(®istered_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()?;
|
||||
|
||||
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(®istered_at)
|
||||
.map_err(|e| {
|
||||
ApiError::Internal(format!("stored timestamp not parseable: {e}"))
|
||||
})?
|
||||
.with_timezone(&Utc);
|
||||
Ok(Some(HandleRecord {
|
||||
handle,
|
||||
primary,
|
||||
registered_at,
|
||||
}))
|
||||
}
|
||||
}
|
||||
row.map(build_record).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
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(®istered_at)
|
||||
.map_err(|e| ApiError::Internal(format!("stored timestamp not parseable: {e}")))?
|
||||
.with_timezone(&Utc);
|
||||
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
@ -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
@ -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; 8–12 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 (120–200ms), `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.
|
||||
@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 718 B |
@ -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 |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 853 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 408 B |
@ -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} />
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<!-- 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>
|
||||
{: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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
73
kez-chat/web/src/lib/Avatar.svelte
Normal 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
|
||||
// (160–210°), 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>
|
||||
@ -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>
|
||||
|
||||
27
kez-chat/web/src/lib/VerifiedBadge.svelte
Normal 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>
|
||||
18
kez-chat/web/src/lib/Wordmark.svelte
Normal 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>
|
||||
@ -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> {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -27,7 +27,7 @@ import {
|
||||
streamInbox,
|
||||
type InboxMessage,
|
||||
type StreamHandle,
|
||||
} from "./messages.js";
|
||||
} from "./transport.js";
|
||||
import { lookupByPrimary } from "./api.js";
|
||||
import {
|
||||
appendInbound,
|
||||
|
||||
61
kez-chat/web/src/lib/nostr-id.ts
Normal 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 (1000–9999 → 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));
|
||||
}
|
||||
243
kez-chat/web/src/lib/nostr-transport.ts
Normal 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";
|
||||
48
kez-chat/web/src/lib/onboarding.svelte.ts
Normal 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();
|
||||
73
kez-chat/web/src/lib/theme.svelte.ts
Normal 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();
|
||||
28
kez-chat/web/src/lib/transport.ts
Normal 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;
|
||||
@ -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";
|
||||
|
||||
@ -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")!,
|
||||
|
||||
@ -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}:<…></p>
|
||||
<p class="font-semibold text-text">{c.label}</p>
|
||||
<p class="text-xs text-text-muted mt-1 font-mono">{c.key}:<…></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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>. 3–32 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}
|
||||
|
||||
220
kez-chat/web/src/routes/Identity.svelte
Normal 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}
|
||||
@ -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.
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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/<id></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"}
|
||||
|
||||
223
kez-chat/web/src/routes/Settings.svelte
Normal 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}
|
||||
@ -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
|
||||
|
||||
201
kez-chat/web/src/routes/Welcome.svelte
Normal 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}
|
||||
@ -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" },
|
||||
|
||||
@ -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
@ -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:** 10–15 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.
|
||||
@ -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
@ -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:** 10–15 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.
|
||||