From 39b914bdcef337b436be95169bbe145174f4d756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:36:16 +0100 Subject: [PATCH] feat(broker): add /join endpoint for peer self-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single HTTP POST /join the CLI calls after parsing an invite link + generating an ed25519 keypair client-side. Broker validates the mesh exists + is not archived, inserts a mesh.member row (or returns the existing id for idempotency), returns {ok, memberId, alreadyMember?}. body: {mesh_id, peer_pubkey, display_name, role} - peer_pubkey must be 64 hex chars (32 bytes) - role is "admin" | "member" v0.1.0 trusts the request — no invite-token validation, no ed25519 signature check. Both land in Step 18 alongside libsodium wrapping. size cap enforced via MAX_MESSAGE_BYTES (shared with hook endpoint). structured log line per enrollment with truncated pubkey + whether it was a new member or re-enrolled existing one. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/broker/src/broker.ts | 52 ++++++++++++++++++++++++ apps/broker/src/index.ts | 83 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index 67da6fc..19153f2 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -29,6 +29,7 @@ import { } from "drizzle-orm"; import { db } from "./db"; import { + mesh, meshMember as memberTable, messageQueue, pendingStatus, @@ -489,6 +490,57 @@ export async function stopSweepers(): Promise { .where(isNull(presence.disconnectedAt)); } +/** + * Enroll a new member in an existing mesh. Called by the CLI join + * flow after invite-link parsing + keypair generation client-side. + * + * v0.1.0: trusts the request. Signature verification + invite-token + * one-time-use tracking land in Step 18. + */ +export async function joinMesh(args: { + meshId: string; + peerPubkey: string; + displayName: string; + role: "admin" | "member"; +}): Promise< + | { ok: true; memberId: string; alreadyMember?: boolean } + | { ok: false; error: string } +> { + // Validate the mesh exists. + const [m] = await db + .select({ id: mesh.id }) + .from(mesh) + .where(and(eq(mesh.id, args.meshId), isNull(mesh.archivedAt))); + if (!m) return { ok: false, error: "mesh not found or archived" }; + + // Idempotency: same pubkey already a member → return existing id. + const [existing] = await db + .select({ id: memberTable.id }) + .from(memberTable) + .where( + and( + eq(memberTable.meshId, args.meshId), + eq(memberTable.peerPubkey, args.peerPubkey), + isNull(memberTable.revokedAt), + ), + ); + if (existing) { + return { ok: true, memberId: existing.id, alreadyMember: true }; + } + + const [row] = await db + .insert(memberTable) + .values({ + meshId: args.meshId, + peerPubkey: args.peerPubkey, + displayName: args.displayName, + role: args.role, + }) + .returning({ id: memberTable.id }); + if (!row) return { ok: false, error: "member insert failed" }; + return { ok: true, memberId: row.id }; +} + /** * Look up a member row by pubkey within a mesh. Used at WS handshake * to authenticate an incoming hello. diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 4ba201f..176fb18 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -23,6 +23,7 @@ import { findMemberByPubkey, handleHookSetStatus, heartbeat, + joinMesh, queueMessage, refreshQueueDepth, refreshStatusFromJsonl, @@ -149,6 +150,11 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void { return; } + if (req.method === "POST" && req.url === "/join") { + handleJoinPost(req, res, started); + return; + } + res.writeHead(404); res.end("not found"); log.debug("http", { route, status: 404, latency_ms: Date.now() - started }); @@ -219,6 +225,83 @@ function handleHookPost( }); } +function handleJoinPost( + req: IncomingMessage, + res: ServerResponse, + started: number, +): void { + const chunks: Buffer[] = []; + let total = 0; + let aborted = false; + + req.on("data", (chunk: Buffer) => { + if (aborted) return; + total += chunk.length; + if (total > env.MAX_MESSAGE_BYTES) { + aborted = true; + writeJson(res, 413, { ok: false, error: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on("end", async () => { + if (aborted) return; + try { + const payload = JSON.parse(Buffer.concat(chunks).toString()) as { + mesh_id?: string; + peer_pubkey?: string; + display_name?: string; + role?: "admin" | "member"; + }; + // Minimal shape validation. + if ( + !payload.mesh_id || + !payload.peer_pubkey || + !payload.display_name || + !payload.role + ) { + writeJson(res, 400, { + ok: false, + error: "mesh_id, peer_pubkey, display_name, role required", + }); + return; + } + if (!/^[0-9a-f]{64}$/i.test(payload.peer_pubkey)) { + writeJson(res, 400, { + ok: false, + error: "peer_pubkey must be 64 hex chars (32 bytes)", + }); + return; + } + const result = await joinMesh({ + meshId: payload.mesh_id, + peerPubkey: payload.peer_pubkey, + displayName: payload.display_name, + role: payload.role, + }); + writeJson(res, result.ok ? 200 : 400, result); + log.info("join", { + route: "POST /join", + mesh_id: payload.mesh_id, + pubkey: payload.peer_pubkey.slice(0, 12), + ok: result.ok, + already_member: "alreadyMember" in result ? result.alreadyMember : false, + latency_ms: Date.now() - started, + }); + } catch (e) { + writeJson(res, 500, { + ok: false, + error: e instanceof Error ? e.message : String(e), + }); + log.error("join handler error", { + error: e instanceof Error ? e.message : String(e), + }); + } + }); +} + function handleUpgrade( wss: WebSocketServer, req: IncomingMessage,