Closes the last gap from phase 3.5: web-created topics start as v1
plaintext (mutations.ts ensureGeneralTopic doesn't generate a key,
because the dashboard owner has a throwaway pubkey with no secret).
Once the browser identity is registered via /v1/me/peer-pubkey, the
chat panel can lazily upgrade the topic to v2.
API (POST /v1/topics/:name/claim-key)
- Atomic claim: only succeeds when topic.encrypted_key_pubkey IS
NULL. Body carries the new senderPubkey + the caller's sealed copy
of the freshly-generated topic key. Race losers get 409 with the
winning senderPubkey so they fall through to the regular fetch
path. Idempotent at topic_member_key level.
Web
- claimTopicKey() in services/crypto/topic-key.ts: generates a fresh
32-byte symmetric key, seals for self, POSTs the claim. Returns
the in-memory key so the caller can encrypt immediately without a
follow-up GET /key round-trip.
- sealTopicKeyFor(): mirrors the CLI helper so a browser holder can
re-seal for newcomers (CLI peers, other browsers) instead of the
topic going dark when only a browser has the key.
- TopicChatPanel: when keyState === "topic_unencrypted", composer
now shows a "🔓 plaintext (v1) — encryption not yet enabled" line
with an "enable encryption" button. Click → claimTopicKey → state
flips to "ready" → 🔒 v0.3.0 banner appears. On race-lost, falls
through to fetch.
- New 30s re-seal loop fires while holding the key: polls
/pending-seals, seals via sealTopicKeyFor for each pending target,
POSTs to /seal. Same cadence + soft-fail discipline as the CLI.
Net effect: any dashboard user can convert legacy v1 topics to v2
with a single click, and CLI peers joining later will receive a
sealed copy from the browser's re-seal loop without manual action.
Adds a 409 not_web_member guard to POST /v1/me/peer-pubkey: the
endpoint will only rewrite peer_pubkey on members that have
dashboard_user_id set. CLI members own their on-disk keypair —
overwriting their stored peer_pubkey would break the next WS hello
because the signature verification would fail against the new
pubkey.
In practice this restriction is invisible to the legitimate browser
flow: the dashboard always mints its apikey against the web member
(dashboard_user_id is non-null by construction in mutations.ts).
Guard ensures a misuse (e.g. a CLI-minted apikey being used to call
peer-pubkey) gets a clear 409 instead of silently breaking the CLI's
auth.
Discovered during phase 3.5 smoke when a CLI-minted apikey clobbered
the only openclaw member (CLI-owned) and the user's CLI signature
would have stopped verifying on the next launch.
Closes the v1-vs-v2 split between CLI and dashboard. The web chat
panel now reads and writes the same crypto_secretbox-under-topic-key
ciphertext that CLI 1.8.0+ writes — every encrypted topic finally
renders correctly from the browser.
API
- POST /v1/me/peer-pubkey replaces the throwaway pubkey that
mutations.ts mints at mesh-create time with one whose secret the
browser actually holds. Idempotent; auth via the dashboard apikey
whose issuedByMemberId is the row to update.
Web
- apps/web/src/services/crypto/identity.ts — IndexedDB-backed
ed25519 identity, lazy-init on first use. Generates once per
browser-profile; survives reload. ed25519 → x25519 derivation for
crypto_box decrypt. Module-cached after first call.
- apps/web/src/services/crypto/topic-key.ts — mirrors the CLI
topic-key service. Fetches GET /v1/topics/:name/key, decrypts the
sealed copy with our x25519 secret, caches the 32-byte symmetric
key in-memory keyed by (apikey-prefix, topic). encryptMessage /
decryptMessage map directly onto crypto_secretbox{,_open}.
- apps/web/src/modules/mesh/topic-chat-panel.tsx — on mount:
registers our pubkey, fetches the topic key, polls /key every 5s
while not_sealed (matching the CLI's 30s re-seal cadence). Render
branches on bodyVersion: v2 -> decrypted-cache, v1 -> legacy
base64. Send branches: encrypts under the topic key when key is
ready, falls back to v1 plaintext on legacy or not-yet-sealed
topics. Composer shows a 🔒 v0.3.0 / "waiting for re-seal" badge.
Adds libsodium-wrappers + @types to apps/web. Browser bundle picks
up its own copy; the existing CLI/broker/API copies are untouched.
Threat model: IndexedDB is per-origin and not exfiltratable from
other sites; XSS or a malicious extension still wins, same as for
any browser-stored secret. Documented divergence from the CLI's
~/.claudemesh-stored keypair in the identity module's preamble.
Previously POST /v1/messages returned the message_queue row id as
`messageId`. Topic posts ARE durable (in topic_message); the queue
entry drains on delivery. Pasting that id into `--reply-to` failed
because the broker validates parents against topic_message, not the
queue. Now `messageId` aliases `historyId` for topic posts; both
`historyId` and `queueId` remain available as explicit fields.
Roadmap and CLI README updated with v0.3.1 reply-to + v0.3.2
multi-session entries.
Adds a reply_to_id column (self-FK on topic_message) plus end-to-end
plumbing so a message can mark itself as a reply to a previous one in
the same topic.
- Schema: 0027_topic_message_reply_to.sql adds reply_to_id with
ON DELETE SET NULL + index for backlink lookup.
- Broker: appendTopicMessage validates parent shares the topic, writes
reply_to_id; topicHistory + topic_history_response surface it; WS
push envelope now carries senderMemberId, senderName, topic name,
reply_to_id, and message_id so recipients have everything they need
to reply without a follow-up query.
- REST: POST /v1/messages accepts replyToId (validated server-side);
GET /messages and SSE /stream emit it per row.
- CLI: \`topic post --reply-to <id|prefix>\` resolves prefixes against
recent history; \`topic tail\` renders an "↳ in reply to <name>:
<snippet>" line above replies and shows a copyable #shortid tag on
every row.
- MCP push pipe: channel attributes now include from_pubkey,
from_member_id, message_id, topic, reply_to_id — the recipient can
thread a reply directly from the inbound notification.
- Skill + identity prompt updated to teach Claude how to use the new
attributes for replies.
Bumped CLI to 1.9.0.
Wire format:
topic_member_key.encrypted_key = base64(
<32-byte sender x25519 pubkey> || crypto_box(topic_key)
)
Embedding sender pubkey inline lets re-sealed copies (carrying a
different sender than the original creator-seal) decode the same
way as creator copies, without an extra schema column or join.
topic.encrypted_key_pubkey stays for backwards-compat metadata
but the wire truth is the inline prefix.
API (phase 3):
GET /v1/topics/:name/pending-seals list members without keys
POST /v1/topics/:name/seal submit a re-sealed copy
POST /v1/messages now accepts bodyVersion (1|2); v2 skips the
regex mention extraction (server can't read v2 ciphertext).
GET /messages + /stream now return bodyVersion per row.
Broker + web mutations updated to use the inline-sender format
when sealing. ensureGeneralTopic (web) also generates topic keys
per the bugfix that landed earlier today; both producers now
share one wire format.
CLI (claudemesh-cli@1.8.0):
+ apps/cli/src/services/crypto/topic-key.ts — fetch/decrypt/encrypt/seal
+ claudemesh topic post <name> <msg> — encrypted REST send (v2)
* claudemesh topic tail <name> — decrypts v2 on render, runs a
30s background re-seal loop for pending joiners
Web client stays on v1 plaintext until phase 3.5 (browser-side
persistent identity in IndexedDB). Mention fan-out from phase 1
already works for both versions, so /v1/notifications keeps
working through the cutover.
Spec at .artifacts/specs/2026-05-02-topic-key-onboarding.md
updated with the implemented inline-sender format and the
phase 3.5 web plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 (infra layer) of v0.3.0. Topics now generate a 32-byte
XSalsa20-Poly1305 key on creation; the broker seals one copy via
crypto_box for the topic creator using an ephemeral x25519
sender keypair (whose public half lives on
topic.encrypted_key_pubkey). Topic key plaintext leaves memory
immediately after the creator's seal — the broker can't read it.
Schema 0026:
+ topic.encrypted_key_pubkey (text, nullable for legacy v0.2.0)
+ topic_message.body_version (integer, 1=plaintext / 2=v2 cipher)
+ topic_member_key (id, topic_id, member_id,
encrypted_key, nonce, rotated_at)
API:
+ GET /v1/topics/:name/key — return the calling member's sealed
copy. 404 if no copy exists yet (joined post-creation, no peer
has re-sealed). 409 if the topic is legacy unencrypted.
Open question parked: how new joiners get their sealed copy
without ceding plaintext to the broker. Spec at
.artifacts/specs/2026-05-02-topic-key-onboarding.md picks
member-driven re-seal (Option B). Pending-seals endpoint, seal
POST, and the actual on-the-wire encryption ship in phase 3.
Mention fan-out from phase 1 (notification table) is decoupled
from ciphertext, so /v1/notifications + MentionsSection keep
working unchanged through both phases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of v0.3.0 — replaces the regex-on-decoded-ciphertext scan
in /v1/notifications and the dashboard MentionsSection with reads
from a new mesh.notification table populated at write time.
Schema 0025: mesh.notification (id, mesh_id, topic_id, message_id,
recipient_member_id, sender_member_id, kind, created_at, read_at)
with a unique (message_id, recipient) so a re-fanned message yields
one row per recipient. Backfills existing v0.2.0 messages by
regex-matching the (still-base64-plaintext) bodies — guarded with
a base64 + length check so binary ciphertext doesn't crash the
migration.
Writers (POST /v1/messages + broker appendTopicMessage) now
extract @-mentions from either an explicit `mentions: string[]`
on the request OR a regex over the base64 plaintext (transitional
fallback). Targets are intersected with the mesh roster + capped
at 32 per message. Web chat panel sends the explicit array now so
it keeps working after phase 2 lands.
Readers switch to JOIN-on-notification:
/v1/notifications — table-backed, supports ?unread=1
POST /v1/notifications/read — new, mark by ids or all-up-to
MentionsSection (RSC) — same JOIN, returns readAt for each row
GET /v1/notifications also gains a read_at field per row so a
future bell UI can show unread vs read.
Once per-topic encryption (phase 2) lands, the regex fallback
becomes a no-op for v2 messages — clients MUST send `mentions`,
which they already do.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recently-active apikey holders (used in the last 5 minutes) appear
in the peer list alongside WS-connected sessions. The dashboard
chat user now becomes visible to CLI peers calling list_peers,
closing the v1.6.0 humans-as-peers loop.
Presence rows take precedence when both exist; rest-only rows
get via:"rest" flag and idle status (no presence channel to
infer working/dnd from).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Universe dashboard gets a "Recent mentions" section listing every
topic_message from the last 7 days that references the viewer via
`@<displayName>` (per-mesh — a user can carry different display
names in different meshes). One union'd OR query, capped at 20.
Each mention card links straight into the topic chat at the right
mesh. Snippet is the first 240 chars of the decoded ciphertext with
@-tokens highlighted in clay, matching the in-chat renderer.
GET /v1/notifications mirrors the same scan for api-key-authed
clients (CLI, bots) — accepts ?since=<ISO> for incremental polling.
Both paths use Postgres regex on the decoded base64 plaintext;
when per-topic encryption lands in v0.3.0 they'll move to a
notification table populated at write time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /v1/members lists every non-revoked member of the api key's
mesh, decorated with online state from presence rows. Distinct from
/v1/peers (active sessions) — sidebars want roster + live dot, not
just whoever is currently connected.
Chat panel splits into a 2-column layout (>=lg) with a 180px
sidebar that polls the roster every 20s. Online members go up top
with status-coloured dots (idle=green, working=clay, dnd=fig);
offline members fade below at 50% opacity. Bots get a "bot" tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PATCH /v1/topics/:name/read upserts topic_member.last_read_at for the
api key's issuing member. The chat panel calls it on mount and on
every inbound SSE message (5s debounce so we don't hammer it).
GET /v1/topics now returns unread per topic — counts messages newer
than last_read_at and not authored by the viewer. Mesh detail page
shows a clay-rounded badge next to each topic name with the count
(99+ ceiling).
AuthedApiKey gains issuedByMemberId so endpoints can attribute
side-effects to the minting member. Required because external api
keys aren't tied to a specific peer member; only dashboard- and
CLI-minted keys carry one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /v1/topics/:name/stream opens an SSE firehose, polled server-side
every 2s and streamed as `message` events. Forward-only — clients
hit /messages once for backfill, then live from connect-time onward.
Heartbeats every 30s keep the connection through proxies.
Web chat panel reads the stream via fetch + ReadableStream so the
bearer token stays in the Authorization header (EventSource can't
set custom headers, which would force token-in-URL leaks). Auto-
reconnect with exponential backoff. setInterval polling removed.
Vercel maxDuration bumped to 300s on the catch-all API route so
streams aren't cut at the 10s default.
drizzle migrations/meta/ deleted — superseded by the filename-
tracked custom runner in apps/broker/src/migrate.ts (c2cd67a).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bearer-auth REST endpoints for humans, scripts, bots — anyone without
browser-side ed25519. Same key model as broker WS, scoped by capability
and optional topic whitelist.
Endpoints (v0.2.0 minimum):
- POST /v1/messages
- GET /v1/topics
- GET /v1/topics/:name/messages (limit, before cursor)
- GET /v1/peers
Auth: Authorization: Bearer cm_<secret>. Middleware verifies prefix +
SHA-256 hash with constant-time compare; capability + topic-scope
asserted per route. Cross-mesh isolation: every endpoint scopes to
apiKey.meshId.
Live delivery: writes to messageQueue + topic_message; broker's
existing pendingTimer drains and pushes to live peers. Real-time
push from REST writes is a follow-up.
Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>