feat(kez-chat/web): real zstd compact form + 3-way format toggle on AddClaim

- 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 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-25 12:48:44 -06:00
parent a9feb1b5b2
commit 17d68dbb75
4 changed files with 123 additions and 57 deletions

View File

@ -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",

View File

@ -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",

View File

@ -194,53 +194,74 @@ export function toMarkdown(envelope: SignedClaimEnvelope): string {
}
/**
* `kez:z1:<base64url-no-pad(zstd(json-envelope))>`
* Browser zstd: `CompressionStream` doesn't support zstd as of 2026, so
* we fall back to a tiny pure-JS zstd compressor for now. For v0.1 we
* only need it for short claim envelopes; small payloads compress fast.
* Spec-compliant compact form: `kez:z1:<base64url-no-pad(zstd(json-envelope))>`.
*
* 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<string> {
export async function toCompact(envelope: SignedClaimEnvelope): Promise<string> {
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<Uint8Array> {
// Copy into a fresh ArrayBuffer-backed buffer so the BodyInit
// overload in TS 5.6+ accepts it (Uint8Array<ArrayBufferLike> isn't
// assignable to BodyInit without this).
const fresh = new Uint8Array(input.byteLength);
fresh.set(input);
const stream = new Response(fresh).body!.pipeThrough(
new CompressionStream("deflate-raw"),
);
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
/** Decode a `kez:z1:` string back to a SignedClaimEnvelope. */
export async function fromCompact(value: string): Promise<SignedClaimEnvelope> {
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<typeof import("@bokuweb/zstd-wasm")> | null = null;
function ensureZstd(): Promise<typeof import("@bokuweb/zstd-wasm")> {
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;
}

View File

@ -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<ChannelDef | null>(null);
let identifierInput = $state("");
let envelope = $state<SignedClaimEnvelope | null>(null);
let format = $state<"json" | "markdown">("json");
let format = $state<Format>("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<string> {
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 @@
</section>
<section class="space-y-3">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap">
<h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide flex-1">
2. Copy this:
</h2>
<div class="flex border border-gray-300 rounded overflow-hidden text-xs">
<button
class={`px-2 py-1 ${format === "markdown" ? "bg-gray-900 text-white" : "bg-white text-gray-700"}`}
class={`px-2 py-1 ${format === "compact" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`}
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"}`}
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 ${format === "json" ? "bg-gray-900 text-white" : "bg-white text-gray-700"}`}
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"}`}
onclick={() => (format = "json")}
title="Raw envelope JSON. Best for .well-known/kez.json and developer tooling."
>JSON</button>
</div>
</div>
<pre class="text-xs bg-gray-900 text-gray-100 rounded p-4 overflow-x-auto font-mono leading-relaxed max-h-96 overflow-y-auto">{format === "markdown" ? toMarkdown(envelope) : toPrettyJson(envelope)}</pre>
{#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>
{: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>
{#if format === "compact"}
<p class="text-xs text-gray-500">
{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>
{/await}
<button
class="px-3 py-2 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"