Files
Shade/CHANGELOG.md
Sterister 594992a183 release(v4.7.0): peer-presence events for instant BroadcastChannel revoke
Adds the bridge-connection-lifecycle signal that closes Prism's
~45s revoke window down to one server→client round-trip (~50ms).

Server (`@shade/inbox-server`):
- `inbox.peer_connected` / `inbox.peer_disconnected` events on the
  0↔1 boundary across WS + SSE bridges. Long-poll deliberately not
  tracked (every poll boundary would flap; push transports are also
  the only ones where instant revoke matters).
- `PresenceTracker` collapses two parallel bridges (e.g. WS + SSE
  during fallback handover) into one connect/disconnect pair.
- `GET /v1/bridge/presence` SSE endpoint: signed query with
  `kind: 'presence'`, `watched: string[]`; on open streams a
  per-address snapshot, then change frames filtered server-side.
  MAX_WATCHED_ADDRESSES = 64. Subscribing does not itself count as
  a peer-bridge connection.
- `createBridgeRoutes` now returns `{ app, websocket, presence }`.

Client (`@shade/transport-bridge`):
- `PresenceBridge.subscribe({ watch, onPresenceChange })` →
  `{ addPeer, removePeer, watching, unsubscribe }`. addPeer/removePeer
  mutate via reconnect with a fresh signed query.
- `signPresenceQuery` helper for non-PresenceBridge consumers.

Tests cover all four acceptance criteria from the Prism request:
server-event smoke, online→offline subscription, address scoping
(carol invisible to a [alice]-only sub), reconnect, plus an
addPeer/removePeer regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:16:35 +02:00

84 KiB
Raw Blame History

Changelog

All notable changes to Shade are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[4.7.0] — 2026-05-07 — Peer-presence events for instant BroadcastChannel revoke

BroadcastChannel.removeMember (v4.6) is the right primitive for revoking a paired peer's sender-key membership when, say, a tab closes or a laptop locks — but until now there was no signal saying "this peer's bridge just went away". Apps had to fall back to client-side heartbeats: apps/web/src/shade/heartbeat.ts-style 20s pings + a 10s GC sweep, with a ~45s worst-case revoke window. For a terminal-mirroring product whose threat model includes "someone takes the unattended laptop", 45s of legitimate broadcast access for the attacker is too long.

This release surfaces the bridge-connection-lifecycle signal that createBridgeRoutes already had internally. The inbox event bus now emits inbox.peer_connected / inbox.peer_disconnected on the 0↔1 boundary across WS + SSE bridges, and a new /v1/bridge/presence SSE endpoint plus the PresenceBridge client class let any authenticated SDK subscribe to presence transitions for a watcher-declared address list. The SDK glue collapses to ~5 lines:

const sub = await new PresenceBridge({ baseUrl, crypto, signingPrivateKey, address }).subscribe({
  watch: paired_peers,
  onPresenceChange: (e) => {
    if (e.status === 'offline') void channel.removeMember(e.address);
  },
});

Reported by Prism — collapses Prism's wave-3 heartbeat-based revoke from ~45s to ~50ms (one network round-trip) for the overwhelmingly common case of a clean WS close.

Added

@shade/inbox-server

  • InboxServerEventMap gains two new event names:
    • inbox.peer_connected{ address, bridgeKind: 'ws' | 'sse' } — fires when an address transitions from zero to ≥1 active push-bridge connections.
    • inbox.peer_disconnected{ address, bridgeKind, reason: 'closed' | 'error' } — fires when the last push-bridge connection for the address closes.
  • New PresenceTracker class (packages/shade-inbox-server/src/presence.ts) — per-address connection-count map; emits transitions into a wired InboxServerEvents. Two parallel bridges (WS + SSE during a fallback handover) collapse into one peer_connected / peer_disconnected pair so consumers don't see flicker.
  • createBridgeRoutes now returns { app, websocket, presence } so operators / tests can read the live presence map. A presenceTracker option lets multiple route mounts share state.
  • New GET /v1/bridge/presence SSE endpoint:
    • Auth: signed query { address, kind: 'presence', watched: string[], signedAt, signature } against the watcher's registered owner key. kind: 'presence' is bound into the canonical signed payload to prevent cross-endpoint replay against /v1/bridge/{stream,poll,ws}.
    • On open: emits one event: presence SSE frame per watched address with the current online/offline snapshot.
    • On change: streams { address, status, at, via: 'ws'|'sse' } frames filtered server-side to the watcher's address list.
    • Subscribing does NOT itself count as a peer-bridge connection — a PresenceBridge open will not make the watcher appear online to other watchers.
    • MAX_WATCHED_ADDRESSES = 64 per subscription.

@shade/transport-bridge

  • New PresenceBridge class with subscribe({ watch, onPresenceChange, onError? }) returning { addPeer, removePeer, watching, unsubscribe }.
  • addPeer / removePeer mutate the watched set by aborting the current SSE connection so the run loop reopens with a fresh signed query. Mutations are expected to be rare (only on pair / unpair) so the brief reconnect gap is acceptable.
  • Auto-reconnect with exponential backoff (250ms → 10s, same defaults as SseBridge); disableAutoReconnect: true for tests.
  • signPresenceQuery helper exported from @shade/transport-bridge/auth for non-PresenceBridge consumers (manual EventSource, observability scrapers, etc.).

Why long-poll is NOT tracked

A long-poll client toggles in/out of /v1/bridge/poll every few seconds, and treating each request boundary as a presence transition would dominate the event stream with flapping. Push transports are also the only ones where a ~50ms revoke window matters — long-poll users are already on a slow path. Apps that need presence over long-poll continue to use client-side heartbeats.

Tests

  • packages/shade-transport-bridge/tests/bridge.test.ts — four blocks covering all acceptance criteria from the request:
    • (1) WsBridge.connect() then disconnect() → operator's events.on(...) sees inbox.peer_connected then inbox.peer_disconnected with address: 'alice', bridgeKind: 'ws'.
    • (2A) Bob subscribes presence on [alice]; alice opens a WsBridge → bob's onPresenceChange fires online within 2s.
    • (3) Bob's [alice] subscription must NOT receive frames for an unrelated carol address opening her own bridge.
    • (4) Alice's bridge reopens after a drop → bob sees online again on the same subscription.
    • Plus an addPeer / removePeer regression that verifies the reconnect-on-mutation path delivers a fresh snapshot for the new address and stops delivering for the removed one.

Migration

None. Strict additive — existing InboxServerEvents consumers keep working unchanged. createBridgeRoutes's return type added a presence field; destructuring code that names only app, websocket keeps compiling.

For Prism specifically: drop the wave-3 heartbeat module (apps/web/src/shade/heartbeat.ts) on the PC sidecar and replace with a PresenceBridge subscription on the paired-peer set. Keep the heartbeat as a network-partition fallback if you want a belt-and- braces revoke story; with presence-events the worst-case revoke window drops from ~45s to one server→PC round-trip.

[4.6.1] — 2026-05-07 — Browser fetch receiver lost in Inbox and HTTP bridges

Every browser consumer of the v4.6.0 transport stack crashed on the first network call with:

Failed to execute 'fetch' on 'Window': Illegal invocation

@shade/inbox, @shade/transport-bridge (SseBridge, LongPollBridge) each cached the default globalThis.fetch reference as a class property and later invoked it as this.fetchImpl(url, …) / this.fetchFn(url, …). The browser's fetch is a WebIDL bound operation: calling it as a method on any object other than the Window rejects with the error above. Node/Bun fetch tolerates a free receiver, so the bug only manifested in actual browsers and slipped through the SDK test suite.

Reported by Prism (multi-device E2EE terminal) — inbox.start()register()client.register()this.fetchImpl(url, …) threw on the first /v1/inbox/register POST, so transport.start() never sent the pair handshake and the web side timed out after 30s with "PC did not reply".

Fixed

@shade/inboxInboxClient constructor

fetchImpl is now (options.fetch ?? globalThis.fetch).bind(globalThis). A consumer-supplied options.fetch is bound too — a custom fetch with its own receiver requirements must bind itself; binding to globalThis is otherwise a no-op for free functions.

@shade/transport-bridgeLongPollBridge and SseBridge constructors

Same binding fix in both. WsBridge was unaffected (uses WebSocket).

Tests

  • packages/shade-inbox/tests/client.test.ts — installs a strict-receiver globalThis.fetch that mimics the WebIDL "Illegal invocation" check, constructs InboxClient with no fetch override, runs register(), and asserts the strict fetch saw globalThis as this. Pre-fix this throws; post-fix it passes.
  • packages/shade-transport-bridge/tests/bridge.test.ts — same regression for both LongPollBridge.connect() (probe call) and SseBridge.connect() (open-once call).

Migration

None. Existing options.fetch overrides keep working unchanged. Apps shipping a workaround like

new Inbox({ ..., fetch: globalThis.fetch.bind(globalThis) });

can drop the .bind(globalThis) and the redundant fetch: option once they're on 4.6.1.

[4.6.0] — 2026-05-07 — Broadcast channels (Signal sender-keys for one-to-many fan-out)

Prism's PC desktop is the sender in a one-to-many fan-out — one PTY output frame, N paired-device deliveries — and bilateral for (peer of peers) shade.send(peer, frame) works for N ≤ 5 but starts hurting once the paired fleet grows (3 laptops + phone + tablet + watch = N = 7) and once mobile cellular is in the loop. The crypto pattern that solves it is Signal's sender-key: the sender holds a per-channel symmetric chain key shared with all members, encrypts each message once with it, and the relay (or the SDK fan-out loop) ships the same ciphertext to every recipient.

This release lands sender-key broadcast as a scoped "broadcast channel" primitive in @shade/sdk, with the persistence + wire format + receiver- side meta.kind === 'broadcast' plumbing wired through every backend. The crypto in @shade/core/sender-keys.ts was already in place; v4.6 turns it into a first-class app-facing API.

Added

@shade/sdk

  • shade.createBroadcastChannel({ label? })BroadcastChannel — opaque, persisted channel id stable across shutdown() / re-open. Owner role: sender (only the channel creator can broadcast).
  • BroadcastChannel.addMember(peerAddress) — distributes the current sender-key to a paired peer over the existing bilateral ratchet. Returns the wrapped envelope the app delivers; the SDK does the framing inline (no new wire-format changes visible to apps — acceptance criterion (3)).
  • BroadcastChannel.removeMember(peerAddress) — rotates the chain (fresh chainKey + new Ed25519 signing keypair, generation++), destroys the old key material, and returns one envelope per surviving member with the new sender-key. Stale broadcasts at lower generations are silently dropped on receive.
  • BroadcastChannel.broadcast(plaintext) — single AES-256-GCM encrypt with the current chain message key + Ed25519 signature; the SAME envelope is delivered to every member. Returns { envelope: Uint8Array, members: readonly string[] } so the app's transport handles the per-peer fan-out.
  • BroadcastChannel.members() — snapshot of currently-active members (excludes revoked).
  • shade.getBroadcastChannel(channelId) / shade.listBroadcastChannels() for reconciling app-level pairing state with persisted channel state.
  • shade.acceptBroadcast(envelope) — decrypt an inbound broadcast wire envelope; dispatches to onMessage handlers with meta = { kind: 'broadcast', channelId, sender, generation, iteration }.
  • Shade.onMessage handler signature gained an optional third arg meta?: MessageMeta — back-compat: handlers that ignore it keep working unchanged for direct messages.

@shade/proto

  • encodeBroadcast(BroadcastWire) / decodeBroadcast(bytes) — wire type 0x21. Length-prefixed channelId + senderAddress, u32 generation/iteration, 12-byte AES-GCM nonce, 64-byte Ed25519 signature, length-prefixed ciphertext.
  • inspectEnvelopeType recognises 'broadcast'.

@shade/core

  • BroadcastChannelRecord — persisted channel state (chainKey, iteration, signing keys, generation, role).
  • BroadcastMemberRecord — sender-side membership row with joinedAt
    • nullable removedAt.
  • StorageProvider gained six optional methods: saveBroadcastChannel, getBroadcastChannel, listBroadcastChannels, removeBroadcastChannel, saveBroadcastMember, getBroadcastMembers, removeBroadcastMember. Backends < 4.6 throw a clear error when an app tries to call createBroadcastChannel against them.

Storage backends

  • MemoryStorage, SQLiteStorage, IndexedDBStorage — plaintext broadcast_channels + broadcast_members tables. IDB schema bumps to v2 with an upgrade-path that creates the new stores idempotently.
  • EncryptedSQLiteStorage, EncryptedIndexedDBStorage, EncryptedPostgresStoragebroadcast_channels_enc + broadcast_members_enc schemas. The chain key, iteration, and signing-key bundle live in a sealed ciphertext blob bound to (table='broadcast_channels', column='broadcast_channel_sensitive', pk=channelId) AAD; routing fields (channelId, ownerRole, ownerAddress, label, generation, timestamps) stay plaintext for queries. New row-codec helpers sealBroadcastChannelSensitive / openBroadcastChannelSensitive. IDB schema bumps to v2 the same way.

Tests

  • packages/shade-sdk/tests/broadcast.test.ts — Prism's three acceptance tests verbatim: (1) two-member receive with meta.kind === 'broadcast', (1*) revocation rotates + receiver A drops while B keeps working, (2) persistence — channel id, members, and chain advance survive shutdown() + re-open from the SQLite path, (3) listBroadcastChannels surfaces both sender + receiver records correctly.
  • packages/shade-storage-encrypted/tests/encrypted-sqlite.test.ts — channel + member round-trip under sealed storage; receiver-side rows correctly persist without signingPrivateKey.

Compatibility

  • Wire-protocol additive: existing peers ignore the 0x21 envelope type. Apps not using broadcast channels see no behavior change.
  • Storage schemas additive: the new broadcast_* tables / object stores are created on first open; migrations from a 4.5 database are no-ops. IDB schema-version bump happens transparently in upgrade.

[4.5.0] — 2026-05-07 — Browser-side encrypted storage + multi-factor unlock

Browser-based Shade clients (Prism's web client being the first) needed the same at-rest encryption story as the desktop SQLite path: identity, prekeys, sessions and stream-resume state persisted across reloads, unwrapped from a user-supplied passphrase — and on browsers, optionally gated behind a second factor (PIN) since there is no OS-session boundary to lean on. The existing barrel of @shade/storage-encrypted also transitively imported bun:sqlite and postgres, which prevented Vite/ webpack/esbuild from producing a clean browser bundle.

This release adds an encrypted IndexedDB backend that mirrors EncryptedSQLiteStorage byte-for-byte at the AAD/nonce level, exposes browser-safe subpath imports, and lets KeyManager derive its master key from low-entropy secrets (argon2id) and from N composed factors (every factor mandatory).

Added

@shade/storage-encrypted

  • EncryptedIndexedDBStorage — IndexedDB-backed StorageProvider exposed via @shade/storage-encrypted/idb. One object store per _enc table from the SQLite schema, sealed payloads as Uint8Array, routing/timestamp fields kept plaintext for query efficiency. Reuses aeadSeal/aeadOpen and the row-codec sealers verbatim — a row sealed under the SQLite or Postgres backend decrypts under IDB given the same KeyManager. bumpPeerIdentityVersion is atomic under one IDB transaction (closes the read-then-upsert race the SQLite version has).
  • KeyManager.open({ kind: 'argon2id', ... }) — memory-hard KDF for low-entropy secrets (PINs, short passwords). Backed by @noble/hashes/argon2 (already a transitive dep — pure JS, browser safe). DEFAULT_ARGON2ID exported (m=64 MiB, t=3, p=1, 32-byte output; ~250400 ms in modern browsers).
  • KeyManager.open({ kind: 'composite', sources, info? }) — HKDF-combine N sub-sources into one master key. Every source is required: omitting or substituting any source yields a different master key and open() fails on the storage-key-fingerprint check. Order is significant by design ([pwd, pin][pin, pwd]). Composite-of-composite is rejected.
  • Subpath exports: @shade/storage-encrypted/crypto (KeyManager + KDF
    • AEAD + row-codec, no SQLite/Postgres bindings), /sqlite (Bun), /postgres (Node), /idb (browser). The browser condition on the default import resolves to a barrel that excludes Bun/Postgres imports — import { KeyManager } from '@shade/storage-encrypted' now bundles cleanly under Vite without hitting bun:sqlite resolution errors.
  • Dependency: idb ^8.0.3.

Tests

  • packages/shade-storage-encrypted/tests/key-manager-multi-factor.test.ts — argon2id determinism + reject paths, composite same-factors → same master, wrong-PIN/wrong-passphrase/order-swap → different master, explicit info domain separation, nested-composite rejection.
  • packages/shade-storage-encrypted/tests/encrypted-indexeddb.test.ts — full round-trip coverage of all 28 StorageProvider methods, fingerprint-mismatch rejection on wrong key, atomic peer-identity bump, plus cross-impl roundtrip with EncryptedSQLiteStorage proving the AAD/nonce derivation is implementation-agnostic.

[4.4.0] — 2026-05-05 — Public accessor for the device's identity public key

Browser-based Shade consumers building enrollment flows had no way to hand the device's actual Ed25519 identity public key to their own backend — the key was reachable only via the private storage.getIdentityKeyPair() call inside Shade. Apps shipped with placeholder bytes (crypto.getRandomValues(new Uint8Array(32))) that the backend stored but couldn't verify against, deferring real cryptographic device binding until the SDK exposed the key.

Added

@shade/sdk

  • Shade.identityPublicKey: Promise<Uint8Array> — getter returning the local device's 32-byte Ed25519 identity public key. Mirrors the fingerprint accessor shape. Throws if accessed before initialize(). Reflects the current key after rotate(); the previous key remains in retired-identities storage for the configured grace period. Use fingerprint (12-group safety number) for human side-channel comparison; use identityPublicKey when handing the raw key to a backend for signature verification or pinning.

Tests

  • packages/shade-sdk/tests/sdk.test.tsidentityPublicKey exposes the device Ed25519 key and tracks rotation covers the round-trip match against the underlying storage and that the value updates after rotate().

[4.3.0] — 2026-05-05 — Browser persistence via @shade/storage-indexeddb

Browser-based Shade consumers had no path to session persistence: the only storage option that worked outside Node was "memory", so the identity keypair regenerated on every page load and device:${registrationId} churned to a fresh address each refresh. Building a StorageProvider in consumer-land meant 25+ method re-implementations per app and no shared conformance surface.

4.3.0 ships an official IndexedDB adapter alongside SQLite and Postgres so any browser-based Shade SDK consumer (dashboards, contact-list apps, browser-extension messengers) gets persistent identity, prekeys, sessions, retired identities, peer-verification state and stream-resume rows for free, surviving tab refresh and browser restart.

Added

@shade/storage-indexeddb (new package)

  • IndexedDBStorage.create({ dbName? }) — async open of an IDB database (one object store per StorageProvider category) with schema version 1. dbName defaults to "shade"; consumers that run multiple Shade-backed apps on the same origin pass distinct names ("my-app-shade") so the IDB inspector groups them sensibly.
  • Full StorageProvider conformance: identity, signed/one-time prekeys, sessions, trusted identities, retired identities (with prune by retiredAt), stream-state save/get/list/prune, peer verifications, and the per-peer identity-version counter.
  • bumpPeerIdentityVersion is wrapped in a single IDB readwrite transaction — atomic read-modify-write, closing the race window the SQLite adapter currently has on parallel acceptIdentityChange calls. (SQL adapters will be brought in line in a follow-up.)
  • Implementation dependency: idb (Jake Archibald's typed wrapper). Tests run against fake-indexeddb for parity with the SQLite test layout.

@shade/sdk

  • resolveStorage() accepts a fourth spec form: { type: 'indexeddb', dbName?: string }. Resolution goes through a dynamic import so Node-only consumers don't pull a browser-only adapter into their bundle (same pattern as @shade/storage-postgres).
  • ShadeConfig['storage'] now exports a named StorageSpec type reused by ResolvedConfig, replacing the duplicated inline union.

Tests

  • packages/shade-storage-indexeddb/tests/indexeddb-storage.test.ts — full StorageProvider surface (identity, prekeys, sessions, trust, retired identities, persistence across close+reopen) plus an end-to-end ShadeSessionManager conversation that survives a simulated tab reload mid-session.
  • packages/shade-storage-indexeddb/tests/peer-verifications.test.ts — CRUD round-trip, upsert-on-duplicate, identity-version increment invariants, persistence across reopen.

[4.2.1] — 2026-05-04 — Concurrent-ratchet desync under pull-mode drainer

A consumer running shade.files.httpClient(server, { outboundQueueUrl, ... }) alongside parallel RPC traffic against the same peer would, after ~10s of load, see every subsequent message fail with DecryptionError: Failed to decrypt message — wrong key or tampered data. Two bugs combined to cause this; both are fixed in 4.2.1 with regression coverage.

Fixed

@shade/transferOutboundQueue waiter cursor

enqueue woke pending drain waiters with a since=0 snapshot — the full event log — instead of using the waiter's own since. A poll that parked at the head and was woken by a fresh enqueue therefore replayed every event the waiter had already processed. Downstream the queue fed Shade.acceptTransferEnvelope, so the duplicate replayed an envelope into manager.decrypt twice. The second decrypt consumed an already-used skipped key and corrupted the Double Ratchet receive chain. Each PendingWaiter now records its since cursor and is delivered only events with id > since.

@shade/coreratchetDecrypt defense-in-depth

A same-DH message whose counter was already behind the chain — and that did NOT match a cached skipped key — fell through to a path that called kdfChainKey on the current (ahead) chain key and then set chain.counter = message.counter + 1, permanently desyncing the ratchet so every subsequent decrypt returned wrong-key. Such messages are now rejected with DecryptionError without any state mutation, so a downstream replay (transport bug, retry, intermitent network) cannot poison the session.

Tests

  • packages/shade-files/tests/integration/concurrent-ratchet.test.ts — 100 parallel httpClient RPCs while the drainer runs, plus a mixed workload of 50 RPCs + 50 raw shade.send deliveries with Bob echoing replies through the queue. Both surface the bug pre-fix.
  • packages/shade-transfer/tests/outbound-queue.test.ts — direct regression on the waiter since cursor.
  • packages/shade-core/tests/ratchet.test.ts — replay of an already-decrypted message must throw cleanly without breaking subsequent decrypts on the same chain.

[4.2.0] — 2026-05-03 — Pull-mode streams for browser @shade/files

4.1.0 shipped HTTP RPC for browser clients but capped them at inline payloads (≤ 256 KiB). Larger reads/writes — mod-jars (150 MB), world-backups (100+ MB), the things that actually need streaming — threw ConflictError directing callers to the server-to-server pathway. That made browser-side @shade/files insufficient for admin-panel-style apps where the client is a browser tab and the server is a Bun process.

4.2.0 flips the direction: when the browser supplies outboundQueueUrl + transferBaseUrl, server-to-browser chunks + control envelopes ride a per-peer queue that the browser long-polls, and browser-to-server chunks POST directly to the server's existing chunk-receive routes. No WebSockets, no SSE, no inbound listener on the browser. Long-polling + a request-response inbound queue is the entire wire surface.

Added

@shade/transfer

  • OutboundQueue — per-peer monotonic event log with long-poll semantics. enqueue(peer, event) appends, drain(peer, since, blockMs, signal) returns events with id > since (blocking up to blockMs if none are ready). Idle-eviction GC drops peers that haven't polled in idleEvictionMs (default 10 min). Ring- buffered to maxEventsPerPeer (default 1000) — overflow drops oldest, receivers pick up the gap via re-resume from since=0.
  • QueuedEvent discriminated union: { kind: 'envelope', bytes } or { kind: 'chunk', bytes, meta: { streamId, laneId, seq } }.
  • QueueTransferTransport (implements ITransferTransport) — enqueues outbound chunks instead of POSTing. Returns optimistic ChunkAck because the queue is the delivery; chunk-resume picks up dropped events on receiver-side reconnect.

@shade/sdk

  • Shade.transferQueueRoute(opts?) — Hono app with all five routes a pull-mode receiver needs:
    • POST /queue — long-poll the per-peer outbound queue.
    • POST /v1/transfer/:streamId/chunk — receive incoming chunks (browser → server writes).
    • GET /v1/transfer/:streamId/state — resume-state lookup.
    • POST /v1/transfer/control — receive incoming control envelopes (browser → server stream-init / abort).
    • GET /v1/transfer/health — peer reachability probe. Auto-configures shade.configureTransfers(...) with the queue transport + QueueEnvelopeTransport if not already configured.
  • Shade.configureTransfers(opts) extended: resolveBaseUrl is now optional when transport and envelopeTransport are both supplied (lets pure-queue servers omit the baseUrl entirely). New transport?: ITransferTransport override slot.
  • QueueEnvelopeTransportControlEnvelopeTransport impl that enqueues outbound envelopes for browser receivers.

@shade/files

  • createFilesHttpClient (and shade.files.httpClient) accept new options:
    • outboundQueueUrl/queue endpoint to long-poll.
    • transferBaseUrl — base URL for outbound chunk POSTs and control envelope POSTs (browser → server writes).
    • queueBlockMs — long-poll timeout (default 30 s; server clamps at maxBlockMs). When set, the client:
    1. Configures shade.configureTransfers({ resolveBaseUrl }) so outbound chunks POST to <transferBaseUrl>/v1/transfer/....
    2. Builds a ClientStreamsBridge eagerly so the engine's incoming-transfer subscription is in place before the drainer dispatches the first envelope.
    3. Starts a long-poll startQueueDrainer(...) that pulls queued events and dispatches them via shade.acceptTransferEnvelope.
  • Streamed reads (fs.read of files > 256 KiB) and streamed writes (fs.write of large inputs) now work end-to-end on the browser client when the queue options are set.
  • startQueueDrainer(shade, opts) exported for advanced consumers that want to drive their own drainer (e.g. service-worker setups that want a single shared drainer across multiple httpClients).
  • client.close() now stops the drainer and tears down the streams- bridge — important on tab unload to free the long-poll socket.

@shade/files (internal)

  • ClientStreamsBridge uses a TransformStream with highWaterMark: 64 instead of the default 0 so the receive-side write loop doesn't stall on backpressure before the consumer attaches its reader (default HWM stalled at chunk 4 in pull-mode where the drainer races the consumer's getReader() call).

Wire contract

POST <base>/queue HTTP/1.1
X-Shade-Sender-Address: alice@example.com
{ "since": 42, "blockMs": 30000 }

────

200 OK
{
  "events": [
    { "id": 43, "kind": "envelope", "bytesB64": "...", "timestampMs": 1730... },
    { "id": 44, "kind": "chunk",    "bytesB64": "...", "meta": { "streamId": "...", "laneId": 0, "seq": 0 } },
    ...
  ],
  "nextSince": 47
}

Tests

tests/integration/http-rpc-streams.test.ts — three integration tests:

  • 4 MiB streamed read end-to-end via long-poll queue (verifies bytes match the source).
  • Inline-only client throws clear error on streamed read.
  • Long-poll returns empty events on idle timeout (verifies the blockMs pathway).

Migration

4.1.0 → 4.2.0 is wire-compatible and source-compatible — the queue route is purely additive. To enable streamed transfers in a browser app:

// Server
const queue = await shade.transferQueueRoute({ blockMs: 30_000 });
await shade.files.serve(handler);
const rpc = shade.files.rpcRoute({ acceptFirstMessage: true });

const app = new Hono();
app.route('/api/v1/shade-files', queue);
app.route('/api/v1/shade-files', rpc);

// Browser
const fs = shade.files.httpClient(serverAddress, {
  rpcUrl: 'https://server/api/v1/shade-files/rpc',
  outboundQueueUrl: 'https://server/api/v1/shade-files/queue',
  transferBaseUrl: 'https://server/api/v1/shade-files',
});
await fs.write('/mods/some-mod.jar', new Uint8Array(/* 50 MB */));
const result = await fs.read('/backups/world.tar.gz'); // streamed

shade.files.serve(handler, { inlineOnly: true }) is still supported for HTTP-RPC-without-streams deployments — it skips the streams-bridge setup entirely.

[4.1.0] — 2026-05-03 — Browser-friendly HTTP RPC for @shade/files

The default shade.files.client(peer) requires both peers to be mutually addressable over HTTP — the response to a list / read / etc. round-trips through Shade.deliverControlEnvelope, which POSTs to the peer's /v1/transfer/control endpoint. That doesn't work for browsers — a tab can't host an HTTP server, so the server cannot call back outbound.

This release ships a parallel request-response transport. One POST per RPC, encrypted envelope in the request body, encrypted response in the same HTTP response. Mirrors the way @shade/server's shade-auth-middleware works for prekey writes.

Added

@shade/files

  • createFilesRpcRoute(shade, handler, options?) — Hono app exposing POST /rpc. Reads X-Shade-Sender-Address, decrypts the envelope via the existing ratchet session, dispatches through the attached FileHandler, encrypts the result, and returns it in the same HTTP response. Transport-level failures (no session, undecryptable, body too big) return JSON { error } with appropriate 4xx; application- level failures ship encrypted RpcError envelopes.
  • createFilesHttpClient(shade, peer, options) — request-response FileClient for browser-style consumers. Each method (list / stat / mkdir / delete / move / getThumbnail / custom / write inline / read inline) does one HTTP POST and parses the encrypted response. No inbound channel required.
  • shade.files.rpcRoute(opts?) — namespace-side getter for the route. Throws if no handler has been attached via shade.files.serve(...) first.
  • shade.files.httpClient(peer, opts) — namespace-side getter for the client.
  • FilesNamespace.serve(handler, { inlineOnly: true }) — opt-out flag that skips the streams-bridge setup. Required for HTTP-RPC-only servers (which don't need configureTransfers({ resolveBaseUrl })). In inlineOnly mode the channel-based dispatcher is also not attached, so requests are dispatched only by the rpc-route — avoids double-dispatch when a browser client and a server-to-server client share the same Shade instance.
  • ShadeBridge (exported) gains a receive(peer, envelope) member matching Shade.receive so server-side rpc-route can decrypt inbound envelopes through the structural surface.

Wire contract

POST /rpc HTTP/1.1
Content-Type: application/octet-stream
X-Shade-Sender-Address: alice@example.com

<wire-encoded ShadeEnvelope wrapping JSON-encoded RpcRequest>

────

200 OK
Content-Type: application/octet-stream

<wire-encoded ShadeEnvelope wrapping JSON-encoded RpcResponse | RpcError>

Limitations (v1)

  • Inline payloads only (≤ 256 KiB). write of larger inputs throws ConflictError directing callers to shade.files.client(peer) on a server-to-server deployment. Streamed read results throw InternalFileError for the same reason.
  • The X3DH first-message must ride the same RPC route — set acceptFirstMessage: true on rpcRoute({ acceptFirstMessage: true }) when the browser client's first-ever call doubles as the handshake.

Tests

  • tests/integration/http-rpc.test.ts — round-trip via HTTP (list / mkdir / stat / write / read / delete) plus negative cases (streamed write rejected, missing sender header, empty body, garbage body, body past maxBodyBytes, rpcRoute() without serve()).

Migration

4.0.x → 4.1.0 is wire-compatible and source-compatible. The HTTP RPC route is purely additive — no existing code path changes. To adopt:

// server (was)
await shade.files.serve(handlerConfig);

// server (HTTP-RPC mode)
await shade.files.serve(handlerConfig, { inlineOnly: true });
app.route('/api/v1/shade-files', shade.files.rpcRoute());

// browser client
const fs = shade.files.httpClient(serverAddress, { rpcUrl: '...' });

[4.0.2] — 2026-05-03 — Consumer-strict reader-shape fixes

4.0.1 shipped the tsc --noEmit gate that compiles each package internally against lib: ["ES2022"]. That gate did not catch types that only fail when consumer code (running with lib: ["DOM"] + exactOptionalPropertyTypes) tries to assign a native browser type into one of our locally-defined narrower types.

This release adds a consumer-strict smoke test to the pre-publish gate and fixes every collision that smoke uncovered.

Fixed

@shade/files

  • inline-threshold.ts: rewrote the local MinimalReader<T> interface as an explicit disjoint union ({ done: false; value: T } | { done: true; value?: T | undefined }) so it accepts every native reader shape — bun-types (value?: undefined), lib.dom (value?: T), and node:stream/web. The previous flat shape was rejected by consumer projects with exactOptionalPropertyTypes: true because the present-branch required value: T. Fixes "Type ReadableStreamReadResult is not assignable to { value: Uint8Array | undefined; done: boolean }".
  • client/streams-bridge.ts, server/streams-bridge.ts: stash the setTimeout(...) return value in a local before calling .unref?.() through an explicit { unref?: () => void } cast. The previous fluent .unref?.() failed under lib: ["DOM"] because DOM types setTimeout to number, which has no .unref even as an optional property.

@shade/sdk

  • background.ts: same setTimeout / setInterval .unref?.() fix.

Tooling

  • New tests/consumer-strict/ — a tiny "as if I were a downstream app" TypeScript project with its own tsconfig.json: lib: ["ES2022", "DOM", "DOM.Iterable"], types: ["bun-types"], exactOptionalPropertyTypes: true, strict: true, paths-mapped to the workspace's packages/*/src/index.ts. Three smoke files exercise @shade/files, @shade/sdk, and @shade/key-transparency against the consumer-strict tsconfig.
  • scripts/typecheck-all.ts now runs the consumer-strict smoke after the per-package internal type-check. Both must pass before prepublish:check (and therefore publish:dry / publish:all) succeeds.

Migration

4.0.1 → 4.0.2 is wire-compatible and source-compatible. No API shape changed; only internal typing was tightened.

[4.0.1] — 2026-05-03 — Strict-TS publishability fixes

4.0.0 shipped TypeScript source files as the published main / types, which meant every consumer's tsc had to compile our code under their own strict settings. Several files only compiled inside the monorepo (where peer-dep cycles resolve via workspace links and the lib array doesn't include DOM). This release makes all 24 packages compile cleanly under the strict-flagged tsconfig that ships with the repo, and wires a bun run typecheck gate into both the publish:dry and publish:all flows so this category of bug cannot recur.

Fixed

@shade/key-transparency

  • Removed unused imports IndexAbsenceProof, IndexInclusionProof (src/manager.ts), nodeHash (src/index-tree.ts).
  • IndexProofWire is now exported (was a private type that noUnusedLocals flagged).
  • Added missing tsconfig.json so the package can be type-checked in isolation.

@shade/sdk

  • KT verifier wiring: fetchLatestSTH() and fetchConsistencyProof() now have explicit return types (Promise<STHWire> and Promise<{ proof: string[] }>) so consumers don't see Promise<unknown> from res.json().
  • STHWire type is now imported from @shade/key-transparency.
  • thumbnail.ts: cast globalThis through unknown first when reading optional DOM globals (OffscreenCanvas, createImageBitmap) so consumer projects that include lib.dom don't reject our narrower local types as "insufficiently overlapping".

@shade/files

  • Broke the @shade/sdk@shade/files dependency cycle. @shade/files no longer imports Shade from @shade/sdk — every callsite uses a new local ShadeBridge interface defined in src/integration/shade-bridge.ts. This is the structural surface Shade must satisfy: myAddress, send, onMessage, upload, onIncomingTransfer, getFingerprintFor (required) plus getObservability, deliverControlEnvelope (optional). The Shade class structurally implements every member, so createFilesNamespace(this) from the SDK side compiles regardless of how many copies of @shade/sdk a consumer's package manager hoists. Fixes "this is not assignable to type 'Shade'" in consumer builds.
  • <ShadeFilesProvider> now takes files: FilesNamespace as an explicit prop instead of reading shade.files. Consumers pass shade.files (or any createFilesNamespace(...) result for tests) directly.
  • ShadeFileRpcChannel.send now raises a clear error when deliverControlEnvelope is undefined instead of producing an implicit-undefined-call error at compile time.

@shade/storage-encrypted

  • Replaced KeyUsage (a lib.dom type) with a local WebCryptoKeyUsage union so the package compiles under lib: ["ES2022"] without DOM.
  • Fixed tsconfig.json rootDir so package-level bunx tsc works.

@shade/transport-bridge

  • sse-bridge.ts: cast res.body.getReader() to ReadableStreamDefaultReader<Uint8Array> so the strict reader-type parity check in the consume loop passes.

@shade/keychain / @shade/dashboard

  • Fixed tsconfig.json rootDir and include so the packages can type-check standalone (and so vite.config.ts doesn't get pulled into the dashboard's rootDir).

@shade/widgets

  • Removed unused ThumbnailMime import in components/transfer/ThumbnailPreview.tsx.

Tooling

  • New scripts/typecheck-all.ts — runs bunx tsc --noEmit against every workspace package's tsconfig.json and fails if any reports errors.
  • New bun run typecheck script.
  • publish:dry and publish:all now run prepublish:check (typecheck + test) before any package is packed or published.
  • scripts/publish-shade.sh calls the typecheck-all gate before invoking the publisher.

Migration

4.0.0 → 4.0.1 is wire-compatible and source-compatible with one exception:

  • <ShadeFilesProvider> requires a files prop. Previously <ShadeFilesProvider shade={shade}>...</ShadeFilesProvider> worked; it now must be <ShadeFilesProvider shade={shade} files={shade.files}>.

No on-disk schema changes. No package-version-pin changes outside the lockstep 4.0.0 → 4.0.1 bump.

[4.0.0] — 2026-05-03 — General Availability

Shade 4.0 is the first GA-marked release: every plan from V3.1 through V3.12 is merged, the cross-platform vector suite is green on TS + Kotlin, the threat model has been updated to reflect every new surface, and the core stack (X3DH, Double Ratchet, storage encryption, recovery, WebRTC P2P, Key Transparency) has been packaged for external review. Voice and video — the only big-ticket V2.x ask — have been moved to V5.0 so the 4.0 audit can focus on a frozen non-realtime core.

The wire format is unchanged from 0.4.x: 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic (audit-cycle complete, opt-in surface fully exposed), not breaking. Apps that have been running 0.4.x in production move forward by bun add @shade/sdk@^4.0.0 and (optionally) wiring any of the new opt-in surfaces.

Highlights

  • External crypto-review-ready. A "review-bundle" (docs/audit/) ships with this release: links to every protocol spec, the threat model, the cross-platform test corpus, the build instructions, and scope guidance for the auditor.
  • Migration guide locked in. MIGRATION.md documents the exact 0.3.x → 4.0 path, including the optional opt-ins, the schema superset, and the shade migrate-storage workflow.
  • Cross-platform parity gated in CI. .gitea/workflows/cross-vectors.yml runs the same vector corpus on TS (bun) and Kotlin (gradle). A divergent KDF label, AAD layout, or wire byte fails the build.
  • All V.md plans archived.* docs/V3.1.md through docs/V3.12.md and the original V2.1/V2.2/V2.3 backlog now live under docs/archive/ with Status: Done. Active planning continues in docs/V5.0.md (Voice & Video).
  • Operator-facing OpenAPI is complete. packages/shade-server/openapi.yaml now covers prekey, transfer, KT, inbox, bridge (SSE / long-poll / WS), observer, and the /metrics, /healthz, /ready operations endpoints — every HTTP surface a 4.0 client can talk to.
  • Threat-model refresh. Sections 10 (V3.3 fingerprint gates), 11 (V3.11 WebRTC), 12 (V3.8 Web-Worker boundary) are new; the residual- risk table updates the §1 / §2 / §6 entries with the 4.0 mitigations now landed.

What's already in 4.0 (consolidated from 0.4.x)

The detailed CHANGELOG entries below list everything that landed in the 0.4.x series and is now part of the GA baseline:

  • V3.2 — At-Rest Storage Encryption (@shade/storage-encrypted, @shade/keychain, shade migrate-storage).
  • V3.3 — Fingerprint Gates & Trust UX (Shade.beforeFirstLargeFile / beforeBackupImport / beforeNewDeviceTrust, <FingerprintCompare />, <FingerprintGate />).
  • V3.4 — Observability v2 (OpenTelemetry-shaped events, @shade/observability).
  • V3.5 — Android parity + cross-platform CI gate.
  • V3.6 — Async Store-and-Forward (@shade/inbox, @shade/inbox-server, InboxPruneTask).
  • V3.7 — Transport Bridge (@shade/transport-bridge, SSE + long-poll + WS adapters).
  • V3.8 — Web Workers Crypto (@shade/crypto-web/worker).
  • V3.9 — Rich File Metadata + thumbnails (in @shade/files).
  • V3.10 — Social Key Recovery (@shade/recovery, <RecoverySetup />, <RecoveryRequest />, <RecoveryApprove />).
  • V3.11 — WebRTC P2P Transport (@shade/transport-webrtc, MultiTransportFallback).
  • V3.12 — Key Transparency (@shade/key-transparency, createPrekeyServerWithKT(...), LightWitness).

Acceptance criteria

  • V3.1 → V3.12 merged into main.
  • No open critical / high-severity security issues at the time of tagging.
  • Cross-platform test vectors green: TS (1000 / 1000) and Kotlin (11 / 11).
  • Production-checklist (docs/PRODUCTION-CHECKLIST.md) is the canonical operator gate.
  • OpenAPI covers every HTTP surface (/v1/keys/*, /v1/transfer/*, /v1/kt/*, /v1/inbox/*, /v1/bridge/*, /metrics, /healthz, /ready).
  • Threat model reflects every new V3.x surface.
  • 0.3.x → 4.0 migration documented in MIGRATION.md and validated against the shade migrate-storage CLI on a real SQLite DB.
  • Pending external review. A docs/audit/REVIEW-BUNDLE.md pointer is shipped; the actual external review window opens after tag.

Migration

See MIGRATION.md § Migrating from 0.3.x to 4.0 (GA). The short version: bump every @shade/* to ^4.0.0, run bun install, restart, opt in to the V3.x surfaces you actually need. No on-disk schema is destructive; no peer wire format changes.

[Unreleased] — Key Transparency (V3.12) + WebRTC (V3.11)

V3.12 — Key Transparency

Verifiable prekey distribution. The prekey server can now run in Key-Transparency mode: every register / delete event is committed to an append-only Merkle log (RFC 6962-style), every bundle-fetch includes an inclusion proof, and every Signed Tree Head (STH) is signed with an operator-controlled Ed25519 key that clients pin out-of-band.

A malicious server that swaps a bundle, splits its view between two clients, or rewrites history is detected by the client's KT verifier or by an independent witness. KT is opt-in on both server and client — existing deployments work unchanged until upgraded.

See docs/V3.12-DESIGN.md for the design notat (threat model, data-structure choices, freshness model, recovery procedures) and docs/key-transparency.md for operator + client onboarding.

Added

@shade/key-transparency (new package)

  • MerkleLog — RFC 6962 append-only hash tree over pre-hashed leaves. In-memory mirror with O(N) leaf storage and O(log N) audit-path / consistency-proof generation.
  • auditPath, recomputeRootFromAuditPath, consistencyProof, verifyConsistencyProof — standalone primitives matching RFC 6962 §2.1.1 and §2.1.2.
  • AddressIndex + verifyInclusionProof / verifyAbsenceProof — lexicographically sorted address commitment with both inclusion and neighbor-pair absence proofs. The index commitment becomes part of every STH so address → bundle_hash is auditable, not just the raw event log.
  • SignedTreeHead + signSth / verifySthSignature / canonicalSthBytes / computeLogId — Ed25519-signed commitment to the tree state. log_id = SHA-256(public_key) so a forged STH that claims a different log key is rejected.
  • KTLogManager — server-side orchestration that wires MerkleLog, AddressIndex, persistent KTLogStore, and STH signing under one serial-mutation API (recordRegister, recordReplenish, recordDelete, publishSTH, buildBundleInclusionProof, buildBundleAbsenceProof, buildConsistencyProof).
  • KTLogStore interface + MemoryKTLogStore reference impl. The interface is append-only by contract (no update() or delete() on historical leaves).
  • LightWitness — passive observer that polls a server's /v1/kt/sth endpoint, verifies signature + freshness + consistency, stores observed STHs, and exposes compare(otherSth) for split-view detection. Used by both witness CLIs and (transparently) by the SDK.
  • Bundle-proof verifiers: verifyBundleInclusion, verifyBundleAbsence, verifyBundleTombstone. Each re-derives the bundle hash, checks the audit path against the STH root, verifies the index commitment, and confirms freshness.
  • Errors: KTError, KTVerificationError, KTSplitViewError, KTStaleSTHError, KTLogIdMismatchError. Mapped to SHADE_KT_* codes.
  • Wire-format helpers: ktProofToWire / ktProofFromWire / sthToWire / sthFromWire for JSON-safe transport.

@shade/server

  • createPrekeyServerWithKT(...) — convenience that builds the KT service and wires it into the prekey routes in one call.
  • KeyTransparencyService — single-writer wrapper around KTLogManager with mutex-serialized mutations, cached latest STH, and configurable heartbeat interval (default 10 min).
  • New routes mounted under /v1/kt/:
    • GET /v1/kt/log_id — operator's signing public key + log_id.
    • GET /v1/kt/sth — latest signed tree head.
    • GET /v1/kt/sth/:treeSize — historical STH lookup.
    • GET /v1/kt/consistency?from=N1&to=N2 — RFC 6962 consistency proof.
  • POST /v1/keys/register and DELETE /v1/keys/:address now commit to the KT log (when enabled). GET /v1/keys/bundle/:address returns a ktProof field on success and on 404 (absence/tombstone).
  • KT is fully opt-in. Existing deployments are byte-compatible until keyTransparency is configured.

@shade/storage-postgres

  • PostgresKTLogStore — durable KTLogStore on Postgres. Uses three tables (shade_kt_leaves, shade_kt_index, shade_kt_sths) with an BEFORE UPDATE/DELETE/TRUNCATE trigger on shade_kt_leaves that blocks any mutation — defense-in-depth against operator error.
  • ensureKTLogTables(sql) exported for embedding.

@shade/transport

  • ShadeFetchTransport accepts keyTransparency: KTVerifierOptions. Modes: 'observe' verifies when proof present, 'observe-strict' requires proof on every response.
  • fetchBundleVerified(address) returns { bundle, ktSth? } so callers can route the verified STH into a LightWitness.
  • 404 responses are also verified (absence or tombstone proof) under strict mode.

@shade/sdk

  • ShadeConfig.keyTransparency — opt-in client config:
    createShade({
      prekeyServer: 'https://shade.example.com',
      keyTransparency: { mode: 'observe-strict', logPublicKey: KEY_BYTES_32 },
    });
    
  • Shade.getKTWitness() returns the auto-wired LightWitness so app code can introspect observed STHs or run manual gossip checks.
  • The SDK transparently feeds every fetched STH into the witness so split-view detection runs by default whenever KT is on.

Tests

  • 76 new tests across the KT stack: hash primitives, Merkle audit paths, consistency proofs, address-index inclusion/absence proofs, STH signing, manager orchestration, witness ingest, server-side HTTP routes, transport-side verification, and an end-to-end acceptance test that simulates two divergent server views and asserts a KTSplitViewError is raised.

V3.11 — WebRTC P2P Transport

Direct peer-to-peer chunk delivery for @shade/transfer (and therefore @shade/files) via RTCDataChannel. Signaling — SDP offer / answer + trickle ICE — rides on top of Shade.send / Shade.onMessage so the same Double Ratchet that authenticates regular messages authenticates WebRTC negotiation. Throughput-heavy uploads (multi-MB / multi-GB) skip the HTTP relay entirely when NAT allows; when traversal fails, the new MultiTransportFallback([webrtc, http]) demotes back to HTTP within the configured connect-timeout window without losing any chunks already in flight. See docs/webrtc.md and docs/V3.11.md.

Added

@shade/transport-webrtc (new package)

  • WebRtcConnection — per-peer wrapper around an IPeerConnection plus the single bidirectional RTCDataChannel (label shade-transfer/v1). Drives offer/answer/ICE through a WebRtcSignalingChannel; handles the receiver-side dispatch loop for chunk-ack / resume-state / ping-pong / error frames; exposes per-request reqId-correlated request() for the transport layer.
  • WebRtcConnectionManager — per-peer pool with deterministic glare resolution (lexicographic address compare). getOrCreate(peer) returns the live connection or initiates a fresh one; following through a glare-yield is automatic so the user-facing promise resolves to whichever role survives.
  • WebRtcSignalingChannel — multiplexes the four signaling kinds (shade.webrtc-offer/v1, shade.webrtc-answer/v1, shade.webrtc-ice/v1, shade.webrtc-bye/v1) over any ShadeBridge (real Shade.send/onMessage, or MemoryShadeBridge for tests). Non-signaling plaintext is forwarded to a configurable passthrough hook so consumer onMessage handlers stay untouched.
  • WebRtcTransferTransport — implements @shade/transfer's ITransferTransport over the managed DataChannel. Encodes chunks into the package's binary wire format, awaits chunk-ack frames matched by 16-byte requestId tokens, and enforces SCTP-friendly backpressure by polling bufferedAmount (default threshold 4 MiB).
  • IRtcFactory interface + nativeRtcFactory() adapter wrapping globalThis.RTCPeerConnection for browsers / Deno / Cloudflare Workers. MemoryRtcFactory ships an in-process WebRTC simulator used by the package's own tests and by @shade/sdk integration tests.
  • createShadeBridgeFromShade(shade) — turns any Shade-shaped object into a ShadeBridge. Calls shade.send(plaintext) to ratchet-encrypt the JSON, then shade.deliverControlEnvelope(...) (when present) to ship the envelope over HTTP — same path the existing control-plane already uses.
  • Wire-format constants (WIRE_CHUNK, WIRE_CHUNK_ACK, etc.) + encode*Frame / decodeFrame helpers exported for adapters that want to interoperate with ShadeTransferWsTransport (the wire matches frame-for-frame).
  • Errors: WebRtcConnectError, WebRtcDataChannelError, WebRtcSignalingError, WebRtcTimeoutError — all extend TransferTransportError so MultiTransportFallback automatically demotes on failure.

@shade/transfer

  • MultiTransportFallback — N-ary generalisation of the existing two-arg FallbackTransferTransport. Constructor takes [{ name: 'webrtc', transport }, { name: 'ws', transport }, ...]; layers are tried in order and demote sticky on TransferTransportError. Exposes activeName, hasFallenBack, failures (diagnostic log), and onSwitch((from, to) => ...) for observability hooks.

@shade/sdk

  • Shade.configureWebRTC({ factory, iceServers?, iceTransportPolicy?, bundlePolicy?, connectTimeoutMs?, requestTimeoutMs?, backpressureThresholdBytes? }) — opt-in entrypoint. MUST be called before the engine is built (i.e. before the first upload(), onIncomingTransfer(), or transferRoute() call). When configured, the engine is wired with MultiTransportFallback([webrtc, http]) and the WebRTC manager receives receiver-hooks pointing at engine.receiveChunk / engine.getResumeState.
  • Shade.getWebRtcRuntime(): ShadeWebRtcRuntime | null — diagnostic accessor returning the live signaling channel, manager, transport, and MultiTransportFallback after engine() builds.
  • @shade/transport-webrtc is a (optional) peer-dep — projects that don't call configureWebRTC() don't pay the install or runtime cost.

Tests

  • packages/shade-transport-webrtc/tests/ — wire-format roundtrips, signaling routing, full memory-factory caller/callee handshake, receiver-hook dispatch (chunk + resume-query), glare convergence, TURN-only configuration plumbing, native-adapter availability smoke test.
  • packages/shade-transfer/tests/multi-fallback.test.ts — N-ary demotion, sticky-after-failure, non-transport-error preservation, empty-list rejection.
  • packages/shade-sdk/tests/webrtc-integration.test.ts — two real Shade instances upload via WebRTC primary; verifies the engine picks webrtc and never demotes during the run.
  • packages/shade-sdk/tests/webrtc-failover.test.ts — broken-RTC factory provokes connect timeout; SDK demotes to HTTP within the V3.11 5-second SLO without losing chunks.
  • packages/shade-sdk/tests/webrtc-throughput.test.ts — 4 MiB / 4 lanes loopback over WebRTC vs HTTP; integrity match across both transports + diagnostic speedup ratio.

Documentation

  • docs/webrtc.md — full V3.11 guide (NAT-traversal table, TURN config matrix, connection flow, glare resolution, backpressure, multi-fallback wiring, diagnostics, wire format, limits, migration).
  • packages/shade-transport-webrtc/README.md — package quickstart.
  • README + CHANGELOG + ROADMAP marked V3.11 as Done.

[Earlier Unreleased] — Social Key Recovery (V3.10)

The biggest UX hole in any E2EE system — "what happens if I lose my phone?" — closed without a centralized recovery agent. Pick n guardians from your peers, set a threshold k; any k of them together can rebuild your identity onto a new device, but k-1 or fewer cannot. Shamir Secret Sharing over GF(2^8) gates the recovery key; AES-GCM authentication on the backup blob detects forged shares; an OOB-confirmed fingerprint gate on the guardian side blocks social-engineering. See docs/recovery.md and docs/V3.10.md.

Added

@shade/recovery (new package)

  • setupRecovery({ shade, guardians, threshold, deliver }) — primary-device flow. Generates a 32-byte recoveryKey, encrypts an identity backup under the recoveryKey-derived passphrase via Shade.exportBackup, Shamir-splits the key into n shares, and ships one share-deposit envelope per guardian over the existing 1:1 Shade session. Returns a per-guardian delivery report so partial-distribution is recoverable.
  • attachGuardian({ shade, store, approve, deliver }) — guardian-side receiver. Wires a Shade.onMessage handler that persists incoming deposits in a caller-supplied RecoveryStore and gates recovery-request envelopes behind a user-driven approve callback. Auto-declines requests for unknown (originalAddress, setupId) pairs.
  • requestRecovery({ shade, originalAddress, setupId, threshold, guardians, deliver }) — new-device flow. Sends one recovery-request per guardian, collects share-grant / share-decline replies, Shamir-combines the threshold-many grants, and atomically swaps in the restored identity via Shade.importBackup. Forged shares are detected by the AES-GCM tag on the backup blob; the loop tries every threshold-sized subset of grants before giving up.
  • Pure-TS Shamir Secret Sharing primitives (splitSecret, combineShares, encodeShare, decodeShare) over GF(2^8) with constant-time table lookups. Exported for advanced callers and hardware-token integrations.
  • MemoryRecoveryStore for tests + a RecoveryStore interface apps implement against IndexedDB / SQLite / AsyncStorage / etc.
  • Errors: RecoveryError, RecoveryDeclinedError, RecoveryTimeoutError, RecoveryReconstructionError, RecoveryProtocolError, RecoveryGuardianRejectedError.
  • Wire protocol: share-deposit, recovery-request, share-grant, share-decline JSON envelopes carried over Double-Ratchet plaintext.

@shade/widgets

  • <RecoverySetup /> — primary-device guardian-picker + threshold slider, drives setupRecovery and exposes formatRecoveryCard for the user's offline copy.
  • <RecoveryRequest /> — new-device widget that displays the temporary fingerprint prominently, drives requestRecovery, and reports per-guardian progress live.
  • <RecoveryApprove /> — guardian-side widget. Renders the pending request with original-vs-new fingerprint side-by-side and enforces a two-checkbox gate ("matches" + "OOB-verified") before the release button is clickable.
  • createApprovalQueue() — turns the attachGuardian.approve callback into a deferred queue the widget can consume.

@shade/core

  • Bug fix. initReceiverSession now copies the localDHKeyPair into the session so the eventual zeroize on DH ratchet step touches a scratch buffer, not the persisted signed prekey. Pre-V3.10 this corrupted the receiver's signed prekey after the first incoming X3DH from any sender — a bug surfaced by V3.10's multi-sender recovery flow but harmful to any user receiving messages from more than one peer. Regression test in packages/shade-core/tests/ratchet.test.ts.

Acceptance criteria (V3.10)

  • 3-of-5 recovery works end-to-end on two separate Shade instances. (packages/shade-recovery/tests/integration.test.ts)
  • No coalition of (k-1) guardians can reconstruct the recoveryKey (verified with fast-check property tests). (packages/shade-recovery/tests/shamir.test.ts, tests/adversarial.test.ts)
  • Guardian-side widget requires fingerprint-confirmation before sending a share. Two-checkbox enforcement + symmetric tests of both honest-OOB-confirm and hostile-fingerprint-mismatch paths.

[Unreleased] — Web Workers Crypto (V3.8)

Big in-browser uploads stay smooth: AES-GCM, HKDF, HMAC, X25519, Ed25519 and full per-lane stream state now run in a dedicated Web Worker. The main thread only buffers and forwards plaintext slices over zero-copy postMessage; lane keys never cross the thread boundary. Opt-in via shade.configureWorkerCrypto({ workerUrl }). See docs/web-workers.md and docs/archive/V3.8.md.

Added

@shade/crypto-web

  • WorkerCryptoProvider — drop-in CryptoProvider proxy that forwards every async op to a dedicated Web Worker via the worker-protocol. Sync helpers (randomBytes, randomUint32, constantTimeEqual, zeroize) execute on the calling thread — no useless round-trips.
  • createWorkerCryptoProvider({ workerUrl, idleTimeoutMs?, spawn? }) factory. Spawns lazily, completes a protocol-version handshake, and self-terminates after 30 s (configurable) of inactivity. Idempotent re-spawn on next call.
  • WorkerStreamSender / WorkerStreamReceiver — main-thread handles on StreamSender / StreamReceiver instances that live entirely inside the worker. Plaintext is shipped via transferable ArrayBuffers; lane keys + running sha256 stay worker-side.
  • createEncryptStream / createDecryptStream — TransformStream factories. pipeThrough(encryptStream) consumes plaintext and emits one wire-encoded stream-chunk envelope per write. Both expose a laneSha256 promise that resolves once the stream finishes.
  • New subpath export: @shade/crypto-web/worker is the dedicated module-worker entrypoint. Bundle with the standard new URL('@shade/crypto-web/worker', import.meta.url) idiom.
  • rotate() and destroy() lifecycle controls — call after identity rotation to bound the worst-case duration any lane key sits in worker memory.

@shade/sdk

  • shade.configureWorkerCrypto({ workerUrl, idleTimeoutMs? }) — opt-in setup. Without it, encryptStream / decryptStream throw a clear error pointing to the docs.
  • shade.encryptStream({ streamId, streamSecret, laneId?, chunkSize? }){ stream, laneSha256 } — TransformStream with an end-of-stream sha256 promise for end-to-end integrity proofs.
  • shade.decryptStream(...) — inverse. Strict in-order seq, AAD-bound AEAD, replay-rejecting.
  • shade.getWorkerCrypto() — direct access to the worker-backed CryptoProvider for one-off heavy ops.
  • shade.shutdown() now also destroy()s the worker provider.

Acceptance criteria (V3.8)

  • 100 MB upload in Chrome without blocking the main thread > 16 ms in P99 (verification recipe in docs/web-workers.md#verifying-main-thread-budget).
  • Safari works at default chunk-size — every postMessage carries ≤ 256 KiB + AEAD overhead, far below Safari's transferable cap.
  • Worker terminates within 30 s of last use (default idleTimeoutMs), and re-spawns transparently on the next call.

[Unreleased] — Transport Bridge (V3.7)

A canonical fallback chain for clients that cannot or will not run a WebSocket: SSE primary, long-poll secondary, plus a thin WS adapter for the happy path. All three transports surface the same IncomingMessage shape so application code stays portable across browser-extension, edge-runtime, and proxy-locked environments. See docs/transport.md and docs/archive/V3.7.md.

Added

@shade/transport-bridge (new)

  • IncomingMessage{ from, bytes, receivedAt, msgId? } — single shape across every transport.
  • BridgeTransportconnect({ onMessage }) → disconnect() contract.
  • WsBridge, SseBridge, LongPollBridge — three concrete transports consuming the matching /v1/bridge/{ws,stream,poll} endpoints.
  • FallbackBridgeTransport — sticky-after-first-success priority chain. Exposes activeKind and attempts for observability.
  • signBridgeQuery — Ed25519-signed query-string builder (the only carrier that survives EventSource's no-headers restriction).
  • Auto-reconnect with exponential backoff for WS + SSE; Last-Event-ID cursor resume for SSE; bounded one-outstanding-request loop for long-poll.

@shade/inbox-server

  • createBridgeRoutes({ store, crypto, events, … }) returns { app, websocket }.
    • GET /v1/bridge/stream — SSE feed, one envelope per event: envelope. Heartbeats every 15 s as : ping comments.
    • GET /v1/bridge/poll?timeoutMs=… — long-poll, default 25 s server hold under typical proxy idle cutoffs, hard cap 55 s.
    • GET /v1/bridge/ws — Bun-WebSocket upgrade, JSON frame per envelope.
  • Push-style delivery via InboxServerEvents (inbox.blob_stored); falls back to a 1 s polling timer when no events emitter is wired.
  • Cross-endpoint replay-protected: kind is bound into the canonical signed payload so a /poll signature cannot reach /stream.

@shade/server standalone container

  • Bridge routes mount on the same Hono app + Bun.serve as the prekey and inbox routes — no extra port, no extra env vars.

Acceptance criteria (V3.7)

  • Same "send 100 small messages" suite passes on WS, SSE, and long-poll.
  • Client that starts with WS and is blocked by proxy continues automatically via SSE — and on through to long-poll if SSE is also blocked — without message loss.
  • Long-poll fallback uses no more than one outstanding request per client.

[Unreleased] — Async Store-and-Forward (V3.6)

A dedicated relay (@shade/inbox-server) holds ciphertext blobs with TTL

  • auth so a sender can deliver to an offline recipient. Server stores only address || msgId || ciphertext-bytes || expires_at; the prekey server stays public-keys-only, and the relay never holds plaintext or private keys. See docs/inbox.md and docs/archive/V3.6.md.

Added

@shade/inbox (new)

  • Inbox — high-level orchestrator. Buffers outgoing PUTs in a durable queue, polls + acks incoming blobs, and exposes onMessageQueued(handler) (the vendor-neutral push-trigger hook mandated by V3.6) and onIncoming(handler).
  • InboxClient — low-level HTTP client (register, put, fetch, ack, unregister).
  • OutgoingQueueStore interface + MemoryOutgoingQueueStore default — swap in a SQLite/IDB backend so queue survives a process restart.
  • CursorStore interface + MemoryCursorStore default for the receive cursor.
  • computeMsgId(ciphertext) helper — lowercase-hex(sha256(ciphertext)).

@shade/inbox-server (new)

  • createInboxServer({ crypto, store, ... }) Hono app exposing:
    • POST /v1/inbox/register — TOFU bind address ↔ signing key.
    • DELETE /v1/inbox/register/:address — signed unregister.
    • POST /v1/inbox/:address — signed PUT, idempotent on (address, msgId), rejects mismatched msgId !== sha256(ciphertext) and bodies past maxBlobBytes (default 1 MiB) or per-recipient quota (default 1000).
    • POST /v1/inbox/:address/fetch — signed challenge, cursor-paginated.
    • DELETE /v1/inbox/:address/:msgId — signed ack.
  • InboxStore interface + MemoryInboxStore default.
  • InboxPruneTask — periodic prune of expired blobs (cron, default 5 min).
  • InboxServerEvents — structural-only event emitter for observability.

@shade/storage-sqlite

  • SqliteInboxStore(address, expires_at) + (address, received_at) + (expires_at) indexes. SHADE_INBOX_DB_PATH env var for the file path.

@shade/storage-postgres

  • PostgresInboxStore — concurrent-safe via INSERT … ON CONFLICT and a per-row nextval('shade_inbox_seq'). ensureInboxServerTables(sql) is exported for embedded deployments.

@shade/server standalone container

  • Inbox routes mount alongside prekey routes on the same Hono app.
  • New env vars: SHADE_INBOX_DB_PATH, SHADE_INBOX_PG_URL, SHADE_INBOX_PRUNE_INTERVAL_MINUTES. If SHADE_INBOX_PG_URL is unset the inbox falls back to SHADE_PREKEY_PG_URL (single Postgres deploy).

Acceptance criteria (V3.6)

  • Sender → recipient with no online overlap; payload < 1 MiB; first poll after recipient startup pulls the queued message.
  • Server-DB dump exposes no plaintext and no sender-recipient graph beyond byte-pair sizes (sender pubkey is per-PUT TOFU; only the recipient address is persisted).
  • Replay of PUT with the same msgId returns 200 with idempotent: true instead of 409, and no second row is written.

[0.4.0] — 2026-05-02 — Fingerprint Gates & Trust UX (V3.3)

Blocking verification gates for the handful of operations where MITM risk is real. Apps stay alert-fatigue-free for ordinary chat, but upload() of a large file, importBackup(), and acceptIdentityChange() now run through user-registered handlers before they touch anything sensitive. See docs/trust-ux.md and docs/archive/V3.3.md.

Added

@shade/sdk

  • Shade.beforeFirstLargeFile(threshold, handler) — gate runs in upload() when the file size meets the threshold (default 10 MiB) and the peer is unverified.
  • Shade.beforeBackupImport(handler) — gate receives the fingerprint of the identity embedded in the backup blob, before any state is written.
  • Shade.beforeNewDeviceTrust(handler) — gate runs from Shade.acceptIdentityChange(). The peer's identity-version is bumped first, so any prior verification automatically goes stale.
  • Shade.beforeInboxFanout(handler) — reserved hook for V3.6 fan-out; apps can register today.
  • Shade.markPeerVerified(address) / isPeerVerified(address) / unmarkPeerVerified(address) — manual control over persisted verification state.
  • decryptBackup / applyBackupPayload — split of the backup pipeline so callers can inspect a backup's identity fingerprint before writing.
  • New FingerprintGateRegistry exported for advanced integrations.

@shade/core

  • FingerprintNotVerifiedError (HTTP 403) — raised when a gate handler returns false, throws, or is missing in environments that policy- forbid TOFU.
  • PeerVerification + PeerVerificationSource types and storage methods on StorageProvider: savePeerVerification, getPeerVerification, removePeerVerification, getPeerIdentityVersion, bumpPeerIdentityVersion.

Storage backends

  • MemoryStorage, SQLiteStorage, PostgresStorage, EncryptedSQLiteStorage, EncryptedPostgresStorage all carry the new peer_verifications + peer_identity_versions tables.

@shade/widgets

  • <FingerprintGate peerAddress=... /> — render-prop wrapper that blocks children until the peer's safety number is verified at the current identity-version. SSR-safe; ships a default fallback with "Copy OOB text" + "I have verified" actions.
  • <FingerprintCompare onVerified=... /> — existing widget extended with the same two actions when wired to a callback.
  • formatOobText(peerAddress, fingerprint) helper exported.

Changed

  • @shade/sdk version bumped to 0.4.0 alongside all packages (lockstep per ROADMAP convention).

Migration

  • No breaking changes. Apps that don't register gate handlers get warning-mode TOFU automatically ('tofu-after-warning' source on the persisted verification). To upgrade to hard gates, register handlers for the operations you use. Existing <FingerprintCompare /> calls keep working.

[0.3.0] — 2026-05-02 — Shade Files

E2EE filesystem RPC primitive — drop-in entrypoints for any consumer that wants to expose a filesystem (or filesystem-like surface) over Shade. Apps keep their own UI; this layer ships the typed RPC, the streams bridge for content I/O over 256 KiB, and production hooks (rate limit, retention, fingerprint gate, metrics).

Added

@shade/files (NEW)

  • Standard ops: list, stat, mkdir, delete, move, read, write, getThumbnail — Zod-validated wire schemas + clean user-handler types.
  • Custom ops: client.custom('app.foo', {...}) with full type-safety via TypeScript declaration merging on CustomOpsMap + per-op Zod schemas registered server-side.
  • Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams (> 256 KiB) ride @shade/transfer with automatic correlation via userMetadata.shadeFilesWriteId / shadeFilesReadStreamId.
  • Directory ops: walk(path, opts) async-iterable depth-first walker; uploadDirectory() / downloadDirectory() with bounded concurrency pool (default 4, cap 16), aggregated progress events, abort support.
  • Production hooks (all callback-based, vendor-neutral):
    • Rate limit: token-bucket per sender, op-cost + byte-quota, FsRateLimitError / QuotaExceededError with retryAfterMs.
    • Idempotency cache: per-sender LRU + TTL, in-flight de-dupe, periodic prune via BackgroundHooks.onPruneFiles.
    • Path policy: built-in traversal hardening, percent-decode, forbidden-bytes check, root-scope, symlink toggle, extra predicate.
    • Fingerprint gate: requireFingerprintVerifiedFor(ctx)'required' | 'optional' | 'reject' + isFingerprintVerified(sender).
    • Signature verification: pluggable verifySender(sender, canonical, sig) with replay-window enforcement (±5 min signedAt skew rejected).
    • Metrics: onMetric(name, value, tags) with standard names (shade_files_op_duration_ms, _op_total, _bytes_in/out, _idempotency_hit/conflict_total, _rate_limit_reject_total, _fingerprint_reject_total, _signature_reject_total).
  • React hooks (subpath import @shade/files/react): <ShadeFilesProvider>, useShadeFiles, useFileList, useFileTransfer / useFileUpload / useFileDownload. SSR-safe; no UI components — apps bring their own.
  • High-level entry: Shade.files.serve(handler) and Shade.files.client(peer) in @shade/sdk. Lazy + memoized; one handler per Shade instance.
  • Drop-in adapter: createMemoryDirectory() for tests; structurally compatible with browser FileSystemDirectoryHandle.

Wire format bump

  • @shade/proto wire VERSION bumped from 0x01 to 0x02. Length prefixes changed from u16 to u32 — previous limit was 64 KiB ratchet payloads, which blocked inline file ops up to 256 KiB. Wire-incompatible with 0.2.x peers. New sessions only.
  • Cross-platform Kotlin port (android/shade-android) updated to match.

Concurrency safety

  • ShadeSessionManager.encrypt / .decrypt now run under per-peer mutex. Previously, concurrent decryptions of the same peer raced ratchet state (manifested as sporadic Failed to decrypt — wrong key or tampered data under load). Encrypt was already serialized via Shade.send's encryptChains; decrypt is now serialized at the manager layer too.

@shade/streams extension

  • StreamMetadata gets optional userMetadata?: Record<string, string> — application-level key/value pairs that round-trip verbatim through stream-init plaintext. Used by @shade/files for write/read correlation but available to any consumer.

@shade/sdk extension

  • Shade.files getter (lazy + memoized).
  • BackgroundHooks.onPruneFiles?: () => void + periodic timer (default 5 min) for @shade/files retention.
  • BackgroundTasks.setHook(name, fn) for runtime hook registration.

Examples

  • examples/08-files-browser/ — three-process demo (prekey + Bob server + Alice CLI) covering list/stat/mkdir/delete/upload/download with both inline and streamed paths.

Tests

  • 100+ new tests across tests/{unit,integration,security}/ in @shade/files. End-to-end coverage for streams I/O up to 1 MiB, custom-op registration + Zod validation, fingerprint-gate rejection, replay-window enforcement, idempotent retries, rate-limit + quota enforcement, walk
    • bulk transfer aggregated progress.

[0.2.0] — 2026-05-01 — Shade Streams

E2EE chunked upload/download with parallel lanes, resumable transfers, and a "magic drop-in" UX for any Shade-using app. Adds two new packages (@shade/streams, @shade/transfer) and extends @shade/sdk and @shade/widgets with high-level transfer APIs.

Added

Streams crypto layer (@shade/streams)

  • HKDF stream/lane key derivation (deriveStreamKey, deriveLaneKey)
  • Deterministic AES-GCM nonce construction nonce = laneId(4) || seq(8)
  • Streaming SHA-256 via @noble/hashes/sha2.js for memory-bounded integrity
  • StreamSender / StreamReceiver per-lane state machines with strict in-order seq + replay detection (StreamReplayError, StreamOutOfOrderError, StreamDecryptionError, StreamProtocolError)
  • MultiLaneSender / MultiLaneReceiver coordinators for parallel transfers
  • Range and round-robin partitioning helpers (planRangePartition, planRoundRobinPartition, chunkRange)
  • Wire format: new envelope type 0x11 (stream-chunk) in @shade/proto, control envelopes (stream-init / -finish / -abort / -resume-*) ride existing 0x02 ratchet messages with JSON kind discriminator

Transfer orchestration (@shade/transfer)

  • TransferEngine — single class wrapping outgoing + incoming lifecycle
  • Default ShadeTransferHttpTransport for chunk POSTs, opt-in ShadeTransferWsTransport with FallbackTransferTransport for auto-fallback
  • createTransferRoutes() Hono factory mounts /v1/transfer/* routes (chunk, state, health)
  • IControlChannel + MemoryControlChannel for in-process testing; the SDK provides ShadeControlChannel over Shade.send/receive
  • Resume protocol: MemoryResumeStore, StorageBackedResumeStore, deriveDeviceKey() for at-rest streamSecret encryption, engine.resumeUpload(streamId, freshInput) for kill-restart-verify flows
  • ProgressTracker with EMA-smoothed throughput + ETA
  • Retry/backoff (withRetry) with exponential delay + jitter
  • Error hierarchy: TransferError, TransferAbortError, TransferIntegrityError, TransferProtocolError, TransferOfflineError, TransferResumeError, TransferTransportError

SDK (@shade/sdk)

  • Shade.upload(opts) — high-level entry; encrypts + chunks + ships
  • Shade.onIncomingTransfer(handler) — receiver-side subscription
  • Shade.transferRoute() — Hono router to mount on the consumer's HTTP server
  • Shade.acceptTransferEnvelope(from, env) — low-level entry for custom transports
  • Shade.resumeUpload(streamId, freshInput) — pick up an interrupted transfer
  • Shade.listTransfers(filter?) — list resumable / active transfers from storage
  • ShadeTransferAuthenticator — Ed25519-signing authenticator for HTTP/WS transports
  • Shade.onMessage(handler) now accepts Promise<void>-returning handlers (awaited in sequence) — supports flow-control over the control plane

Storage (all backends)

  • New optional StorageProvider methods: saveStreamState, getStreamState, removeStreamState, listActiveStreamStates, pruneStreamStates. Existing v0.1.x providers compile cleanly (optional methods)
  • SQLite (stream_state table) and Postgres (shade_stream_state table) schemas with at-rest encrypted streamSecret
  • MemoryStorage extended with in-memory stream-state map

Widgets (@shade/widgets)

  • <ShadeRuntimeProvider runtime={shade}> — separate React context for upload/download widgets (distinct from the observer-dashboard <ShadeProvider>)
  • useShadeUpload() / useShadeDownload() headless hooks
  • <ShadeUploader /> / <ShadeDownloader /> composite components with render-prop pattern for full UI replacement
  • Sub-components: <DropZone />, <TransferRow />, <ProgressBar />, <SpeedReadout />, <ETAReadout />, <LaneIndicator />
  • Theme-token additions for progress, drop zone, and lane indicator colors

Security properties

  • Per-chunk AES-256-GCM with deterministic nonce; AAD binds streamId || laneId || seq || isLast so any header tamper invalidates AEAD
  • streamSecret never on the wire in plaintext — shipped via Double Ratchet control envelope; lane keys derived locally and never transmitted
  • Resume state encrypted at rest with deviceKey derived from identity's signing private key (rotation invalidates in-flight resume — by design)
  • Receiver enforces strict in-order seq per lane (StreamOutOfOrderError, StreamReplayError); finish-time integrity check verifies per-lane sha256
    • overall sha256 over original byte order

Tests added (118 new across 47 files; 444 total)

  • Unit: KDF, nonce, AEAD, streaming SHA, sender/receiver, partition
  • Integration: 1/4/16-lane parity, range vs round-robin parity, Bun.serve loopback at 100 KiB / 1 MiB / 8 MiB, two real Shade instances end-to-end at 64 KiB / 512 KiB / 4 MiB
  • Resume: kill-restart-verify on 256 KiB with 4 lanes
  • WS fallback: WS connect failure → transparent HTTP completion
  • Tamper: bit-flip ciphertext / tag / header field; replay; out-of-order
  • Wire: 0x11 envelope encode/decode roundtrip + edge cases

Backward compatibility

  • Shade.send/receive/onMessage/fingerprint/rotate unchanged (onMessage widened to support async handlers — sync handlers still work)
  • Existing wire types 0x01 (PreKeyMessage) / 0x02 (RatchetMessage) unchanged
  • StorageProvider interface extension uses optional methods
  • @shade/streams and @shade/transfer are new packages; no migration

[1.0.0] — 2026-04-10

First production release

Shade implements the Signal Protocol (X3DH + Double Ratchet) as a standalone, audit-friendly E2EE library for TypeScript/Bun.

Added

Core protocol

  • X3DH key agreement (X25519 + Ed25519, supports asynchronous bundles)
  • Double Ratchet with forward secrecy and post-compromise recovery
  • Skipped message key cache for out-of-order delivery (max 1000 per chain)
  • Header-bound AAD on AES-256-GCM encrypts (tampered headers fail decryption)
  • Memory zeroization of message keys, chain keys, root keys, and DH private keys after use

Storage

  • MemoryStorage (in-memory, for tests/embedded)
  • SQLiteStorage (@shade/storage-sqlite) — bun:sqlite, WAL mode, crash-safe
  • PostgresStorage (@shade/storage-postgres) — Drizzle, FOR UPDATE SKIP LOCKED
  • All backends survive container restarts and SIGKILL
  • Identity history with 7-day grace period for rotation

Prekey server (@shade/server)

  • Hono-based REST API with self-authenticated registration (Ed25519 signatures)
  • Anonymous bundle fetches (read-only)
  • Per-IP and per-identity rate limiting (token bucket)
  • Address validation (NFKC normalization, alphanumeric + :_-.)
  • ±5 minute replay window on signed requests
  • Health endpoints (/health, /healthz, /ready)
  • Prometheus metrics (/metrics)
  • Structured JSON logging
  • Graceful shutdown on SIGTERM/SIGINT
  • Production Dockerfile with non-root user, healthcheck, multi-stage build
  • docker-compose.yml example for Dokploy

Session manager (@shade/core)

  • ShadeSessionManager high-level API (encrypt, decrypt, initSessionFromBundle)
  • getIdentityFingerprint() — Signal-style 60-digit safety numbers
  • ensurePreKeyStock() — auto-replenish when below threshold
  • resetSession() and acceptIdentityChange() for recovery scenarios
  • rotateIdentity() with archived previous identities

Transport (@shade/transport)

  • ShadeFetchTransport — HTTP client for the prekey server with auto-signing
  • ShadeWebSocket — WebSocket wrapper with transparent encrypt/decrypt

Wire format (@shade/proto)

  • Compact binary encoding (significantly smaller than JSON)
  • Length-prefixed byte arrays, big-endian integers
  • Version-tagged envelopes for forward compatibility

Cryptographic hardening

  • constantTimeEqual (XOR-accumulator, no early exit)
  • randomUint32 via crypto.getRandomValues (no Math.random)
  • Timing-attack regression test
  • Constant-time trust verification in all storage backends

Errors

  • Stable SHADE_* error codes
  • errorToHttpStatus for consistent HTTP mapping
  • toJSON() for network serialization
  • 14 specific error types (Validation, Network, Storage, RateLimit, etc.)

Documentation

  • README, SECURITY.md, THREAT-MODEL.md
  • 5 runnable examples (basic conversation, prekey server, WebSocket tunnel, identity verification, Dokploy deployment)
  • Per-package READMEs
  • Inline TSDoc throughout

Testing

  • 195+ tests across all packages
  • Crash recovery integration test
  • Cross-platform PostgreSQL tests (skip without SHADE_TEST_PG_URL)
  • CI workflow with PostgreSQL service
  • Benchmark suite

Security properties

  • Forward secrecy
  • Post-compromise security
  • Authenticated identity verification
  • Replay protection
  • Constant-time secret comparisons
  • Memory zeroization (best-effort)