feat(broker+api): every mesh ships with a default #general topic
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:
@@ -10,7 +10,7 @@
|
|||||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||||
import sodium from "libsodium-wrappers";
|
import sodium from "libsodium-wrappers";
|
||||||
import { db } from "./db";
|
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;
|
let ready = false;
|
||||||
async function ensureSodium(): Promise<typeof sodium> {
|
async function ensureSodium(): Promise<typeof sodium> {
|
||||||
@@ -344,6 +344,32 @@ export async function claimInviteV2Core(params: {
|
|||||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
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.
|
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
|
||||||
let sealed: string;
|
let sealed: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5945,6 +5945,22 @@ async function handleCliMeshCreate(req: IncomingMessage, res: ServerResponse, st
|
|||||||
VALUES (${memberId}, ${meshId}, ${auth.userId}, ${body.pubkey}, ${body.name + "-owner"}, ${"admin"})
|
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 });
|
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 });
|
log.info("mesh-create", { route: "POST /cli/mesh/create", slug, user_id: auth.userId, latency_ms: Date.now() - started });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { randomBytes } from "node:crypto";
|
|||||||
import sodium from "libsodium-wrappers";
|
import sodium from "libsodium-wrappers";
|
||||||
|
|
||||||
import { and, eq, isNull } from "@turbostarter/db";
|
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 { db } from "@turbostarter/db/server";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -142,9 +149,47 @@ export const createMyMesh = async ({
|
|||||||
})
|
})
|
||||||
.returning({ id: mesh.id, slug: mesh.slug });
|
.returning({ id: mesh.id, slug: mesh.slug });
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
await ensureGeneralTopic(created.id);
|
||||||
|
}
|
||||||
|
|
||||||
return created!;
|
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 ({
|
export const archiveMyMesh = async ({
|
||||||
userId,
|
userId,
|
||||||
meshId,
|
meshId,
|
||||||
|
|||||||
34
packages/db/migrations/0024_general_topic_backfill.sql
Normal file
34
packages/db/migrations/0024_general_topic_backfill.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user