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:
parent
a9feb1b5b2
commit
17d68dbb75
7
kez-chat/web/package-lock.json
generated
7
kez-chat/web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user