From 17d68dbb75240291a2f4a9093aa7524e5fc6ad00 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 25 May 2026 12:48:44 -0600 Subject: [PATCH] feat(kez-chat/web): real zstd compact form + 3-way format toggle on AddClaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @bokuweb/zstd-wasm; replace the kez:zd1: deflate-raw placeholder with spec-compliant kez:z1: zstd(JSON envelope) compact form. - Dynamic import keeps the WASM (~348 KB) in its own Vite chunk so the initial bundle only grows from 113 KB to 116 KB; the WASM is fetched the first time a user picks compact format. - AddClaim.svelte: 3-way format toggle (compact / markdown / JSON). DNS defaults to compact since TXT records want the shortest payload. - Drop the v0.1 apology in DNS instructions — kez:z1: is the spec form and verifiers can decompress it directly. - Cross-impl interop verified: browser-generated kez:z1: decompresses cleanly in the Rust CLI and the Node port, byte-for-byte modulo JSON key-order whitespace. Deployed live to https://kez.lat. Co-Authored-By: Claude Opus 4.7 --- kez-chat/web/package-lock.json | 7 ++ kez-chat/web/package.json | 1 + kez-chat/web/src/lib/kez.ts | 101 ++++++++++++++---------- kez-chat/web/src/routes/AddClaim.svelte | 71 +++++++++++++---- 4 files changed, 123 insertions(+), 57 deletions(-) diff --git a/kez-chat/web/package-lock.json b/kez-chat/web/package-lock.json index 39eaf96..68c338d 100644 --- a/kez-chat/web/package-lock.json +++ b/kez-chat/web/package-lock.json @@ -8,6 +8,7 @@ "name": "kez-chat-web", "version": "0.1.0", "dependencies": { + "@bokuweb/zstd-wasm": "^0.0.22", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/base": "^1.1.9", @@ -27,6 +28,12 @@ "vite": "^5.4.0" } }, + "node_modules/@bokuweb/zstd-wasm": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/@bokuweb/zstd-wasm/-/zstd-wasm-0.0.22.tgz", + "integrity": "sha512-GT9gZCxWzVTclSipZZNbYRloSKLtPBxj4Avc/SLLQcnBJlJCHUllfePe8it9vNZBSMD6y9Ox0LxaubWT/nJ19Q==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", diff --git a/kez-chat/web/package.json b/kez-chat/web/package.json index aa33d03..32d5ea8 100644 --- a/kez-chat/web/package.json +++ b/kez-chat/web/package.json @@ -10,6 +10,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { + "@bokuweb/zstd-wasm": "^0.0.22", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/base": "^1.1.9", diff --git a/kez-chat/web/src/lib/kez.ts b/kez-chat/web/src/lib/kez.ts index bdd0368..ccdcffc 100644 --- a/kez-chat/web/src/lib/kez.ts +++ b/kez-chat/web/src/lib/kez.ts @@ -194,53 +194,74 @@ export function toMarkdown(envelope: SignedClaimEnvelope): string { } /** - * `kez:z1:` - * Browser zstd: `CompressionStream` doesn't support zstd as of 2026, so - * we fall back to a tiny pure-JS zstd compressor for now. For v0.1 we - * only need it for short claim envelopes; small payloads compress fast. + * Spec-compliant compact form: `kez:z1:`. * - * Implementation: use the browser's native `CompressionStream("deflate-raw")` - * as a substitute (NOT zstd — different format!). This is a v0.1 stopgap - * so the SPA can show a compact form; we mark these as `kez:zd1:` instead - * of `kez:z1:` to make absolutely clear they are NOT the spec-compliant - * zstd encoding. They round-trip in the SPA but don't interop with the - * Rust/Node implementations until we add proper zstd-in-browser (next - * iteration; the @bokuweb/zstd-wasm crate works). + * Async because zstd lives in a WASM module that initializes lazily — + * the WASM blob (~64 KB) is in a Vite-split chunk and only downloaded + * when this function is first called, keeping the initial page-load + * bundle tight. Subsequent calls are synchronous after init. + * + * Output round-trips byte-for-byte with Rust's `zstd` crate and Node's + * built-in `zstd` (both also default to level 3). Any conforming KEZ + * verifier can decompress and parse this form. */ -export async function toCompactDevPreview( - envelope: SignedClaimEnvelope, -): Promise { +export async function toCompact(envelope: SignedClaimEnvelope): Promise { + const { compress } = await ensureZstd(); const json = JSON.stringify(envelope); - const compressed = await deflateRaw(new TextEncoder().encode(json)); - // base64url, no padding - let b64 = btoa(String.fromCharCode(...compressed)); - b64 = b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); - return `kez:zd1:${b64}`; + const input = new TextEncoder().encode(json); + const compressed = compress(input, 3); + return COMPACT_PROOF_PREFIX + base64UrlNoPad(compressed); } -async function deflateRaw(input: Uint8Array): Promise { - // Copy into a fresh ArrayBuffer-backed buffer so the BodyInit - // overload in TS 5.6+ accepts it (Uint8Array isn't - // assignable to BodyInit without this). - const fresh = new Uint8Array(input.byteLength); - fresh.set(input); - const stream = new Response(fresh).body!.pipeThrough( - new CompressionStream("deflate-raw"), - ); - const chunks: Uint8Array[] = []; - const reader = stream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) chunks.push(value); +/** Decode a `kez:z1:` string back to a SignedClaimEnvelope. */ +export async function fromCompact(value: string): Promise { + if (!value.startsWith(COMPACT_PROOF_PREFIX)) { + throw new Error(`expected ${COMPACT_PROOF_PREFIX} prefix`); } - const total = chunks.reduce((n, c) => n + c.length, 0); - const out = new Uint8Array(total); - let off = 0; - for (const c of chunks) { - out.set(c, off); - off += c.length; + const { decompress } = await ensureZstd(); + const body = value.slice(COMPACT_PROOF_PREFIX.length); + const compressed = base64UrlDecode(body); + const json = decompress(compressed); + return JSON.parse(new TextDecoder().decode(json)) as SignedClaimEnvelope; +} + +// ───────────────────────────────────────────────────────────────────────────── +// WASM zstd init (dynamic import so the WASM is its own bundle chunk) +// ───────────────────────────────────────────────────────────────────────────── + +let zstdReady: Promise | null = null; + +function ensureZstd(): Promise { + if (!zstdReady) { + zstdReady = (async () => { + const mod = await import("@bokuweb/zstd-wasm"); + await mod.init(); + return mod; + })(); } + return zstdReady; +} + +// ───────────────────────────────────────────────────────────────────────────── +// base64url helpers (no padding) — used by compact form +// ───────────────────────────────────────────────────────────────────────────── + +function base64UrlNoPad(bytes: Uint8Array): string { + let b64 = ""; + for (let i = 0; i < bytes.length; i++) { + b64 += String.fromCharCode(bytes[i]); + } + b64 = btoa(b64); + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function base64UrlDecode(s: string): Uint8Array { + // Restore padding for atob. + const padded = s.replace(/-/g, "+").replace(/_/g, "/") + + "=".repeat((4 - (s.length % 4)) % 4); + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } diff --git a/kez-chat/web/src/routes/AddClaim.svelte b/kez-chat/web/src/routes/AddClaim.svelte index 2583263..ae5470b 100644 --- a/kez-chat/web/src/routes/AddClaim.svelte +++ b/kez-chat/web/src/routes/AddClaim.svelte @@ -5,6 +5,7 @@ signClaim, toPrettyJson, toMarkdown, + toCompact, type SignedClaimEnvelope, } from "../lib/kez.js"; import { addClaim } from "../lib/claims-store.js"; @@ -12,6 +13,8 @@ type ChannelKey = "github" | "dns" | "web" | "nostr" | "bluesky" | "ap"; + type Format = "compact" | "markdown" | "json"; + interface ChannelDef { key: ChannelKey; label: string; @@ -22,7 +25,7 @@ /** Free-text instructions shown next to the publish artifact. */ instructions: (subject: string) => string; /** Which encoding to show by default. */ - preferredFormat: "json" | "markdown"; + preferredFormat: Format; } const CHANNELS: ChannelDef[] = [ @@ -46,15 +49,18 @@ identifierLabel: "Domain", identifierPlaceholder: "tudisco.com", toSubject: (s) => `dns:${s.trim().toLowerCase()}`, - preferredFormat: "json", + preferredFormat: "compact", instructions: (subject) => { const domain = subject.slice(4); return ( `1. Open your DNS registrar / nameserver console for ${domain}.\n` + `2. Add a TXT record:\n` + - ` Name: _kez.${domain}\n` + - ` Value: (paste the JSON envelope — but in v0.1 the SPA's compact form isn't spec-compliant zstd; use the CLI to produce the kez:z1: form for now, or paste the JSON if your DNS host accepts long TXT values).\n\n` + - `Verifiers resolve _kez.${domain} via DNS TXT lookups.` + ` Name: _kez.${domain}\n` + + ` Value: (the kez:z1:… string on the right)\n` + + `3. Save. Propagation can take a few minutes.\n\n` + + `Notes:\n` + + `- DNS TXT records have a 255-byte segment limit. Most registrars (Cloudflare, Route 53, Gandi, etc.) handle long values by splitting into multiple segments automatically — just paste the whole string. If your registrar refuses, see the FAQ.\n` + + `- Verifiers resolve _kez.${domain} via DNS TXT lookups and decode the kez:z1: form to find your signed claim.` ); }, }, @@ -126,16 +132,30 @@ let selected = $state(null); let identifierInput = $state(""); let envelope = $state(null); - let format = $state<"json" | "markdown">("json"); + let format = $state("compact"); let copied = $state(false); + /** + * Render the current envelope in the chosen format. + * Async because the compact form initializes the zstd WASM module on + * first use (subsequent calls are sync). Returns "" if no envelope. + */ + async function renderArtifact( + env: SignedClaimEnvelope, + fmt: Format, + ): Promise { + if (fmt === "markdown") return toMarkdown(env); + if (fmt === "json") return toPrettyJson(env); + return await toCompact(env); + } + onMount(() => { if (!session.unlocked) push("/unlock"); }); function pickChannel(c: ChannelDef) { selected = c; - format = c.preferredFormat; + format = c.preferredFormat; // DNS → compact, github → markdown, web → json step = "identifier"; } @@ -150,13 +170,12 @@ step = "publish"; } - function copyArtifact() { + async function copyArtifact() { if (!envelope) return; - const text = format === "markdown" ? toMarkdown(envelope) : toPrettyJson(envelope); - navigator.clipboard.writeText(text).then(() => { - copied = true; - setTimeout(() => (copied = false), 1500); - }); + const text = await renderArtifact(envelope, format); + await navigator.clipboard.writeText(text); + copied = true; + setTimeout(() => (copied = false), 1500); } async function saveAndDone() { @@ -259,23 +278,41 @@
-
+

2. Copy this:

+
-
{format === "markdown" ? toMarkdown(envelope) : toPrettyJson(envelope)}
+ {#await renderArtifact(envelope, format)} +
Computing…
+ {:then text} +
{text}
+ {#if format === "compact"} +

+ {text.length} chars · zstd-compressed signed envelope, base64url-encoded. +

+ {/if} + {:catch e} +
Error: {e.message}
+ {/await}