diff --git a/apps/broker/scripts/backfill-owner-members.ts b/apps/broker/scripts/backfill-owner-members.ts new file mode 100644 index 0000000..dcf7ea1 --- /dev/null +++ b/apps/broker/scripts/backfill-owner-members.ts @@ -0,0 +1,101 @@ +/** + * One-shot backfill: every active mesh whose owner has no peer-identity + * member row gets one minted via a fresh ed25519 keypair. Without this, + * web-first owners (who never connected via CLI) can't access the chat + * surface — issueDashboardApiKey is a FK to mesh.member, and the topic + * page server component's owner branch picks the oldest member row in + * the mesh (which is null if none exist). + * + * Idempotent. Safe to re-run. Each run prints per-mesh status. + * + * Owner identification: a member is the "owner's row" when its user_id + * matches mesh.owner_user_id. The script targets meshes that have zero + * such matching rows (regardless of total member count — a mesh with + * peers but no owner member also gets a fresh owner row). + * + * The owner row is also auto-subscribed to #general as 'lead' so the + * unread/role accounting matches CLI-flow meshes. + * + * Usage: + * DATABASE_URL=... bun apps/broker/scripts/backfill-owner-members.ts + */ + +import postgres from "postgres"; +import sodium from "libsodium-wrappers"; + +interface Orphan { + meshId: string; + slug: string; + ownerUserId: string; + meshName: string; +} + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL not set"); + process.exit(2); + } + + await sodium.ready; + + const sql = postgres(url, { max: 1, onnotice: () => {} }); + try { + const orphans = await sql` + SELECT m.id AS "meshId", m.slug, m.owner_user_id AS "ownerUserId", m.name AS "meshName" + FROM mesh.mesh m + WHERE m.archived_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM mesh.member mm + WHERE mm.mesh_id = m.id + AND mm.revoked_at IS NULL + AND mm.user_id = m.owner_user_id + ) + ORDER BY m.created_at + `; + console.log(`backfill · ${orphans.length} meshes need an owner member row`); + + let inserted = 0; + for (const o of orphans) { + const kp = sodium.crypto_sign_keypair(); + const peerPubkey = sodium.to_hex(kp.publicKey); + const id = sodium.to_hex(sodium.randombytes_buf(16)); + try { + await sql.begin(async (tx) => { + await tx` + INSERT INTO mesh.member ( + id, mesh_id, peer_pubkey, display_name, role, + user_id, dashboard_user_id + ) + VALUES ( + ${id}, ${o.meshId}, ${peerPubkey}, + ${o.meshName + "-owner"}, ${"admin"}::mesh.role, + ${o.ownerUserId}, ${o.ownerUserId} + ) + `; + // Subscribe to #general as 'lead' if the topic exists. + await tx` + INSERT INTO mesh.topic_member (topic_id, member_id, role) + SELECT t.id, ${id}, ${"lead"}::mesh.topic_member_role + FROM mesh.topic t + WHERE t.mesh_id = ${o.meshId} AND t.name = 'general' + ON CONFLICT (topic_id, member_id) DO NOTHING + `; + }); + inserted += 1; + console.log(` + ${o.slug.padEnd(20)} owner=${o.ownerUserId.slice(0, 8)}… member=${id.slice(0, 8)}… pk=${peerPubkey.slice(0, 12)}…`); + } catch (e) { + console.error(` ✗ ${o.slug}: ${(e as Error).message}`); + throw e; + } + } + console.log(`backfill done · ${inserted} owner member rows inserted`); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((e) => { + console.error("backfill failed:", e); + process.exit(1); +}); diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts index 58d78c9..7d9f566 100644 --- a/packages/api/src/modules/mesh/mutations.ts +++ b/packages/api/src/modules/mesh/mutations.ts @@ -148,12 +148,46 @@ export const createMyMesh = async ({ rootKey, }) .returning({ id: mesh.id, slug: mesh.slug }); + if (!created) throw new Error("mesh insert returned no row"); - if (created) { - await ensureGeneralTopic(created.id); + // Create the owner's peer-identity member row. Mirrors what the broker + // does on first WS hello so a web-only user has a valid identity from + // t=0 — without this, the topic chat can't issue a dashboard apikey + // (issuedByMemberId is a FK), and the owner's "oldest member row in + // the mesh" lookup returns null. Fresh ed25519 keypair; secret key is + // discarded because web users don't sign anything in v0.2.0 (no DMs, + // base64 plaintext on topics). If they later install the CLI, the + // broker will mint a separate member row with a CLI-side keypair — + // both work for their respective surfaces. + const peerKp = s.crypto_sign_keypair(); + const peerPubkey = s.to_hex(peerKp.publicKey); + const [ownerMember] = await db + .insert(meshMember) + .values({ + meshId: created.id, + peerPubkey, + displayName: `${input.name}-owner`, + role: "admin", + userId, + dashboardUserId: userId, + }) + .returning({ id: meshMember.id }); + if (!ownerMember) throw new Error("owner member insert returned no row"); + + // Auto-create #general and subscribe the owner as 'lead'. + const generalTopic = await ensureGeneralTopic(created.id); + if (generalTopic) { + await db + .insert(meshTopicMember) + .values({ + topicId: generalTopic.id, + memberId: ownerMember.id, + role: "lead", + }) + .onConflictDoNothing(); } - return created!; + return created; }; /**