From b60daff886ea5a3b1c8f94e4dbd5870d4d407467 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 16:19:38 +0100 Subject: [PATCH] feat(web): topic chat UI over /api/v1/* (v0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../dashboard/(user)/meshes/[id]/page.tsx | 57 +++++ .../(user)/meshes/[id]/topics/[name]/page.tsx | 136 +++++++++++ apps/web/src/config/paths.ts | 3 + .../web/src/modules/mesh/topic-chat-panel.tsx | 229 ++++++++++++++++++ packages/api/package.json | 3 +- packages/api/src/modules/mesh/api-key-auth.ts | 42 +++- 6 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/topics/[name]/page.tsx create mode 100644 apps/web/src/modules/mesh/topic-chat-panel.tsx diff --git a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx index 6016256..f8be2c9 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx @@ -3,8 +3,11 @@ import { notFound } from "next/navigation"; import { getMyMeshResponseSchema } from "@turbostarter/api/schema"; import { handle } from "@turbostarter/api/utils"; +import { db } from "@turbostarter/db/server"; +import { meshTopic } from "@turbostarter/db/schema/mesh"; import { Badge } from "@turbostarter/ui-web/badge"; import { buttonVariants } from "@turbostarter/ui-web/button"; +import { and, asc, eq, isNull } from "drizzle-orm"; import { pathsConfig } from "~/config/paths"; import { api } from "~/lib/api/server"; @@ -37,6 +40,17 @@ export default async function MeshPage({ (i) => !i.revokedAt && new Date(i.expiresAt) > new Date(), ); + const topics = await db + .select({ + id: meshTopic.id, + name: meshTopic.name, + description: meshTopic.description, + visibility: meshTopic.visibility, + }) + .from(meshTopic) + .where(and(eq(meshTopic.meshId, id), isNull(meshTopic.archivedAt))) + .orderBy(asc(meshTopic.name)); + return ( <> @@ -129,6 +143,49 @@ export default async function MeshPage({ )} +
+
+

+ Topics{" "} + ({topics.length}) +

+
+ {topics.length === 0 ? ( +

+ No topics yet. Run{" "} + + claudemesh topic create <name> + {" "} + from the CLI. +

+ ) : ( +
+ {topics.map((t) => ( + +
+ + # + {t.name} + + + {t.visibility} + +
+ {t.description ? ( + + {t.description} + + ) : null} + + ))} +
+ )} +
+

diff --git a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/topics/[name]/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/topics/[name]/page.tsx new file mode 100644 index 0000000..412d737 --- /dev/null +++ b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/topics/[name]/page.tsx @@ -0,0 +1,136 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +import { createDashboardApiKey } from "@turbostarter/api/modules/mesh/api-key-auth"; +import { getMyMeshResponseSchema } from "@turbostarter/api/schema"; +import { handle } from "@turbostarter/api/utils"; +import { db } from "@turbostarter/db/server"; +import { mesh, meshMember, meshTopic } from "@turbostarter/db/schema/mesh"; +import { Badge } from "@turbostarter/ui-web/badge"; +import { buttonVariants } from "@turbostarter/ui-web/button"; +import { and, asc, eq, isNull } from "drizzle-orm"; + +import { pathsConfig } from "~/config/paths"; +import { api } from "~/lib/api/server"; +import { getSession } from "~/lib/auth/server"; +import { getMetadata } from "~/lib/metadata"; +import { + DashboardHeader, + DashboardHeaderDescription, + DashboardHeaderTitle, +} from "~/modules/common/layout/dashboard/header"; +import { TopicChatPanel } from "~/modules/mesh/topic-chat-panel"; + +export const generateMetadata = getMetadata({ + title: "Topic", + description: "Chat in a topic.", +}); + +export default async function TopicChatPage({ + params, +}: { + params: Promise<{ id: string; name: string }>; +}) { + const { id, name: rawName } = await params; + const name = decodeURIComponent(rawName); + + const session = await getSession(); + if (!session?.user?.id) redirect(pathsConfig.auth.login); + + const data = await handle(api.my.meshes[":id"].$get, { + schema: getMyMeshResponseSchema, + })({ param: { id } }).catch(() => null); + if (!data?.mesh) notFound(); + + // Resolve the caller's member id — owner gets the oldest member row in the + // mesh as their identity, otherwise pick the explicit membership. + let memberId: string | null = null; + if (data.mesh.isOwner) { + const [m] = await db + .select({ id: meshMember.id }) + .from(meshMember) + .where(and(eq(meshMember.meshId, id), isNull(meshMember.revokedAt))) + .orderBy(asc(meshMember.joinedAt)) + .limit(1); + memberId = m?.id ?? null; + } else { + const [m] = await db + .select({ id: meshMember.id }) + .from(meshMember) + .where( + and( + eq(meshMember.meshId, id), + eq(meshMember.userId, session.user.id), + isNull(meshMember.revokedAt), + ), + ) + .limit(1); + memberId = m?.id ?? null; + } + if (!memberId) notFound(); + + const [topic] = await db + .select({ + id: meshTopic.id, + name: meshTopic.name, + description: meshTopic.description, + visibility: meshTopic.visibility, + }) + .from(meshTopic) + .where( + and( + eq(meshTopic.meshId, id), + eq(meshTopic.name, name), + isNull(meshTopic.archivedAt), + ), + ) + .limit(1); + if (!topic) notFound(); + + // Mint a fresh dashboard apikey for this user, scoped to read+send on + // this single topic. Lives 24h and is shown ONCE in the page HTML. + const key = await createDashboardApiKey({ + meshId: id, + memberId, + label: `dashboard:${session.user.id.slice(0, 8)}:${name}`, + capabilities: ["read", "send"], + topicScopes: [name], + }); + + return ( + <> + +
+
+ + + # + {topic.name} + + {topic.visibility} + + + + + {topic.description ?? `Topic in ${data.mesh.name}.`} + +
+ + ← Mesh + +
+
+ + + + ); +} diff --git a/apps/web/src/config/paths.ts b/apps/web/src/config/paths.ts index 0549daf..a01cfd6 100644 --- a/apps/web/src/config/paths.ts +++ b/apps/web/src/config/paths.ts @@ -99,6 +99,9 @@ const pathsConfig = { mesh: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}`, invite: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/invite`, live: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/live`, + topics: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/topics`, + topic: (id: string, name: string) => + `${DASHBOARD_PREFIX}/meshes/${id}/topics/${encodeURIComponent(name)}`, }, invites: `${DASHBOARD_PREFIX}/invites`, settings: { diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx new file mode 100644 index 0000000..5256a84 --- /dev/null +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { Badge } from "@turbostarter/ui-web/badge"; +import { Button } from "@turbostarter/ui-web/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card"; + +const POLL_INTERVAL_MS = 5000; + +interface TopicMessage { + id: string; + senderPubkey: string; + senderName: string; + nonce: string; + ciphertext: string; + createdAt: string; +} + +interface Props { + topicName: string; + topicId: string; + meshSlug: string; + apiKeySecret: string; + apiKeyExpiresAt: string; +} + +/** + * Encode plaintext into the broker's wire format. v0.2.0 uses base64 + * plaintext in the `ciphertext` field — real per-topic symmetric keys + * land in v0.3.0. Same applies to the random nonce: it satisfies the + * schema but isn't cryptographically meaningful yet. + */ +function encodeOutgoing(plaintext: string): { ciphertext: string; nonce: string } { + const bytes = new TextEncoder().encode(plaintext); + const ciphertext = + typeof window === "undefined" + ? Buffer.from(bytes).toString("base64") + : btoa(String.fromCharCode(...bytes)); + const nonceBytes = new Uint8Array(24); + crypto.getRandomValues(nonceBytes); + const nonce = + typeof window === "undefined" + ? Buffer.from(nonceBytes).toString("base64") + : btoa(String.fromCharCode(...nonceBytes)); + return { ciphertext, nonce }; +} + +function decodeIncoming(ciphertext: string): string { + try { + const decoded = + typeof window === "undefined" + ? Buffer.from(ciphertext, "base64").toString("utf-8") + : new TextDecoder().decode( + Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)), + ); + return decoded; + } catch { + return "[decode failed]"; + } +} + +function fmtTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +export function TopicChatPanel({ + topicName, + meshSlug, + apiKeySecret, + apiKeyExpiresAt, +}: Props) { + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + const scrollRef = useRef(null); + + const headers = useMemo( + () => ({ + Authorization: `Bearer ${apiKeySecret}`, + "Content-Type": "application/json", + }), + [apiKeySecret], + ); + + const refresh = useCallback(async () => { + try { + const res = await fetch( + `/api/v1/topics/${encodeURIComponent(topicName)}/messages?limit=100`, + { headers, cache: "no-store" }, + ); + if (!res.ok) { + setError(`history fetch failed: ${res.status}`); + return; + } + const json = (await res.json()) as { messages: TopicMessage[] }; + setMessages(json.messages.slice().reverse()); + setError(null); + } catch (e) { + setError((e as Error).message); + } + }, [headers, topicName]); + + useEffect(() => { + void refresh(); + const t = setInterval(refresh, POLL_INTERVAL_MS); + return () => clearInterval(t); + }, [refresh]); + + useEffect(() => { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: "smooth", + }); + }, [messages.length]); + + const send = async () => { + const text = draft.trim(); + if (!text) return; + setSending(true); + setError(null); + try { + const { ciphertext, nonce } = encodeOutgoing(text); + const res = await fetch("/api/v1/messages", { + method: "POST", + headers, + body: JSON.stringify({ topic: topicName, ciphertext, nonce }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + setError(`send failed: ${res.status} ${body}`); + return; + } + setDraft(""); + void refresh(); + } catch (e) { + setError((e as Error).message); + } finally { + setSending(false); + } + }; + + return ( + + + + # + {topicName} + +
+ + {meshSlug} + + + key expires {fmtTime(apiKeyExpiresAt)} + +
+
+ + + {messages.length === 0 ? ( +

+ No messages yet. Be the first. +

+ ) : ( +
    + {messages.map((m) => ( +
  1. +
    + + {m.senderName || m.senderPubkey.slice(0, 8)} + + + {m.senderPubkey.slice(0, 6)}… + + {fmtTime(m.createdAt)} +
    +

    + {decodeIncoming(m.ciphertext)} +

    +
  2. + ))} +
+ )} +
+ +
+ {error ? ( +

{error}

+ ) : null} +
{ + e.preventDefault(); + void send(); + }} + > +