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

@@ -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 };
}