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.
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.
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>
A "search" toggle in the chat header opens a small input that
client-filters loaded messages by plaintext match on body or
sender name. Live tail auto-scroll suspends while a query is
active so matches stay visible when new messages arrive.
Server-side fulltext search lands when ciphertext moves to
per-topic symmetric keys in v0.3.0 — until then there's no
server index to query, and the loaded window (last 100 plus
forward stream) covers most "find that thing from earlier"
needs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Typing `@` in the compose box opens a dropdown of matching mesh
members fed by /v1/members. Filters live by displayName prefix
(case-insensitive); online members rank above offline; shorter
names rank higher; capped at 8 entries.
Keyboard: ArrowUp/Down to navigate, Enter or Tab to insert,
Escape to dismiss. Mouse hover updates the selection; mousedown
inserts (mousedown so the textarea doesn't lose focus first).
Rendered messages now highlight @mentions in clay so they're
visually distinct from plain text — same regex the autocomplete
uses, so the round trip is consistent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A revoked api key or missing topic returned by GET /v1/.../stream
used to throw inside the catch and bounce through the backoff loop
forever. Now any 4xx response terminates the loop and surfaces the
status + body in the panel error so the user sees the real cause.
5xx and network errors still reconnect.
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>
Audit against peer-graph-panel, live-stream-panel, state-timeline-panel,
and resource-panel showed the chat used generic shadcn Card chrome
instead of the established panel pattern. Refactor swaps the wrapper
to the canonical idiom:
- rounded-[var(--cm-radius-lg)] + border-[var(--cm-border)] + bg-[var(--cm-bg)]
- mono header strip with clay-pulse fetch dot, 11px label, 10px metadata
- mono 9px footer status bar (mesh slug · poll cadence · key expiry)
- Anthropic Mono via var(--cm-font-mono) on chrome, sans on message body
- compose textarea uses cm-bg-elevated + cm-border-hover focus state
- error line in cm-fig (#c46686) instead of generic destructive
No behavior change — only chrome. Polling, send path, decode logic
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New dashboard route at /dashboard/meshes/[id]/topics/[name] gives signed-in
users a thin chat client over the v0.2.0 REST surface. The mesh detail page
now lists topics with one-click links into the chat. Backend layout:
- packages/api/src/modules/mesh/api-key-auth.ts — exports
createDashboardApiKey() that mints a 24h read+send key scoped to a single
topic for the caller's member id. The page server component calls this on
every render and embeds the secret in the props of the client component;
the secret never touches sessionStorage so a tab close = key effectively
abandoned (the row remains until expiresAt).
- apps/web/.../topics/[name]/page.tsx — server component, NextAuth gate,
resolves the user's meshMember.id, mints the key, renders the shell.
- apps/web/src/modules/mesh/topic-chat-panel.tsx — client component, polls
GET /v1/topics/:name/messages every 5s, sends via POST /v1/messages.
Encoding wraps base64(plaintext) into the ciphertext field — matches the
current broker contract until per-topic HKDF lands in v0.3.0.
The mesh detail page gains a Topics section with empty-state copy that
points users at the CLI verb (claudemesh topic create) for now; topic
creation from the web UI is a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>