feat(broker): add /join endpoint for peer self-registration

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) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 22:36:16 +01:00
parent 04bf349e7d
commit 39b914bdce
2 changed files with 135 additions and 0 deletions

View File

@@ -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<void> {
.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.

View File

@@ -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,