Closes#1029.
## Problem
PSK-decrypted channels show new messages only after a full page refresh.
The WebSocket live feed delivers `GRP_TXT` packets as encrypted blobs
and the channel UI has no hook to auto-decrypt them with stored keys.
The REST fetch path (used on initial load + on `selectChannel`) already
decrypts; the WS path silently dropped on the floor.
## Fix
Two new helpers in `public/channel-decrypt.js`:
- `buildKeyMap()` → `Map<channelHashByte, { channelName, keyBytes,
keyHex }>`
built from `getStoredKeys()`. Cached and invalidated on `saveKey` /
`removeKey`, so the WS hot path is O(1) per packet after the first
build.
- `tryDecryptLive(payload, keyMap)` → returns
`{ sender, text, channelName, channelHashByte }` when the payload is an
encrypted `GRP_TXT` whose channel hash matches a stored key and whose
MAC verifies; `null` otherwise.
`public/channels.js` wraps `debouncedOnWS` with an async pre-pass
(`decryptLivePSKBatch`) that:
1. Skips the work entirely when no encrypted `GRP_TXT` is in the batch
or no PSK keys are stored.
2. For each match, rewrites `payload.channel`, `payload.sender`, and
`payload.text` so the existing `processWSBatch` consumes the packet
exactly the same way it consumes a server-decrypted `CHAN`.
3. Bumps a per-channel `unread` counter for any decrypted message
whose channel is not currently selected. The badge renders in the
sidebar (`.ch-unread-badge`) and resets on `selectChannel`.
`processWSBatch` itself is untouched, so the existing channel-view
behavior, dedup-by-packet-hash, region filtering, and timestamp ticker
all continue to work as before.
## TDD
- **Red** (`2e1ff05`): `test-channel-live-decrypt.js` asserts the new
helpers + the channels.js integration contract. With stub
`buildKeyMap`/`tryDecryptLive` returning empty/null, the test compiles
and runs to completion with **8/14 assertion failures** (no crashes,
no missing-symbol errors).
- **Green** (`1783658`): real implementation lands; **14/14 pass**.
## Verification (Rule 18)
- `node test-channel-live-decrypt.js` → 14/14 pass
- All other channel tests still pass:
- `test-channel-decrypt-ecb.js` 7/7
- `test-channel-decrypt-insecure-context.js` 8/8
- `test-channel-decrypt-m345.js` 24/24
- `test-channel-psk-ux.js` 19/19
- `cd cmd/server && go build ./...` clean
- Booted the server against the fixture DB and curled
`/channel-decrypt.js`, `/channels.js`, `/style.css` — all three serve
the new code with the auto-injected `__BUST__` cache buster.
## Performance
The WS pre-pass is gated by a quick scan: zero-cost when no encrypted
`GRP_TXT` is present in the batch. When PSK keys exist, the key map is
cached (sig-keyed on the stored-keys snapshot) so `crypto.subtle.digest`
runs once per stored key per change, not per packet. Each match costs
one MAC verify + one ECB decrypt — the same work
`fetchAndDecryptChannel`
already does, just amortized over time instead of in a single batch.
## Out of scope
- Decoupling the badge from the live feed (server should ideally tag
packets with `decryptionStatus` before broadcast). Tracked separately.
- Persisting the `unread` counter across reloads (currently in-memory).
---------
Co-authored-by: clawbot <bot@corescope.local>