# Changelog

All notable changes to Shade are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.11.0] — 2026-05-15 — Streaming Double-Ratchet sub-sessions

Answers Vyvern FR `shade-ws-streaming-ratchet.md` (the last Phase-2
blocker) with a first-class streaming-session API rather than the
"documented contract" fallback: the Double-Ratchet crypto was already
safe for high-frequency one-directional use; the `send`/`receive`
*wrapper* was not (a `saveSession` keystore write per frame; a shared
per-peer mutex + single stored session row coupling any reuse to the
HTTP path). `ShadeStream` keeps the proven ratchet and fixes the
wrapper.

**`@shade/core`**

- New `stream.ts`: `deriveStreamRootKey` (identity-bound 3-DH —
  X3DH-minus-prekeys, no prekey-server round trip; mutually
  authenticated against the parent session's already-pinned
  identities), `bootstrapStreamSession` (reuses
  `initSenderSession`/`initReceiverSession` verbatim), and
  `StreamRatchet` — an in-memory `seal`/`open`/`close` holder on its
  own op-mutex, **never persisted**, zeroized on close.
- `ShadeSessionManager.beginStream` / `acceptStream` custody the
  identity keys for the handshake without exposing private material;
  both require an established parent session (no first-contact).
- New `StreamClosedError` / `StreamHandshakeError`; `stream.opened` /
  `stream.closed` events.

**`@shade/proto`**

- Wire types `STREAM_OPEN` (0x31), `STREAM_OPEN_ACK` (0x32),
  `STREAM_FRAME` (0x33) with encode/decode + `inspectEnvelopeType`
  extension. A `STREAM_FRAME` carries one ratchet message via the exact
  inner codec the HTTP path uses — one sealed frame ⇒ one WS frame.

**`@shade/sdk`**

- `Shade.openStream(peer)` / `Shade.acceptStream(peer, openBytes)`
  returning `ShadeStream` (`handshakeFrame` / `handleHandshake` /
  `seal` / `open` / `close`). Transport-agnostic like `send`/`receive`;
  auto-establishes the parent session if missing. Independent of the
  per-peer encrypt/decrypt queues and the stored parent session (R5).
  Identical on the `sqlite:` server build and the IndexedDB browser
  build (R4) — it touches no storage at all.

**Security / perf**

- Per-frame cost is exactly one symmetric KDF + one AES-GCM (no
  keystore I/O) — strictly better than the budgeted "doubled CPU".
  In-memory-only is a forward-secrecy property, not a shortcut; a
  dropped stream is re-opened, never resumed.
- New `docs/streaming-sessions.md` (full R1–R7 contract); SECURITY.md
  threat-matrix rows added with tests
  (`packages/shade-core/tests/stream.test.ts`,
  `packages/shade-proto/tests/stream-wire.test.ts`).

## [Unreleased — 2026-05-09] — Android: V4.9/V4.10 ports + KeystoreStorage adapter

The Kotlin side of the v4.10 cross-host approval routing FR. With this
release every primitive Prism Plan 04 needs on the phone has a Kotlin
implementation that produces byte-identical output to the TS reference.

**`shade-android` (pure-JVM, no Android SDK needed)**

- V4.9 blob primitives ported: `deriveBlobSlotId / deriveBlobKey /
  deriveBlobSigningSeed`, `aeadSeal / aeadOpen` (`nonce(12) || ct||tag`
  format with `shade-profile-aad-v1:<slotIdHex>` AAD),
  `ed25519PublicKeyFromSeed`, `slotIdToHex`. Lives under `no.zyon.shade.blob`.
- `BlobClient` HTTP wrapper for `/v1/blob/<slotId>` (java.net.http) —
  GET/PUT/DELETE with the same canonical-JSON-sorted-keys signing form
  the TS server expects. Hand-written JSON canonicalizer keeps Ed25519
  signing-input bytes identical to TS `signPayload` output.
- `Profile` high-level namespace (`createProfileNamespace`) — bundles
  KDF + AEAD seal/open + BlobClient calls into the same shape as
  `@shade/sdk`'s `createProfileNamespace`.
- V4.10 approval helpers ported: canonical profile schema
  (`CanonicalProfileBlob`, `ProfileHostEntry`, `ProfileClientEntry`)
  with parse/serialize/upsert/setTrustedApprover mutators that
  re-derive `trustedApproverFingerprints[]` invariantly.
  `buildApprovalRequest`, `signProxyApproval`, `verifyProxyApproval`,
  and the load-bearing `canonicalApprovalSigningBytes` (length-prefixed
  u16 BE UTF-8). All under `no.zyon.shade.approval`.
- Password KDFs: `deriveMasterKey` (scrypt) + `deriveMasterKeyArgon2id`
  via Bouncy Castle. NFKC-normalize string inputs to match TS.
- New `SessionStateJson` serializer for at-rest persistence of
  `IdentityKeyPair` / `SignedPreKey` / `OneTimePreKey` / `SessionState`.

**Cross-platform vectors**

- `test-vectors/blob.json` — V4.9 blob KDF + AEAD round-trip. Three
  (master, app) cases plus two pinned AEAD seal/open round-trips.
- `test-vectors/approval.json` — V4.10 approval signing-payload bytes
  + a TS-signed Ed25519 signature the Kotlin port verifies. Ed25519 is
  deterministic, so the Kotlin sign-with-the-same-seed produces the
  same 64 bytes back — that's checked too.
- Both wired into `CrossPlatformVectorTest` so any byte-divergence
  fails Gradle within the existing 60-second parity gate.

**`shade-android-keystore` (new sibling module — Android-specific)**

- `KeystoreMasterKey` — hardware-backed AES-256-GCM master key.
  - `BIOMETRIC_STRONG` gating only (Class 3 assurance) — explicitly
    excludes `DEVICE_CREDENTIAL` so a stolen-device-with-known-PIN
    can't unlock Shade.
  - StrongBox-backed when available; transparent fallback to TEE.
  - `setInvalidatedByBiometricEnrollment(true)`: a newly enrolled
    fingerprint/face wipes the key, forcing credential rebootstrap.
- `BiometricUnlock` — coroutine wrapper around `BiometricPrompt`.
  Tagged exceptions (`BiometricCancelledException` /
  `BiometricFailedException`) so callers handle UX without writing
  callback boilerplate.
- `KeystoreStorage` — `StorageProvider` impl over `SharedPreferences`
  with each row AES-GCM-encrypted under the keystore key. AAD = the
  pref key string so a substituted-prefs swap fails to open. Exposes
  `unlock(BiometricUnlock)` / `lock()` / `forgetEverything()` for
  app-lifetime gating (single biometric prompt at start, in-memory
  unlock thereafter).
- Builds as a standard AAR (`com.android.library`, AGP 8.7.3), depends
  transitively on `:shade-android` for protocol types and on
  `androidx.biometric:biometric:1.2.0-alpha05`.

**Threat model**

The keystore key never leaves the secure environment — encrypt/decrypt
operations happen in the TEE/StrongBox. A compromised app process can
ask the TEE to use the cipher only after biometric authentication
within the same `Cipher` instance. Combined with `Profile.delete()`
+ `forgetEverything()`, this gives a credible erase path: zero
recoverable plaintext after a rebootstrap.

Instrumented tests for `KeystoreStorage` are deferred (need an
emulator/device); the pure-JVM `SessionStateJson` round-trip is unit-
tested in `:shade-android`.

## [4.10.0] — 2026-05-09 — cross-host approval routing primitives

Prism filed a follow-up feature request
(`cross-host-approval-routing.md`) building on V4.9: now that the
encrypted profile blob is shipped, headless servers and away-from-PC
scenarios still can't approve a `linkRequest` from a new device
because there's no GUI to pop a dialog. Solution: a *trusted-approver*
phone signs an Ed25519 approval that any host can verify against the
freshest profile blob, even if the host has never spoken to the phone
before (X3DH-on-first-send via the existing `Shade.send` handles
session bootstrap).

The FR's questions (1) "is X3DH-on-first-send supported?" and
(5) "is there a broadcast helper to N addresses with X3DH-on-first?"
are both answered by *yes, `Shade.send` already does this per call*
— no new relay primitives needed. What this release ships is the
*shape* every Shade app would otherwise reinvent: a canonical
profile-blob schema and the build/sign/verify trio for proxy approvals.

**SDK (`@shade/sdk`)**

- Canonical profile-blob schema: `CanonicalProfileBlob` with
  `hosts[]`, `clients[]`, and a denormalized
  `trustedApproverFingerprints[]`. `parseCanonicalProfile` /
  `serializeCanonicalProfile` round-trip JSON; mutators
  (`upsertHost`, `upsertClient`, `setTrustedApprover`,
  `removeClient`, ...) are immutable and re-derive the
  denormalized list on every change so it can't drift.
- `ProfileClientEntry` stores both `identityPublicKey` (32-byte hex,
  used by `verifyProxyApproval`) and `identityFingerprint` (safety-
  number for display). The FR sketched only the fingerprint; storing
  the public key in-band drops the prekey-server dependency at
  verify-time and lets any host check signatures from a fresh
  profile read alone.
- Approval frames: `ApprovalRequestFrame` (`kind: 'approvalNeeded'`)
  and `ProxyApprovalFrame` (`kind: 'linkApproveByProxy'`).
  `buildApprovalRequest` mints a 128-bit hex `requestId` and a
  configurable expiry (default 5 min).
- `signProxyApproval` / `verifyProxyApproval` use a length-prefixed
  binary signing payload (`canonicalApprovalSigningBytes`) that binds
  domain, requestId, host fingerprint, requesting-device fingerprint,
  and decision. Length-prefixed (u16 BE) so any platform — Kotlin,
  Swift, Go — can produce byte-identical bytes from test vectors
  without a JSON canonicalizer.
- Domain separator: `DEFAULT_APPROVAL_DOMAIN = 'shade-link-approve-v1'`.
  Apps with their own canonical name (Prism uses
  `prism-link-approve-v1`) override via `domain`. The frame carries
  the domain so a verifier rejects mismatch before signature check.
- `verifyProxyApproval` returns a tagged result instead of throwing.
  Reasons: `request-id-mismatch`, `domain-mismatch`,
  `unknown-approver`, `not-trusted`, `bad-signature`, `expired`.
  Hosts log the reason and decide what to surface to the user.
- `isTrustedApprover` cross-checks the per-client `trustedApprover`
  flag AND the denormalized `trustedApproverFingerprints[]`. Both
  must agree — defends against a partially-written blob.

**Threat model — what's new**

- Compromised relay still can't read or forge approvals. The
  approval signature is verified against the approver's long-term
  Ed25519 identity key stored in the profile blob, which the relay
  can't decrypt or rewrite (profile-blob TOFU on owner-pubkey from
  V4.9 still applies).
- Revocation TOCTOU: hosts MUST refetch the profile blob fresh
  before honoring `linkApproveByProxy`. The `verifyProxyApproval`
  signature accepts the blob as a parameter — caller controls
  freshness. One extra `Profile.get` RTT per approval.
- The signature is belt-and-suspenders on top of the bilateral E2EE
  that delivers the frame. The E2EE channel already authenticates
  the sender's session, but the long-term identity binding means an
  approval is verifiable independently of session state.

**No relay or transport changes**. All app-level. Hosts persist their
own pending-`requestId` set for replay protection; that's app state
the SDK doesn't need to track.

## [4.9.0] — 2026-05-09 — relay-side encrypted blob primitive + SDK `Profile` namespace

Prism filed a feature request
(`encrypted-profile-storage-v4.9.md`) for relay-side encrypted profile
storage as the missing cryptographic primitive for Phase 2 of their
device-linking work: a brand new browser/device must be able to
locate a user's existing E2EE state from credentials alone — no QR,
no physical access to a paired device.

Ships as a *generic* primitive: a deterministically-located,
AEAD-sealed blob keyed by a 32-byte slotId derived client-side via
HKDF from the user's master key. The relay sees opaque slotIds and
opaque ciphertext; it never decrypts and cannot link slots to users.
Compare-and-swap via a per-slot etag prevents two devices from
silently clobbering each other's writes when a user adds a new
paired peer concurrently from two existing devices.

**Server (`@shade/inbox-server`)**

- New `BlobStore` interface + `MemoryBlobStore` reference impl. Per-slot
  layout: `(slotId, ownerPubkey, blob, etag, updatedAt)`. ETag is
  monotonic per process, clamped against `Date.now()` so values are
  unique and roughly time-ordered for ops.
- New `createBlobRoutes(store, crypto, options)` mounting
  `GET / PUT / DELETE /v1/blob/:slotId`. SlotId is validated as
  64 lowercase hex chars (32 bytes); URL-bound into the signed payload
  to prevent cross-slot signature replay. Owner pubkey is recorded
  TOFU on the first PUT — subsequent writes verify against it; a
  different key trying to overwrite an existing slot returns 401.
- CAS via `ifMatch`: omitted = create-only (409 on populated slot),
  numeric etag = strict CAS (412 on mismatch), `'*'` =
  unconditional overwrite when populated (412 on empty per RFC 7232).
- Default per-slot ceiling: 64 KiB. Sized for ~500 host entries in
  Prism's `hosts[]` JSON form with plenty of headroom.
- `createInboxServer` now also mounts the blob primitive on the
  same Hono app — pass `{ blobStore: null }` to opt out.

**Storage backends**

- `SqliteBlobStore` (`@shade/storage-sqlite`) — single-table
  `shade_blob_slots` with WAL journal mode. Reads CAS state and
  writes inside a transaction so concurrent CAS attempts can't both
  observe the same etag. Volume: same path convention as the inbox
  store; `SHADE_BLOB_DB_PATH` overrides (default `/data/shade-blob.db`).
- `PostgresBlobStore` (`@shade/storage-postgres`) — uses
  `nextval('shade_blob_seq')` so etag ordering is strict across
  multi-instance deployments. CAS path holds `FOR UPDATE` on the
  read so the txn serializes against concurrent writers. New
  `ensureBlobServerTables()` exposed for ops.

**SDK (`@shade/sdk`)**

- New `createProfileNamespace({ baseUrl, crypto, masterKey, app })`
  high-level wrapper. Computes slotId, blobKey, signing seed
  deterministically; AEAD-seals plaintext with `AAD = "shade-profile-aad-v1:<slotIdHex>"`
  on every PUT (fresh random nonce); verifies AEAD on every GET.
- New low-level `BlobClient` (`@shade/inbox`) — caller-supplied
  signing key; transports already-sealed ciphertext.
- New `ed25519PublicKeyFromSeed(seed)` (`@shade/crypto-web`) for
  deterministic Ed25519 keypair derivation from a 32-byte seed.
- KDF helpers `deriveBlobSlotId / deriveBlobKey / deriveBlobSigningSeed`
  exposed from `@shade/storage-encrypted/crypto` so apps that want a
  custom flow (skip the AEAD wrapper, hit a non-Shade relay) don't
  reimplement the info-string conventions.
- `app` namespace string mandatory — distinct apps under the same
  master MUST pass different values (e.g. `prism-profile-v1`) so
  they don't collide on the same slot.

**Standalone server**

- Boots a `BlobStore` mirroring the inbox-store selection chain:
  `SHADE_BLOB_PG_URL` > `SHADE_BLOB_DB_PATH` > shared
  `SHADE_PREKEY_PG_URL` > memory. `SHADE_DISABLE_BLOB=1` opts out.
  Honors `SHADE_DISABLE_RATE_LIMIT` for single-tenant deployments.

**Errors**

- New `ConflictError` → `SHADE_CONFLICT` → HTTP 409.
- New `PreconditionFailedError` → `SHADE_PRECONDITION_FAILED` → HTTP 412.

**Test vectors**

- `test-vectors/blob-storage.json` — three (masterKey, app) cases
  with expected slotId/blobKey/signingSeed/ownerPubkey. Lets Prism
  (and future non-JS implementations) verify HKDF-info-string
  interop without spinning up a relay.

## [4.8.5] — 2026-05-08 — `Inbox.flushOnce`: kill the 15 s success-backoff + per-recipient parallel drain

Prism filed a "typing-into-a-chatty-shell" UX FR pointing at
serial-per-flush behavior. The investigation surfaced a more
important latent bug: `scheduleFlush` was using a 15 s backoff timer
on **both** the success and failure paths, so any envelopes enqueued
*during* an in-flight flush had to wait ~15 s for the next drain to
fire — visible to Prism's web client as "10 s of silence then a
25-frame burst" whenever the PC sidecar was emitting steady output.

Two fixes ship together:

**(1) `scheduleFlush` distinguishes healthy-drain from all-failed.**
After `flushOnce` returns, if the round delivered ≥1 envelope and
items are still queued, the next flush fires with **0 ms** delay
(network is fine — drain whatever piled up immediately). The 15 s
backoff is reserved for the actual failure case (every attempt this
round threw / was rejected). `flushOnce` now returns
`{ delivered, remaining } | null` so the scheduler can also tell
"someone else is flushing, don't double-schedule" apart from
"queue is empty, idle." Externally-visible API unchanged
(`Inbox.tick()` still returns `{ flushed, received }`).

**(2) Per-recipient parallel drain inside `flushOnce`.** The queue
is grouped by `recipientAddress`; each bucket is drained
sequentially (preserves per-peer enqueue order — the relay assigns
`receivedAt` on PUT arrival, so concurrent PUTs to the same peer
would let the second one land first), but distinct buckets run
concurrently via `Promise.all`. Pre-fix, a slow POST to recipient A
head-of-line-blocked every other recipient's frames. Future N-peer
broadcast fan-outs (multiple devices viewing the same Prism PTY)
benefit immediately; single-recipient deployments are unaffected
since N=1 is the trivial parallel case.

Reported by Prism (multi-device E2EE terminal). Acceptance: under
sustained typing, web's `recv` rate is roughly proportional to PC's
emit rate, no multi-second silences punctuated by burst catch-ups.

### Fixed

#### `@shade/inbox` — `scheduleFlush` 15 s success-backoff
- After a successful drain, the next flush is rescheduled with
  `delayMs=0` when `delivered > 0`. The 15 s timer is reserved for
  rounds where every attempt failed (no progress, avoid tight retry
  loop).
- Concurrent `scheduleFlush` calls during an in-flight flush are
  detected via `flushOnce` returning `null`; the no-op early return
  no longer double-schedules a 15 s retry for a flush that's
  already running.

#### `@shade/inbox` — `flushOnce` per-recipient parallelism
- Outgoing queue is grouped by `recipientAddress`; buckets drain
  via `Promise.all`. Per-peer order preserved (sequential within a
  bucket); cross-peer order has no guarantee in Shade's wire model
  to begin with.
- Failure handling unchanged: per-entry `bumpAttempts` /
  `maxAttempts` semantics are identical to V4.8.4.

### Tests
- `packages/shade-inbox/tests/client.test.ts`:
  1. "burst enqueued during a flush drains immediately, not after
     15 s backoff" — slow first PUT (100 ms), pile 24 more during,
     assert `pendingCount === 0` within 1 s.
  2. "per-recipient parallel drain — slow POST to A does not block
     POSTs to B" — `bob` PUT stalls 200 ms; `carol` envelope queued
     after; assert `inbox.message_delivered` for carol fires within
     150 ms (would be ≥200 ms pre-fix).

### Migration

None. `Inbox.flushOnce` is a private method; the
`{ delivered, remaining } | null` shape is internal. `Inbox.tick`
public return `{ flushed, received }` is unchanged. Apps that hand
custom `OutgoingQueueStore` implementations to `Inbox` see no
contract change — `list()` / `remove()` / `bumpAttempts()` / `size()`
are called the same way per entry; only the *order* of `remove()`
calls across distinct recipients changes (interleaved instead of
strictly sequential).

## [4.8.4] — 2026-05-08 — Server-side cross-channel dedup via `BridgeDeliveryLog`

V4.8.3 shipped the *client-side* cross-channel dedup hook
(`Inbox.acceptBridgeFrame`), but a recipient that didn't migrate to
the new wiring kept observing the same envelope twice — once via the
WS bridge push, again ~30 s later when the next inbox-poll cycle
fetched it. Prism re-verified the FR after 4.8.3 and called this out:
they wanted the relay itself to enforce the
"one `Inbox.send` ⇒ one observable delivery" contract so app code
doesn't have to ack-via-DELETE on every bridge frame.

V4.8.4 adds the **server-side** dedup gate. A new in-memory
`BridgeDeliveryLog` (default 60 s grace, 8192-entry-per-address cap)
records every successful WS / SSE / long-poll push of
`(address, msgId)`. The inbox-fetch route reads the log and filters
out blobs the bridge has already pushed within the grace window. The
cursor advances over the *full* fetched window so a poll cycle that
straddles a suppressed blob doesn't get stuck — the bridge frame is
the canonical delivery; a recipient that crashed before processing it
falls back to ack-via-DELETE or waits for TTL the same way it would
in a no-bridge deployment.

The standalone server (`@shade/server`) auto-wires the log between
`createBridgeRoutes` and `createInboxRoutes`, so self-hosted relays
get the fix without configuration. Custom mounts thread the same
instance through `bridgeDeliveryLog` on both factories.

Reported by Prism (multi-device E2EE terminal). Acceptance: a single
`inbox.send` from sender A produces exactly one observable receive
on recipient B even when B runs both a bridge subscription and the
30 s `Inbox.pollOnce` cadence — confirmed by `bun test
packages/shade-transport-bridge/tests/bridge.test.ts`'s new
"BridgeDeliveryLog" describe block (WS + SSE coverage).

### Added

#### `@shade/inbox-server`
- `BridgeDeliveryLog` class — in-memory `Map<address, Map<msgId,
  deliveredAt>>` with grace-window filtering. Bounded per address
  (default 8192, oldest-first eviction); lazy cleanup on insert.
  Exported from the package root.
- `BridgeDeliveryLogOptions` — `{ graceMs?, maxPerAddress? }`.
- `createBridgeRoutes(...).bridgeDeliveryLog` — the auto-created log
  the bridge handlers write to. Inject one explicitly via
  `BridgeRoutesOptions.bridgeDeliveryLog` when you need to share an
  instance across multiple bridge mounts.
- `InboxRoutesOptions.bridgeDeliveryLog` — when provided, the
  `/v1/inbox/:addr/fetch` route filters out blobs in the log's
  grace window. Cursor advances over the *full* unsuppressed-plus-
  suppressed page so successive polls don't stall.
- `createInboxServer({ bridgeDeliveryLog })` — opt-in for the
  high-level factory.

#### `@shade/server` — `standalone.ts`
- The shared `BridgeDeliveryLog` is auto-wired between
  `createBridgeRoutes` and `createInboxRoutes` so self-hosted
  relays inherit the cross-channel dedup with zero configuration.

### Tests
- `packages/shade-transport-bridge/tests/bridge.test.ts` — new
  "BridgeDeliveryLog" describe block:
  1. WS push then `/v1/inbox/:addr/fetch` returns 0 blobs but the
     cursor has advanced.
  2. SSE push records into the log identically (transport parity).
  3. A blob the bridge never pushed (e.g. bridge wasn't connected)
     still comes through inbox-fetch — the filter is bridge-
     delivered-specific, not a blanket suppression.
- `bootstrap()` in the test file now wires the bridge's
  auto-created log into `createInboxRoutes`, mirroring the
  standalone-server wiring.

### Migration

For self-hosted operators of the standalone server: drop-in.

For consumers that mount `createInboxRoutes` + `createBridgeRoutes`
themselves: pass the same `bridgeDeliveryLog` to both factories. The
bridge auto-creates one and exposes it as
`bridgeRoutes.bridgeDeliveryLog`; the inbox routes accept it as
`InboxRoutesOptions.bridgeDeliveryLog`. Without the wiring, the bridge
push still works as before but the cross-channel dedup is off.

For client-side consumers, V4.8.3's `Inbox.acceptBridgeFrame` is
still the recommended path (instant ack, no grace-window wait), but
clients that only consume bridge pushes via their own dispatcher now
get the dedup for free as long as the relay is on V4.8.4.

## [4.8.3] — 2026-05-08 — Cross-channel msgId dedup + `Shade.aliasSession`

Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed
against. V4.8.2 closed the same-channel duplicate (8× via WS bridge
became 1×); V4.8.3 closes the cross-channel duplicate (bridge push +
inbox-poll catching up still delivered the same `msgId` twice) and
adds the missing `Shade.aliasSession` primitive that lets receivers
canonicalize their first-contact `fp:<senderfp>` label to the peer's
real address once the plaintext announces it.

**(1) `Inbox.acceptBridgeFrame(blob)` + shared msgId LRU.** The
relay's `Inbox.send` durably stores the blob and pushes it to every
active delivery channel. Without a client-side cross-channel ack, a
recipient running both a bridge and an inbox-poll cycle processed the
same blob twice — the bridge frame ran first, the
30 s-cadence inbox-poll fetched it again, and the duplicate dispatch
tripped on already-consumed one-time prekeys
(`one-time prekey not found: <id>`) or surfaced as duplicate
`shade.receive` work even when the canonical first delivery had
succeeded. The new `Inbox.acceptBridgeFrame(blob)` plumbs bridge
deliveries through the same dispatch + ack pipeline that `pollOnce`
uses; both paths share a 4096-entry msgId LRU so whichever channel
delivers first wins, and the other channel acks-and-skips when the
same `msgId` comes back around. The relay drops the blob on either
ack so subsequent polls don't see it.

**(2) `Shade.aliasSession(oldLabel, newLabel)`.** First-contact
forces the receiver to label the new session by the relay's
sender-fingerprint hint (`fp:<senderfp>` — the only sender label
visible at receive-time per the V4.8 sender-attribution feature),
because the receiver doesn't yet know the sender's prekey-server
address. The post-decrypt plaintext typically *announces* the
sender's address; without an SDK primitive to canonicalize, every
subsequent `send`/`receive` would either fail
(`Failed to decrypt message — wrong key or tampered data`) or
require app-level fp ↔ address translation around every call.
`aliasSession` moves the per-peer storage rows (session, trusted
identity, peer-verification record, identity-version counter) under
the new label, holding the per-peer mutex on **both** labels for the
duration so concurrent encrypt/decrypt can't observe a half-moved
state. The send/receive `encryptChains` + `decryptChains` queues for
the old label are also dropped so future operations start fresh.
Refuses to overwrite an existing session under the new label
(call `resetSession(newLabel)` first if that's intentional).

Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
handshake's web-side `paired` reply now decrypts cleanly,
`BroadcastChannel.addMember` accepts the sender-address-vs-bilateral
cross-check, and steady-state heartbeats / terminal traffic don't
log a duplicate "OPK not found" per envelope.

### Added

#### `@shade/inbox`
- `Inbox.acceptBridgeFrame(blob: FetchedBlob): Promise<boolean>` —
  feed a bridge-pushed envelope through the same dispatch + ack +
  dedup pipeline as `pollOnce`. Returns `true` when newly dispatched,
  `false` for a duplicate or a handler-rejected blob. Wire-up
  pattern documented inline.
- A 4096-entry FIFO msgId LRU (`deliveredIds` + `deliveredOrder`) is
  shared between `acceptBridgeFrame` and `pollOnce` so cross-channel
  duplicates are skipped (and acked) without re-running
  `incomingHandler`.
- `Inbox.handleBlob` now records every successfully-dispatched
  msgId before issuing the ack, eliminating the
  ack-in-flight window where a parallel `pollOnce` could see the
  blob and re-dispatch.

#### `@shade/transport-bridge`
- `IncomingMessage.expiresAt?: number` — relay-assigned absolute
  expiry, surfaced from the wire envelope so receivers can pass it
  straight to `Inbox.acceptBridgeFrame` without inventing a TTL.
  `decodeWireMessage` populates it when the wire message includes
  one (V4.8.3 relay onward).

#### `@shade/sdk`
- `Shade.aliasSession(oldLabel: string, newLabel: string): Promise<void>` —
  rename a session and its companion per-peer rows. Throws on
  no-such-session-for-oldLabel and refuses-to-overwrite-newLabel.

#### `@shade/core`
- `ShadeSessionManager.aliasSession(oldLabel, newLabel)` — the
  primitive backing `Shade.aliasSession`. Holds the per-peer mutex
  on both labels (acquired in lexicographic order) so the rename is
  atomic w.r.t. concurrent crypto ops.
- `ShadeEventMap['session.aliased']: { oldLabel, newLabel }` — emitted
  on a successful rename. Surfaced for observability dashboards.

### Tests
- `packages/shade-inbox/tests/client.test.ts` — two new cases:
  bridge-then-poll and poll-then-bridge, both asserting exactly one
  `incomingHandler` dispatch per `inbox.send`.
- `packages/shade-sdk/tests/sdk.test.ts` — new `aliasSession`
  cases: happy-path canonicalization (Bob initiates as `alice`, Alice
  receives under `fp:bobfp`, aliases to `bob`, subsequent ratchet
  exchange in both directions decrypts cleanly), refuses-to-overwrite,
  same-label no-op.

### Migration

None. `acceptBridgeFrame` and `aliasSession` are additive. Existing
bridge consumers that don't call `acceptBridgeFrame` keep working as
before — they just don't get cross-channel dedup, and the same
duplicate-on-poll behavior persists. `aliasSession` callers are
opt-in.

## [4.8.2] — 2026-05-08 — Per-`from` decrypt serialization + per-connection bridge dedup

Two interlocking robustness fixes for the first-contact / duplicate-fan-out
class of failures Prism reported. Either fix on its own would help; together
they make the receiver path tolerant of any combination of relay duplicates
and concurrent dispatchers.

**(1) `Shade.receive(from, env)` now serializes its ratchet/storage
step per `from`.** The send path has had a per-address `encryptChains`
mutex since V1 — receive did not. Concurrent decrypts for the same peer
raced the `SessionManager` ratchet (mutated in place) and the
`StorageProvider` (which is not required to be a concurrent-safe
writer — `bun:sqlite` throws `database is locked`, IndexedDB throws
transaction conflicts). Symptom in production: a single relay PUT that
fans out 8× over a WS bridge gets dispatched as 8 parallel
`shade.receive` calls; one wins the X3DH prekey race, the other 7 fail
with `database is locked` or `one-time prekey not found: <id>`, and the
post-decrypt side effects (`markPeerVerified`,
`BroadcastChannel.addMember`, paired-reply `inbox.send`) get lost in
the rubble. The decrypt step is now chained off a per-`from` promise
queue. Crucially, the user-facing **message handlers run outside the
queue** — streams + file-RPC issue nested `shade.receive` calls for the
same peer from inside their handlers (e.g. `stream-end` arrives while a
write-RPC is still waiting on chunks), and holding the queue across the
handler would self-deadlock. Only the atomic ratchet+storage step is
protected.

**(2) Bridge handlers (WS + SSE) now run a per-connection msgId
LRU dedup.** Cursor-based delivery already de-duplicates in the happy
path, but the gate is a defense-in-depth against any subtle re-entry of
`flushTo` (event-storm, future refactor, fallback-timer race). The chain
that drives flush is now also wrapped in `.catch(() => {})` so a
transient `ws.send` / SSE write rejection doesn't poison every future
push on the connection.

Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
handshake is unblocked even when the receiver runs multiple bridges or
the relay double-fires `inbox.blob_stored`.

### Fixed

#### `@shade/sdk` — `Shade.receive` per-`from` serialization
- `Shade` gains a private `decryptChains: Map<string, Promise<unknown>>`
  mirroring the existing `encryptChains` on the send path.
- `Shade.receive(from, env)` chains its `manager.decrypt(from, env)`
  call off the prior decrypt promise for the same `from`. The
  post-decrypt control-plaintext check and user `messageHandlers` run
  *outside* the chain so nested `shade.receive` calls from inside a
  handler don't self-deadlock (streams + file-RPC depend on this).
- The stored chain is `decryptPromise.catch(() => undefined)` so a
  rejection in one decrypt doesn't sabotage the next; this caller
  still sees its own rejection through the original promise.
- External signature unchanged.

#### `@shade/inbox-server` — bridge per-connection msgId dedup
- New internal `DeliveredIdLru` (4096-entry bounded set, FIFO eviction)
  per WS / SSE connection. `flushTo` skips emit when a row's `msgId` is
  already in the LRU. Long-poll handlers don't need it (each request is
  isolated).
- `pendingFlushPromise` chains in both WS and SSE handlers now
  terminate in `.catch(() => {})` so a transient emit failure doesn't
  silently kill the connection's flush loop.

### Tests
- `packages/shade-transport-bridge/tests/bridge.test.ts` — new
  "Bridge dedup" describe block: storms `inbox.blob_stored` 10× for one
  PUT and asserts WS / SSE both deliver exactly one frame.
- `packages/shade-sdk/tests/sdk.test.ts` — new
  "concurrent receive(from, env) for same `from` does not race the
  ratchet" exercises 8 parallel `bob.receive('alice', env)` for the
  same envelope and asserts:
  1. at least one fulfills with the right plaintext;
  2. no rejection mentions `database is locked`;
  3. the next legitimate message still decrypts (ratchet intact).

### Migration

None. Drop-in. Bridges and receivers behave identically on non-
duplicate paths; the new gates only kick in when a duplicate would
otherwise have been emitted / dispatched.

## [4.8.1] — 2026-05-08 — `SHADE_DISABLE_RATE_LIMIT` env var for single-tenant deploys

The standalone server's `routes.ts` and `inbox-server`'s
`createInboxRoutes` already accepted a `disableRateLimit?: boolean`
option, but the standalone entry just didn't read it from environment.
Self-hosted single-tenant deploys (Prism's relay is a typical case —
only Prism PC clients + their paired browsers) tripped the
`REGISTER_LIMIT` (5/hour per IP) every dev iteration: ~6 pair attempts
in an hour from the same IP plus the sidecar's register call killed
the dev loop until the bucket refilled (~1 token per 12 minutes).

Reported by Prism. Two-line plumbing fix: `standalone.ts` now reads
`SHADE_DISABLE_RATE_LIMIT=1` and forwards `disableRateLimit` to both
`createPrekeyRoutes` and `createInboxRoutes`.

### Added

#### `@shade/server`
- `SHADE_DISABLE_RATE_LIMIT=1` env var disables IP rate-limits on every
  prekey + inbox route in `standalone.ts`. Logged as a `WARN` on startup
  (`SHADE_DISABLE_RATE_LIMIT=1 — IP rate limits OFF on prekey + inbox
  routes`) so operators see it in stderr/log aggregation.
- **Single-tenant deployments only** — multi-tenant relays must leave
  this unset. The rate-limit defends multi-tenant relays against abuse;
  flipping it off is appropriate for self-hosted single-team setups
  where every caller is a known client. Documented in
  [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md) under "Environment variable
  reference".

### Tests
- `packages/shade-server/tests/rate-limit.test.ts` — the existing
  "register endpoint rate-limits per IP" test verifies the default-on
  path; a new sister test exercises
  `createPrekeyServer({ disableRateLimit: true })` and confirms 12
  consecutive register calls from the same IP all return 200 (no 429).
  The env-var → option conversion in `standalone.ts` is a one-liner
  verified by inspection.

### Migration

None. Default is unchanged (rate limits stay ON). Self-hosted
single-tenant operators add `SHADE_DISABLE_RATE_LIMIT=1` to their
deployment env to flip it off.

## [4.8.0] — 2026-05-08 — Sender-fingerprint attribution + `Inbox.start()` race fix

Two unblocking changes for first-contact flows. First, the relay now
captures the sender's signing-key fingerprint at PUT time and surfaces
it on every downstream delivery — bridge push (`IncomingMessage.from`)
and inbox-fetch response (`FetchedBlob.from`). Without it, an app
receiving a prekey envelope from a never-before-seen peer cannot
decrypt it: `shade.receive(from, env)` requires a sender address and
the wire envelope itself doesn't authenticate the sender. The
fingerprint is the same 8-byte hex of SHA-256(senderSigningKey) that
`IncomingMessage.from` was already documented as carrying; the field
just wasn't populated.

Second, `Inbox.start()` no longer races register vs the first poll.
Pre-fix, a fresh address calling `start()` saw the very first
`/v1/inbox/{addr}/fetch` POST race the register HTTP RTT and return
`SHADE_NOT_FOUND` — confusing 404 in DevTools, ~30s gap until the next
scheduled poll, and inbox-fetch silently dark for the gap (bridge push
covered for it, which is why this slipped through). `start()` now
defers the first poll; `register()` success kicks `schedulePoll(0)`.

Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
handshake is unblocked: web POSTs pair frame to PC inbox, PC's
`onIncoming` gets `raw.from = "fp:<hex>"`, calls
`shade.receive('fp:<hex>', env)`, parses plaintext, learns real
address, sends paired-reply.

### Added

#### `@shade/inbox-server`
- `InboxStore.putBlob({ ..., senderFp? })` — store interface accepts an
  optional 8-byte hex fingerprint. `MemoryInboxStore`,
  `SqliteInboxStore` (`@shade/storage-sqlite`), and `PostgresInboxStore`
  (`@shade/storage-postgres`) all persist + return it.
- `InboxStore.fetchBlobs(...)` rows expose `senderFp?: string`.
  Undefined for legacy rows persisted by a pre-4.8 relay.
- `POST /v1/inbox/:address` route computes `shortHash(senderSigningKey)`
  after the sender's signature is verified and forwards it to
  `store.putBlob({ ..., senderFp })`. The signature verification path
  authorizes the same fingerprint that gets persisted — no new trust
  surface.
- `POST /v1/inbox/:address/fetch` response includes `from` per blob
  when the row has a fingerprint. Absent on legacy rows.
- Bridge endpoints (`/v1/bridge/{stream,poll,ws}`) now populate
  `BridgeWireMessage.from` from the row's `senderFp`. The
  `transport-bridge` wire format already accepted `from`; v4.7 just
  never filled it.

#### `@shade/inbox`
- `FetchedBlob.from?: string` — relay-supplied sender fingerprint hint,
  parsed from the fetch response.
- `DecryptHandler` raw arg gains `from?: string`. Apps that ignore it
  keep working unchanged (back-compat: the field is optional).

### Fixed

#### `@shade/inbox` — `Inbox.start()` register/poll race
`start()` no longer schedules the first poll synchronously alongside
the fire-and-forget `register()`. Instead, `register()` success kicks
`schedulePoll(0)`, so the first poll fires after the server has
acknowledged the address. Already-registered instances (where the
local `this.registered` flag is true at `start()` time, e.g. after a
restart that hydrated state) get an immediate poll as before.

### Storage migrations

Idempotent ALTER TABLE for live deployments:
- **SQLite** (`@shade/storage-sqlite`): on open, the store does
  `PRAGMA table_info(inbox_blobs)` and runs
  `ALTER TABLE inbox_blobs ADD COLUMN sender_fp TEXT` if the column is
  missing. Fresh databases get the column from the `CREATE TABLE IF
  NOT EXISTS` directly.
- **Postgres** (`@shade/storage-postgres`): `ensureInboxServerTables`
  runs `ALTER TABLE shade_inbox_blobs ADD COLUMN IF NOT EXISTS
  sender_fp TEXT`.

Both leave existing rows with `sender_fp = NULL`. The fetch path emits
`from` only when the column is non-empty, so legacy blobs surface as
`from: undefined` (acceptance criterion (4): inter-version compat).

### Tests
- `packages/shade-inbox/tests/client.test.ts`:
  - **Race fix**: spy fetch records the order of `register` and `fetch`
    requests; first `fetch` (if any) must follow `register`. Pre-fix
    the recording fetch threw "fetch fired before register completed
    (race not fixed)".
  - **Fetch attribution**: `FetchedBlob.from` matches
    `SHA-256(senderSigningKey)[:8]` in hex.
  - **DecryptHandler propagation**: `raw.from` arrives in the app's
    handler.
- `packages/shade-transport-bridge/tests/bridge.test.ts`: same
  fingerprint regression for SSE, WS, and long-poll bridges
  (`IncomingMessage.from` non-empty + matches the expected digest).
- `packages/shade-storage-sqlite/tests/sqlite-inbox-store.test.ts`:
  - senderFp round-trip through put + fetch.
  - senderFp omitted on put → fetched row has `senderFp: undefined`.
  - **Pre-4.8 schema migration**: open a DB seeded with a v4.7
    `inbox_blobs` schema (no `sender_fp` column), reopen via
    `SqliteInboxStore`, verify the legacy row survives + new writes
    carry the new field.

### Migration

None required for app code. Existing handlers that ignore
`raw.from` / `IncomingMessage.from` keep working unchanged. Apps that
want sender-attributed first-contact:

```ts
inbox.onIncoming(async (raw) => {
  const tentativeAddr = raw.from ? `fp:${raw.from}` : null;
  if (!tentativeAddr) return null;            // legacy relay; drop
  const env = decodeEnvelope(raw.ciphertext);
  const plaintext = await shade.receive(tentativeAddr, env);
  // pair frame announces real address; reconcile fp:<hex> → real
  return null;
});
```

For Prism specifically: drop the `await this.inbox.register()`
workaround in `apps/web/src/shade/transport.ts` and
`packages/shade-sidecar/src/transport.ts`. `inbox.start()` on 4.8+
no longer races and the explicit pre-register is redundant.

## [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:

```ts
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/inbox` — `InboxClient` 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-bridge` — `LongPollBridge` 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

```ts
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`,
  `EncryptedPostgresStorage` — `broadcast_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; ~250–400 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.ts` — `identityPublicKey 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/transfer` — `OutboundQueue` 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/core` — `ratchetDecrypt` 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 (1–50 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.
- `QueueEnvelopeTransport` — `ControlEnvelopeTransport` 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 `httpClient`s).
- `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:

```ts
// 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:

```ts
// 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<Uint8Array> 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

- [x] V3.1 → V3.12 merged into `main`.
- [x] No open critical / high-severity security issues at the time of
      tagging.
- [x] Cross-platform test vectors green: TS (1000 / 1000) and
      Kotlin (11 / 11).
- [x] Production-checklist (`docs/PRODUCTION-CHECKLIST.md`) is the
      canonical operator gate.
- [x] OpenAPI covers every HTTP surface (`/v1/keys/*`,
      `/v1/transfer/*`, `/v1/kt/*`, `/v1/inbox/*`, `/v1/bridge/*`,
      `/metrics`, `/healthz`, `/ready`).
- [x] Threat model reflects every new V3.x surface.
- [x] `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)](./MIGRATION.md#migrating-from-03x-to-40-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:
  ```ts
  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)
- [x] 3-of-5 recovery works end-to-end on two separate Shade
      instances. (`packages/shade-recovery/tests/integration.test.ts`)
- [x] 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`)
- [x] 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 `ArrayBuffer`s; 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)
- [x] 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`).
- [x] Safari works at default chunk-size — every `postMessage` carries
      ≤ 256 KiB + AEAD overhead, far below Safari's transferable cap.
- [x] 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.
- `BridgeTransport` — `connect({ 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)
- [x] Same "send 100 small messages" suite passes on WS, SSE, and
      long-poll.
- [x] 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.
- [x] 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)
- [x] Sender → recipient with no online overlap; payload < 1 MiB; first
      poll after recipient startup pulls the queued message.
- [x] 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).
- [x] 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)
