From 2e97a0eeee3f7a8171a277e35240f45cf77b772c 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:32:16 +0100 Subject: [PATCH] feat(broker+api): every mesh ships with a default #general topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/broker/src/crypto.ts | 28 ++++++++++- apps/broker/src/index.ts | 16 +++++++ packages/api/src/modules/mesh/mutations.ts | 47 ++++++++++++++++++- .../0024_general_topic_backfill.sql | 34 ++++++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 packages/db/migrations/0024_general_topic_backfill.sql diff --git a/apps/broker/src/crypto.ts b/apps/broker/src/crypto.ts index cb36205..65d247b 100644 --- a/apps/broker/src/crypto.ts +++ b/apps/broker/src/crypto.ts @@ -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 { @@ -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 { diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 8472171..6cb5c4a 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -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) { diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts index 15dcaf6..58d78c9 100644 --- a/packages/api/src/modules/mesh/mutations.ts +++ b/packages/api/src/modules/mesh/mutations.ts @@ -3,7 +3,14 @@ import { randomBytes } from "node:crypto"; import sodium from "libsodium-wrappers"; import { and, eq, isNull } from "@turbostarter/db"; -import { invite, mesh, meshMember, pendingInvite } from "@turbostarter/db/schema"; +import { + invite, + mesh, + meshMember, + meshTopic, + meshTopicMember, + pendingInvite, +} from "@turbostarter/db/schema"; import { db } from "@turbostarter/db/server"; import type { @@ -142,9 +149,47 @@ export const createMyMesh = async ({ }) .returning({ id: mesh.id, slug: mesh.slug }); + if (created) { + await ensureGeneralTopic(created.id); + } + return created!; }; +/** + * Idempotently create the conventional `#general` topic for a mesh. + * + * `#general` is the default web-readable room: a public topic that every + * mesh has so the dashboard chat surface always has somewhere to land. + * Subscription is not required for read access via the REST surface, so + * subscribing members happens lazily at member-row creation time + * (invite-claim) rather than here. + * + * Safe to call repeatedly — the unique (meshId, name) index keeps it a + * no-op on the second call. + */ +export const ensureGeneralTopic = async ( + meshId: string, +): Promise<{ id: string } | null> => { + const [existing] = await db + .select({ id: meshTopic.id }) + .from(meshTopic) + .where(and(eq(meshTopic.meshId, meshId), eq(meshTopic.name, "general"))) + .limit(1); + if (existing) return existing; + const [row] = await db + .insert(meshTopic) + .values({ + meshId, + name: "general", + description: "Default mesh-wide channel. Every member can read and post.", + visibility: "public", + }) + .onConflictDoNothing() + .returning({ id: meshTopic.id }); + return row ?? null; +}; + export const archiveMyMesh = async ({ userId, meshId, diff --git a/packages/db/migrations/0024_general_topic_backfill.sql b/packages/db/migrations/0024_general_topic_backfill.sql new file mode 100644 index 0000000..cf52ff3 --- /dev/null +++ b/packages/db/migrations/0024_general_topic_backfill.sql @@ -0,0 +1,34 @@ +-- 0024_general_topic_backfill.sql +-- +-- Every mesh now ships with a default #general topic auto-created on mesh +-- creation. This migration backfills the convention for meshes that +-- predate that hook: +-- 1. Insert a #general row for every mesh that doesn't already have one. +-- 2. Subscribe every active (non-revoked) member to #general. +-- +-- Idempotent — safe to re-run. The unique indices on (mesh_id, name) and +-- (topic_id, member_id) make the inserts no-ops on the second pass. + +INSERT INTO mesh.topic (mesh_id, name, description, visibility) +SELECT + m.id, + 'general', + 'Default mesh-wide channel. Every member can read and post.', + 'public' +FROM mesh.mesh m +WHERE m.archived_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM mesh.topic t + WHERE t.mesh_id = m.id AND t.name = 'general' + ); + +INSERT INTO mesh.topic_member (topic_id, member_id, role) +SELECT + t.id, + mm.id, + CASE WHEN m.owner_user_id = mm.user_id THEN 'lead' ELSE 'member' END +FROM mesh.topic t +JOIN mesh.mesh m ON m.id = t.mesh_id +JOIN mesh.member mm ON mm.mesh_id = t.mesh_id AND mm.revoked_at IS NULL +WHERE t.name = 'general' +ON CONFLICT (topic_id, member_id) DO NOTHING;