fix(api): mint owner peer-identity row at mesh creation
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) <noreply@anthropic.com>
This commit is contained in:
101
apps/broker/scripts/backfill-owner-members.ts
Normal file
101
apps/broker/scripts/backfill-owner-members.ts
Normal file
@@ -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<Orphan[]>`
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -148,12 +148,46 @@ export const createMyMesh = async ({
|
|||||||
rootKey,
|
rootKey,
|
||||||
})
|
})
|
||||||
.returning({ id: mesh.id, slug: mesh.slug });
|
.returning({ id: mesh.id, slug: mesh.slug });
|
||||||
|
if (!created) throw new Error("mesh insert returned no row");
|
||||||
|
|
||||||
if (created) {
|
// Create the owner's peer-identity member row. Mirrors what the broker
|
||||||
await ensureGeneralTopic(created.id);
|
// 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user