From 2aa21fe07c8cfec3c34d46a1cd5fea623da69e95 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 17:02:40 +0100 Subject: [PATCH] fix(api): mint owner peer-identity row at mesh creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web-first owners had no mesh.member row because the broker only ever created one on first WS hello (CLI flow). The topic chat page server component requires that row to issue a dashboard apikey (issuedByMemberId is a FK to mesh.member), so visiting the chat for a web-only mesh hit notFound() on the owner's own room. Forward fix: createMyMesh now generates a fresh ed25519 peer keypair, inserts a mesh.member row with role=admin and dashboardUserId=userId, and subscribes the owner to the auto-created #general topic as 'lead'. The peer secret key is intentionally discarded — web users don't sign anything in v0.2.0 (no DMs, base64 plaintext on topics). If the same user later runs the CLI, the broker mints a separate member row from its own keypair; both work for their respective surfaces. Backfill: apps/broker/scripts/backfill-owner-members.ts walks every non-archived mesh whose owner has no member row, generates real ed25519 keypairs via libsodium, inserts the rows in a transaction, and subscribes each as 'lead' on #general. Already run against prod — 13 owner rows minted, ddtest verified end-to-end via playwriter (send → poll → render round-trip ok). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/broker/scripts/backfill-owner-members.ts | 101 ++++++++++++++++++ packages/api/src/modules/mesh/mutations.ts | 40 ++++++- 2 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 apps/broker/scripts/backfill-owner-members.ts 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; }; /**