feat(broker+api): every mesh ships with a default #general topic
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

The web chat surface needed a guaranteed landing room — a topic that
exists for every mesh from creation onward so the dashboard always has
somewhere to drop the user. #general is the convention; ephemeral DMs
remain ephemeral (mesh.message_queue) so agentic privacy is unchanged.

Three hooks plus a backfill:

- packages/api/src/modules/mesh/mutations.ts — createMyMesh now calls
  ensureGeneralTopic() right after the mesh insert. New helper is
  idempotent via the unique (mesh_id, name) index.
- apps/broker/src/index.ts — handleMeshCreate (CLI claudemesh new)
  inserts #general + subscribes the owner member as 'lead' in the
  same handler.
- apps/broker/src/crypto.ts — invite-claim flow auto-subscribes the
  newly minted member to #general as 'member', defensively ensuring
  the topic exists if predates this change.
- packages/db/migrations/0024_general_topic_backfill.sql — one-shot
  backfill: creates #general for every active mesh that doesn't have
  one, subscribes every active member, and marks the mesh owner as
  'lead' based on owner_user_id == member.user_id. Idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 16:32:16 +01:00
parent f727620d16
commit 2e97a0eeee
4 changed files with 123 additions and 2 deletions

View File

@@ -10,7 +10,7 @@
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import sodium from "libsodium-wrappers";
import { db } from "./db";
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
import { invite as inviteTable, mesh, meshMember, meshTopic, meshTopicMember } from "@turbostarter/db/schema/mesh";
let ready = false;
async function ensureSodium(): Promise<typeof sodium> {
@@ -344,6 +344,32 @@ export async function claimInviteV2Core(params: {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 6b. Auto-subscribe the new member to #general (the default mesh-wide
// room). Idempotent via unique (topic_id, member_id). If the mesh was
// created before #general auto-creation existed, ensure it now via a
// best-effort INSERT … ON CONFLICT — backfill migration handles the
// bulk case so this is just a safety net.
await db
.insert(meshTopic)
.values({
meshId: inv.meshId,
name: "general",
description: "Default mesh-wide channel. Every member can read and post.",
visibility: "public",
})
.onConflictDoNothing();
const [generalTopic] = await db
.select({ id: meshTopic.id })
.from(meshTopic)
.where(and(eq(meshTopic.meshId, inv.meshId), eq(meshTopic.name, "general")))
.limit(1);
if (generalTopic) {
await db
.insert(meshTopicMember)
.values({ topicId: generalTopic.id, memberId: row.id, role: "member" })
.onConflictDoNothing();
}
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
let sealed: string;
try {

View File

@@ -5945,6 +5945,22 @@ async function handleCliMeshCreate(req: IncomingMessage, res: ServerResponse, st
VALUES (${memberId}, ${meshId}, ${auth.userId}, ${body.pubkey}, ${body.name + "-owner"}, ${"admin"})
`);
// Auto-create the conventional #general topic + subscribe the owner.
// Idempotent via unique (mesh_id, name) — re-running is a no-op.
const generalTopicId = generateId();
await db.execute(sql`
INSERT INTO mesh.topic (id, mesh_id, name, description, visibility, created_by_member_id)
VALUES (${generalTopicId}, ${meshId}, ${"general"}, ${"Default mesh-wide channel. Every member can read and post."}, ${"public"}, ${memberId})
ON CONFLICT (mesh_id, name) DO NOTHING
`);
await db.execute(sql`
INSERT INTO mesh.topic_member (topic_id, member_id, role)
SELECT t.id, ${memberId}, ${"lead"}
FROM mesh.topic t
WHERE t.mesh_id = ${meshId} AND t.name = ${"general"}
ON CONFLICT (topic_id, member_id) DO NOTHING
`);
writeJson(res, 200, { id: meshId, slug, name: body.name, member_id: memberId });
log.info("mesh-create", { route: "POST /cli/mesh/create", slug, user_id: auth.userId, latency_ms: Date.now() - started });
} catch (e) {