Files
claudemesh/apps/broker/scripts/backfill-owner-members.ts
Alejandro Gutiérrez 2aa21fe07c
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
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>
2026-05-02 17:02:40 +01:00

102 lines
3.4 KiB
TypeScript

/**
* 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);
});