From 7f6af0137dd2e6cad35499aedbccdae83aeae52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 2 May 2026 23:22:26 +0100 Subject: [PATCH] feat(api+web): browser claims + re-seals encryption on v1 topics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../web/src/modules/mesh/topic-chat-panel.tsx | 89 +++++++++++++ apps/web/src/services/crypto/topic-key.ts | 110 +++++++++++++++ packages/api/src/modules/mesh/v1-router.ts | 126 ++++++++++++++++++ 3 files changed, 325 insertions(+) diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx index 0b7b792..5d1a24a 100644 --- a/apps/web/src/modules/mesh/topic-chat-panel.tsx +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -6,10 +6,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@turbostarter/ui-web/button"; import { + claimTopicKey, decryptMessage, encryptMessage, getTopicKey, registerBrowserPeerPubkey, + sealTopicKeyFor, } from "~/services/crypto/topic-key"; interface TopicMessage { @@ -313,6 +315,56 @@ export function TopicChatPanel({ }; }, [apiKeySecret, topicName]); + // Browser-side re-seal loop. While we hold the topic key, every 30s + // we look for newly-joined topic members who don't yet have a sealed + // copy and seal it for them. Mirrors the CLI re-seal path so a topic + // claimed-by-browser doesn't go dark for CLI joiners. + useEffect(() => { + if (!topicKey || keyState !== "ready") return; + let cancelled = false; + const reseal = async () => { + try { + const res = await fetch( + `/api/v1/topics/${encodeURIComponent(topicName)}/pending-seals`, + { headers, cache: "no-store" }, + ); + if (!res.ok) return; + const json = (await res.json()) as { + pending: Array<{ memberId: string; pubkey: string; displayName: string }>; + }; + for (const target of json.pending) { + if (cancelled) return; + const sealed = await sealTopicKeyFor(topicKey, target.pubkey); + if (!sealed) continue; + try { + await fetch( + `/api/v1/topics/${encodeURIComponent(topicName)}/seal`, + { + method: "POST", + headers, + body: JSON.stringify({ + memberId: target.memberId, + encryptedKey: sealed.encryptedKey, + nonce: sealed.nonce, + }), + }, + ); + } catch { + // Another holder likely sealed first — fine to swallow. + } + } + } catch { + // Soft-fail; next tick retries. + } + }; + void reseal(); + const t = setInterval(reseal, 30_000); + return () => { + cancelled = true; + clearInterval(t); + }; + }, [topicKey, keyState, headers, topicName]); + // Decrypt any v2 messages that we haven't decrypted yet. Runs after // `messages` updates (history backfill, SSE delivery) and after // `topicKey` lands. @@ -849,6 +901,43 @@ export function TopicChatPanel({ > 🔒 end-to-end encrypted (v0.3.0)
+ ) : keyState === "topic_unencrypted" ? ( +