feat(web): topic chat UI over /api/v1/* (v0.2.0)
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:
@@ -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 <name>
|
||||
</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">
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
229
apps/web/src/modules/mesh/topic-chat-panel.tsx
Normal file
229
apps/web/src/modules/mesh/topic-chat-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user