feat(web): topic chat UI over /api/v1/* (v0.2.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 16:19:38 +01:00
parent 7d35c779f4
commit b60daff886
6 changed files with 468 additions and 2 deletions

View File

@@ -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 (
<>
<DashboardHeader>
@@ -129,6 +143,49 @@ export default async function MeshPage({
)}
</section>
<section className="rounded-lg border">
<header className="flex items-center justify-between border-b px-4 py-3">
<h2 className="font-medium">
Topics{" "}
<span className="text-muted-foreground">({topics.length})</span>
</h2>
</header>
{topics.length === 0 ? (
<p className="text-muted-foreground px-4 py-8 text-center text-sm">
No topics yet. Run{" "}
<code className="bg-muted rounded px-1.5 py-0.5 text-xs">
claudemesh topic create &lt;name&gt;
</code>{" "}
from the CLI.
</p>
) : (
<div className="divide-y">
{topics.map((t) => (
<Link
key={t.id}
href={pathsConfig.dashboard.user.meshes.topic(mesh.id, t.name)}
className="hover:bg-muted/50 flex flex-col gap-1.5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<span className="font-medium">
<span className="text-muted-foreground">#</span>
{t.name}
</span>
<Badge variant="outline" className="text-xs">
{t.visibility}
</Badge>
</div>
{t.description ? (
<span className="text-muted-foreground truncate text-xs">
{t.description}
</span>
) : null}
</Link>
))}
</div>
)}
</section>
<section className="rounded-lg border">
<header className="flex items-center justify-between border-b px-4 py-3">
<h2 className="font-medium">

View File

@@ -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 (
<>
<DashboardHeader>
<div className="flex w-full items-start justify-between gap-4">
<div>
<DashboardHeaderTitle>
<span className="flex items-center gap-3">
<span className="text-muted-foreground">#</span>
{topic.name}
<Badge variant="outline" className="font-mono text-xs">
{topic.visibility}
</Badge>
</span>
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{topic.description ?? `Topic in ${data.mesh.name}.`}
</DashboardHeaderDescription>
</div>
<Link
href={pathsConfig.dashboard.user.meshes.mesh(id)}
className={buttonVariants({ variant: "outline" })}
>
Mesh
</Link>
</div>
</DashboardHeader>
<TopicChatPanel
topicName={topic.name}
topicId={topic.id}
meshSlug={data.mesh.slug}
apiKeySecret={key.secret}
apiKeyExpiresAt={key.expiresAt.toISOString()}
/>
</>
);
}

View File

@@ -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: {

View File

@@ -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<TopicMessage[]>([]);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(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 (
<Card className="flex h-[70vh] flex-col">
<CardHeader className="flex-row items-center justify-between border-b py-3">
<CardTitle className="text-base font-medium">
<span className="text-muted-foreground">#</span>
{topicName}
</CardTitle>
<div className="flex items-center gap-2 text-xs">
<Badge variant="outline" className="font-mono">
{meshSlug}
</Badge>
<span className="text-muted-foreground">
key expires {fmtTime(apiKeyExpiresAt)}
</span>
</div>
</CardHeader>
<CardContent
ref={scrollRef}
className="flex-1 overflow-y-auto p-4"
>
{messages.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm">
No messages yet. Be the first.
</p>
) : (
<ol className="flex flex-col gap-3">
{messages.map((m) => (
<li key={m.id} className="flex flex-col gap-0.5">
<div className="text-muted-foreground flex items-baseline gap-2 text-xs">
<span className="font-medium text-foreground">
{m.senderName || m.senderPubkey.slice(0, 8)}
</span>
<span className="font-mono">
{m.senderPubkey.slice(0, 6)}
</span>
<span>{fmtTime(m.createdAt)}</span>
</div>
<p className="text-sm whitespace-pre-wrap break-words">
{decodeIncoming(m.ciphertext)}
</p>
</li>
))}
</ol>
)}
</CardContent>
<div className="border-t p-3">
{error ? (
<p className="mb-2 text-xs text-destructive">{error}</p>
) : null}
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void send();
}}
>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={`Message #${topicName}`}
rows={1}
className="flex-1 resize-none rounded-md border bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
/>
<Button type="submit" disabled={sending || !draft.trim()}>
{sending ? "…" : "Send"}
</Button>
</form>
</div>
</Card>
);
}

View File

@@ -7,7 +7,8 @@
".": "./src/index.ts",
"./env": "./src/env.ts",
"./utils": "./src/utils/index.ts",
"./schema": "./src/schema/index.ts"
"./schema": "./src/schema/index.ts",
"./modules/mesh/api-key-auth": "./src/modules/mesh/api-key-auth.ts"
},
"scripts": {
"clean": "git clean -xdf .cache .turbo dist node_modules",

View File

@@ -15,7 +15,7 @@ import { HttpStatusCode } from "@turbostarter/shared/constants";
import { db } from "@turbostarter/db/server";
import { meshApiKey } from "@turbostarter/db/schema/mesh";
import { and, eq } from "drizzle-orm";
import { createHash, timingSafeEqual } from "node:crypto";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
export type ApiKeyCapability = "send" | "read" | "state_write" | "admin";
@@ -109,3 +109,43 @@ export function requireTopicScope(key: AuthedApiKey, topicName: string): void {
code: "error.api_key_topic_out_of_scope",
});
}
/**
* Mint an API key for an authenticated dashboard user. Returns the plaintext
* secret — the caller is responsible for handing it to the browser only over
* the authenticated session render and never persisting it server-side
* outside the (hashed) row this writes.
*
* The default capabilities are read+send and the default expiry is 24h, which
* matches the lifetime of a typical dashboard session. The browser caches the
* secret in `sessionStorage`; on the next page load we mint a fresh one.
*/
export async function createDashboardApiKey(args: {
meshId: string;
memberId: string;
label: string;
capabilities?: ApiKeyCapability[];
topicScopes?: string[] | null;
expiresInMs?: number;
}): Promise<{ id: string; secret: string; expiresAt: Date }> {
const bytes = randomBytes(32);
const plaintext = "cm_" + bytes.toString("base64url");
const prefix = plaintext.slice(0, 11);
const hash = createHash("sha256").update(plaintext).digest("hex");
const expiresAt = new Date(Date.now() + (args.expiresInMs ?? 24 * 60 * 60 * 1000));
const [row] = await db
.insert(meshApiKey)
.values({
meshId: args.meshId,
label: args.label,
secretHash: hash,
secretPrefix: prefix,
capabilities: args.capabilities ?? ["read", "send"],
topicScopes: args.topicScopes ?? null,
issuedByMemberId: args.memberId,
expiresAt,
})
.returning({ id: meshApiKey.id });
if (!row) throw new Error("failed to mint dashboard api key");
return { id: row.id, secret: plaintext, expiresAt };
}