fix(broker): generate owner keys on CLI mesh create + proper invite signing
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

handleCliMeshCreate now generates ownerPubkey/ownerSecretKey/rootKey so
CLI-created meshes can issue invites. handleCliMeshInvite builds the
full signed v1 payload + v2 capability (matching createMyInvite in
packages/api) and self-heals meshes created by older broker versions
that are missing keys.

Fixes 500 on claudemesh share after CLI mesh create.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 00:37:16 +01:00
parent e2b6e53cc1
commit 32851419e6

View File

@@ -103,7 +103,7 @@ import { metrics, metricsToText } from "./metrics";
import { TokenBucket } from "./rate-limit";
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
import { buildInfo } from "./build-info";
import { canonicalInviteV2, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2 } from "./crypto";
import { canonicalInvite, canonicalInviteV2, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2 } from "./crypto";
import { handleWebhook } from "./webhooks";
import { audit, loadLastHashes, ensureAuditLogTable, verifyChain, queryAuditLog } from "./audit";
@@ -5056,18 +5056,78 @@ async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: Serv
try {
const [m] = await db.select().from(mesh).where(eq(mesh.slug, slug)).limit(1);
if (!m) { writeJson(res, 404, { error: "Mesh not found" }); return; }
if (m.ownerUserId !== body.user_id) {
writeJson(res, 403, { error: "Only the owner can invite (for now)" });
return;
}
// Generate invite code
const code = generateShortCode(8);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const sodiumMod = await import("libsodium-wrappers");
const s = sodiumMod.default;
await s.ready;
await db.insert(inviteTable).values({
meshId: m.id,
code,
createdBy: body.user_id,
expiresAt,
role: (body.role as "admin" | "member") ?? "member",
});
// Self-heal: CLI-created meshes before this fix lack owner keys. Generate + persist.
let ownerPubkey = m.ownerPubkey;
let ownerSecretKey = m.ownerSecretKey;
let rootKey = m.rootKey;
if (!ownerPubkey || !ownerSecretKey || !rootKey) {
const kp = s.crypto_sign_keypair();
ownerPubkey = s.to_hex(kp.publicKey);
ownerSecretKey = s.to_hex(kp.privateKey);
rootKey = s.to_base64(s.randombytes_buf(32), s.base64_variants.URLSAFE_NO_PADDING);
await db.execute(sql`UPDATE mesh.mesh SET owner_pubkey = ${ownerPubkey}, owner_secret_key = ${ownerSecretKey}, root_key = ${rootKey} WHERE id = ${m.id}`);
}
const role = (body.role === "admin" ? "admin" : "member") as "admin" | "member";
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const expiresAtSec = Math.floor(expiresAt.getTime() / 1000);
const brokerUrl = process.env.BROKER_URL || "wss://ic.claudemesh.com/ws";
const payloadCore = {
v: 1 as const,
mesh_id: m.id,
mesh_slug: m.slug,
broker_url: brokerUrl,
expires_at: expiresAtSec,
mesh_root_key: rootKey,
role,
owner_pubkey: ownerPubkey,
};
const canonical = canonicalInvite(payloadCore);
const signature = s.to_hex(s.crypto_sign_detached(s.from_string(canonical), s.from_hex(ownerSecretKey)));
const token = Buffer.from(JSON.stringify({ ...payloadCore, signature }), "utf-8").toString("base64url");
// Short code with collision retry
let code = generateShortCode(8);
let inviteId = "";
for (let i = 0; i < 3; i++) {
try {
const rows = await db.insert(inviteTable).values({
meshId: m.id,
token,
tokenBytes: canonical,
code,
maxUses: 1,
role,
expiresAt,
createdBy: body.user_id,
version: 2,
}).returning({ id: inviteTable.id });
inviteId = rows[0]!.id;
break;
} catch (e) {
if (e instanceof Error && e.message.includes("invite_code_unique_idx")) {
code = generateShortCode(8);
continue;
}
throw e;
}
}
if (!inviteId) throw new Error("Could not allocate unique invite code");
// v2 capability backfill
const canonicalV2 = canonicalInviteV2({ mesh_id: m.id, invite_id: inviteId, expires_at: expiresAtSec, role, owner_pubkey: ownerPubkey });
const signatureV2 = s.to_hex(s.crypto_sign_detached(s.from_string(canonicalV2), s.from_hex(ownerSecretKey)));
await db.update(inviteTable).set({ capabilityV2: JSON.stringify({ canonical: canonicalV2, signature: signatureV2 }) }).where(eq(inviteTable.id, inviteId));
const baseUrl = process.env.APP_URL || "https://claudemesh.com";
const url = `${baseUrl}/i/${code}`;
@@ -5075,7 +5135,7 @@ async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: Serv
writeJson(res, 200, { url, code, expires_at: expiresAt.toISOString() });
log.info("mesh-invite", { route: "POST /cli/mesh/:slug/invite", slug, code, email: body.email, latency_ms: Date.now() - started });
} catch (e) {
log.error("mesh-invite", { error: e instanceof Error ? e.message : String(e) });
log.error("mesh-invite", { error: e instanceof Error ? e.message : String(e), stack: e instanceof Error ? e.stack : undefined });
writeJson(res, 500, { error: "Failed to create invite" });
}
}
@@ -5115,10 +5175,19 @@ async function handleCliMeshCreate(req: IncomingMessage, res: ServerResponse, st
const meshId = generateId();
// Generate owner signing keypair + root key so invites can be issued later.
const sodiumMod = await import("libsodium-wrappers");
const s = sodiumMod.default;
await s.ready;
const kp = s.crypto_sign_keypair();
const ownerPubkey = s.to_hex(kp.publicKey);
const ownerSecretKey = s.to_hex(kp.privateKey);
const rootKey = s.to_base64(s.randombytes_buf(32), s.base64_variants.URLSAFE_NO_PADDING);
// Create mesh — use raw SQL to avoid Drizzle default-column issues
await db.execute(sql`
INSERT INTO mesh.mesh (id, name, slug, owner_user_id)
VALUES (${meshId}, ${body.name}, ${slug}, ${body.user_id})
INSERT INTO mesh.mesh (id, name, slug, owner_user_id, owner_pubkey, owner_secret_key, root_key)
VALUES (${meshId}, ${body.name}, ${slug}, ${body.user_id}, ${ownerPubkey}, ${ownerSecretKey}, ${rootKey})
`);
// Create owner member