# @nice-code/util — documentation Typed storage adapters and crypto helpers. This file concatenates only the @nice-code/util pages of https://nicecode.io — point a local AI coding assistant at it when you're working with just this package. For the full set (every nice-code package and how they fit together) use /llms.txt. Generated from src/content/docs — do not edit by hand. --- # Crypto Source: /nice-util/crypto Description: WebCrypto helpers — Ed25519 signing, X25519 exchange, AES-GCM, and a client link. Crypto helpers built on WebCrypto, so they run the same in browsers, Workers / Durable Objects, Bun, and Node. When a key is turned into a string, that string carries its own description (`::::`) — so wherever you store or send a key, it always knows how to load itself back in. > Crypto helpers require the `@scure/base` peer dependency. ## Canonical JSON + SHA-256 — deterministic bytes for hashing and signing `stringifyCanonicalJson` turns equal data into equal bytes regardless of key insertion order — the primitive under content hashes, idempotency keys, dedup, and signed challenges. `sha256Hex` / `sha256Base64` are synchronous SHA-256 (usable at module-evaluation time, where `crypto.subtle.digest` can't go), and the `hashCanonicalJsonSha256*` helpers combine the two: ```ts import { hashCanonicalJsonSha256Hex, sha256Hex, stringifyCanonicalJson, } from "@nice-code/util"; stringifyCanonicalJson({ b: 1, a: 2 }); // '{"a":2,"b":1}' — insertion order never matters sha256Hex("abc"); // "ba7816bf…" — also accepts Uint8Array; sha256Base64 for standard padded base64 const idempotencyKey = hashCanonicalJsonSha256Hex({ op: "transfer", amount: 5 }); ``` The rules are an **exactly-documented project contract** (not RFC 8785/JCS — no claim of JCS number-formatting conformance is made): native `JSON.stringify` spelling for scalars (`-0` → `0`), own enumerable string keys sorted by UTF-16 code unit, `undefined` object entries dropped, `toJSON` never invoked, array order kept with sparse holes as `null` — while non-finite numbers, bigints, functions/symbols, explicit `undefined` array elements, and cycles all throw. The full rule set lives on the function's JSDoc, and frozen byte-level vectors in the library's tests pin every one of these behaviors: **any change to them is a breaking change**, because consumers sign these bytes. The strict `TCanonicalJsonValue` type expresses the safe input shape for signing APIs. Two cautions: there is **no built-in byte or depth ceiling** — bound and validate untrusted input *before* canonicalizing it; and if you already ship your own canonical serializer as part of a wire contract, keep yours authoritative until you have cross-checked vectors byte-for-byte — "canonical JSON" implementations differ in exactly the edge cases that break signatures. ## Canonical challenges — unambiguous signing Joining challenge parts with a separator (`signChallenge(["nonce", ciphertext])` → `"nonce::…"`) is a known ambiguity class: the parts have no escaping, so `["a::b"]` and `["a","b"]` sign identical bytes, and two operations' challenges can collide unless every call site remembers its own fixed tag. The canonical challenge form removes the whole class — a **domain tag** namespaces the operation, a **version** namespaces the format, and canonical-JSON fields give exact boundaries: ```ts import { buildCanonicalChallenge, ClientCryptoKeyLink, type ICanonicalChallenge } from "@nice-code/util"; const challenge: ICanonicalChallenge = { domainTag: "my-app-action-submit", // one per operation version: 1, // bump when this challenge's field contract changes fields: { nonce, payloadHash, seq }, // pre-hash large members; strict JSON values only }; const link = new ClientCryptoKeyLink(); const { signatureBase64 } = await link.signChallengeCanonical(challenge); // The verifier passes the same STRUCTURED challenge — both ends build the bytes one way: const isValid = await peerLink.verifyChallengeCanonicalFromLinkedClient({ linkedClientId, challenge, signatureBase64, }); ``` `buildCanonicalChallenge` (the shared byte-construction both methods call) validates its input strictly — empty tags, non-positive/unsafe versions, `undefined` entries, sparse arrays, and non-plain objects are rejected rather than normalized: a signing API must not guess. The older `signChallenge` / `signCombinedTextDataWithKeyEd25519` surface keeps working byte-for-byte (deployed contracts depend on it), and nice-wire's handshake challenge is likewise unchanged — prefer the canonical form for every **new** signing surface. ## Ed25519 — sign & verify ```ts import { generateEd25519KeyPair, importEd25519Key, serializeEd25519Key_Raw, signTextDataWithKeyEd25519, verifyWithKeyEd25519, } from "@nice-code/util"; import { base64 } from "@scure/base"; const keyPair = await generateEd25519KeyPair(); // Sign const signature = await signTextDataWithKeyEd25519("challenge-text", keyPair.privateKey); const signatureBase64 = base64.encode(signature); // Serialize the public key for transport — "ed25519::raw_base64::" const { prefixed } = await serializeEd25519Key_Raw(keyPair.publicKey); // Other side: import + verify const publicKey = await importEd25519Key.public.fromFormattedString.extractable(prefixed); const isValid = await verifyWithKeyEd25519({ challenge: "challenge-text", signatureBase64, publicKey, }); ``` ## X25519 + AES-GCM — shared-key encryption Derive a shared AES-GCM key from two X25519 key pairs (ECDH + HKDF), then encrypt/decrypt: ```ts import { generateX25519KeyPair, createAesGcmKeyFromX25519Keys, encryptTextDataWithAesGcmKey, decryptTextDataWithAesGcmKey, } from "@nice-code/util"; const alice = await generateX25519KeyPair(); const bob = await generateX25519KeyPair(); // Both sides derive the same key from their private + the other's public key const aliceKey = await createAesGcmKeyFromX25519Keys({ internalX25519PrivateKey: alice.privateKey, externalX25519PublicKey: bob.publicKey, saltString: "optional-session-salt", infoString: "optional-context", }); const payload = await encryptTextDataWithAesGcmKey({ aesGcmKey: aliceKey, dataToEncrypt: "secret message", }); // { nonce, ciphertext } — both base64 const plaintext = await decryptTextDataWithAesGcmKey({ aesGcmKey: bobKey, dataToDecrypt: payload, }); ``` ## ClientCryptoKeyLink — full client-to-client crypto A high-level class that holds your local identity (an Ed25519 sign/verify pair plus an X25519 exchange pair) and the links to other clients. It can optionally save all of this through any `StorageAdapter`. ```ts import { ClientCryptoKeyLink, createMemoryStorageAdapter_json } from "@nice-code/util"; const link = new ClientCryptoKeyLink({ storageAdapter: createMemoryStorageAdapter_json(), // optional — omit for in-memory only }); await link.initialize(); // Share these with the other side (serialized prefixed strings) const { verifyPublicKey, exchangePublicKey } = await link.getLocalPublicKeys(); // Register the other side's keys await link.linkClient({ linkedClientId: "client::partner-1", verifyPublicKey: theirVerifyKey, exchangePublicKey: theirExchangeKey, bindVerifyKeysIntoDerivation: true, // a tampered relayed key makes the first decryption fail }); // Sign + encrypt for the linked client (shared key derived & cached automatically) const { encryptedData, signatureBase64 } = await link.signAndEncryptDataForLinkedClient({ linkedClientId: "client::partner-1", dataToEncrypt: "hello", }); // Other side: decrypt + verify in one call const { data, isValid } = await otherLink.decryptAndVerifyDataFromLinkedClient({ linkedClientId: "client::me", dataToDecrypt: encryptedData, signatureBase64, }); ``` This is the same `ClientCryptoKeyLink` used across the stack to back the crypto identity of secure connections — including the `identityMode: "required"` provisioning described under [Security Levels](/nice-wire/security/#secure-http-needs-no-shared-memory-between-requests). ## TypeScript utilities ```ts import type { StringKeys } from "@nice-code/util"; type Keys = StringKeys<{ a: string; b: number; 0: boolean }>; // → "a" | "b" ``` --- # Typed Storage Source: /nice-util/storage Description: Fully typed, async, key-prefixed storage over any backend. `@nice-code/util` is the quiet foundation the rest of the stack stands on: one typed storage interface over any backend (browser, Durable Object, memory), plus the WebCrypto that authenticates every secure connection. You rarely import it directly — but a realm's identity persists through it, and a secure handshake is built on it. This page is the storage half; [Crypto](/nice-util/crypto/) is the other. ```bash bun add @nice-code/util ``` ## ITypedStorage `ITypedStorage` is a typed key/value store you can put on top of any backend. Every key and value is typed, every method is async, and all your keys get a shared prefix so they don't collide with anything else. ```ts import { createTypedWebLocalStorage } from "@nice-code/util"; interface IAppStorage { user_id: string; theme: "light" | "dark"; recent_searches: string[]; } const storage = createTypedWebLocalStorage({ localStorage, keyPrefix: "app:", }); // All keys autocomplete; values are typed await storage.setJson("theme", "dark"); const theme = await storage.getJson("theme"); // "light" | "dark" | undefined const userId = await storage.getJsonOrDef("user_id", "guest"); // string // Read-modify-write in one call await storage.updateJsonWithDef("recent_searches", [], (cur) => [...cur, "query"]); await storage.removeItem("theme"); await storage.clearAll(); // removes only keys this storage has written ``` ## Schema-validated keys (opt-in) Without schemas, typed storage trusts the cast: `getJson` returns whatever was stored, *typed as* `T[K]`. That's fine for values only your own code writes — but durable storage is where stale shapes live longest. A value written by version N and read by version N+3 has silently crossed three schema evolutions with zero runtime checking; the type lie only surfaces wherever the value finally misbehaves. Pass a `schemas` map (any [Standard Schema](https://github.com/standard-schema/standard-schema) library — Valibot, Zod, …) and the storage boundary gets the same treatment the action layer gives the wire — validated on the way out **and** in, fail-closed: ```ts import { createDurableObjectTypedStorage, StorageValidationError } from "@nice-code/util"; import * as v from "valibot"; // A persisted discriminated union: exact keys, and an unknown mode FAILS CLOSED — it can never // load as legacy. v.strictObject + v.variant is the pattern for versioned durable state. const vBridgeState = v.variant("mode", [ v.strictObject({ mode: v.literal("single_action"), actionId: v.string() }), v.strictObject({ mode: v.literal("multi_action_v1"), turnSeq: v.number() }), ]); interface IBridgeStorage { state: v.InferOutput; } const storage = createDurableObjectTypedStorage({ durableObjectStorage: ctx.storage, schemas: { state: vBridgeState }, onInvalid: (error) => report(error), // observation only — the operation still throws }); ``` With a schema present on a key: - **Reads** (`getJson`) validate the stored value; **defaults** (`getJsonOrDef`, `updateJsonWithDef`) validate whichever value is actually used, so a bad fallback never escapes. - **Writes** (`setJson`) validate before the adapter sees the value — a bad write is caught at the writer, not the next reader. - **Updates** validate in both directions: the updater never receives invalid stored data, and its result validates before persisting. - The schema's **output** (`result.value`) is what is returned *and* persisted, so transforms/defaults can never make storage and readers disagree. - Failures throw `StorageValidationError` (carrying `key` + `issues`); `onInvalid` observes but never suppresses. Validation is synchronous-only — an async schema fails validation. Keys without a schema keep the blind-cast behavior, and JSON parse errors stay their own class. ## Adapters ```ts import { createTypedWebLocalStorage, createTypedWebSessionStorage, createDurableObjectTypedStorage, createKVTypedStorage, createTypedMemoryStorage_string, createTypedMemoryStorage_json, } from "@nice-code/util"; // Browser const local = createTypedWebLocalStorage({ localStorage, keyPrefix: "app:" }); const session = createTypedWebSessionStorage({ sessionStorage }); // Cloudflare Durable Objects (inside a DO class) const doStorage = createDurableObjectTypedStorage({ durableObjectStorage: ctx.storage, keyPrefix: "do:", }); // Cloudflare KV (a namespace binding off `env`) const kv = createKVTypedStorage({ kvNamespace: env.MY_KV, keyPrefix: "app:", defaultPutOptions: { expirationTtl: 3600 }, // applied to every put — TTL-based eviction }); // In-memory (testing / SSR) — string-serialized or JSON-native const mem = createTypedMemoryStorage_string(); const memJson = createTypedMemoryStorage_json(); // Share state between instances by passing the same Map const shared = new Map(); const a = createTypedMemoryStorage_string({ memoryStorageMap: shared }); const b = createTypedMemoryStorage_string({ memoryStorageMap: shared }); ``` KV is **eventually consistent**, so it is the wrong home for a connection's crypto identity — a read-your-writes gap there reads as a regenerated keypair and pins mismatch forever (see [Identity & Trust](/nice-wire/identity/)). Use DO storage for identity; KV is for app data that tolerates the lag. Each adapter also has a lower-level `createKVStorageAdapter` / `createDurableObjectStorageAdapter` / `createWebSessionStorageAdapter` form returning a bare `StorageAdapter` — that is what you hand to anything asking for an adapter rather than a typed store (a connection's `storage`, for instance). ## The interface ```ts interface ITypedStorage> { getJson(key: K): Promise; getJsonOrDef(key: K, defVal: T[K]): Promise; setJson(key: K, val: T[K]): Promise; updateJson(key: K, updater: (cur: T[K] | undefined) => T[K]): Promise; updateJsonWithDef(key: K, defVal: T[K], updater: (cur: T[K]) => T[K]): Promise; removeItem(key: K): Promise; clearAll(): Promise; } ``` ## Wrapping your own backend Fill in the methods interface to put typed storage on top of any backend (Redis, S3, …): ```ts import { createTypedStorage, EStorageAdapterType, StorageAdapter, type IStorageAdapterMethods_String, } from "@nice-code/util"; const redisMethods: IStorageAdapterMethods_String = { type: EStorageAdapterType.string, getItem: async (key) => redis.get(key), setItem: async (key, value) => { await redis.set(key, value); }, removeItem: async (key) => { await redis.del(key); }, }; const storage = createTypedStorage({ storageAdapter: new StorageAdapter({ methods: redisMethods, keyPrefix: "app:" }), }); ``` You can also use the lower-level `StorageAdapter` directly (with untyped keys), including `createJsonGetterSetter(key)` for one key and `withKeyPrefix(prefix)` for a child namespace. A child inherits its parent's `trackKeysForClearing` policy: deriving from an intentionally untracked adapter never silently creates a `__usedKeys__` index.