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:
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import {
|
import {
|
||||||
|
mesh,
|
||||||
meshMember as memberTable,
|
meshMember as memberTable,
|
||||||
messageQueue,
|
messageQueue,
|
||||||
pendingStatus,
|
pendingStatus,
|
||||||
@@ -489,6 +490,57 @@ export async function stopSweepers(): Promise<void> {
|
|||||||
.where(isNull(presence.disconnectedAt));
|
.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
|
* Look up a member row by pubkey within a mesh. Used at WS handshake
|
||||||
* to authenticate an incoming hello.
|
* to authenticate an incoming hello.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
findMemberByPubkey,
|
findMemberByPubkey,
|
||||||
handleHookSetStatus,
|
handleHookSetStatus,
|
||||||
heartbeat,
|
heartbeat,
|
||||||
|
joinMesh,
|
||||||
queueMessage,
|
queueMessage,
|
||||||
refreshQueueDepth,
|
refreshQueueDepth,
|
||||||
refreshStatusFromJsonl,
|
refreshStatusFromJsonl,
|
||||||
@@ -149,6 +150,11 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && req.url === "/join") {
|
||||||
|
handleJoinPost(req, res, started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end("not found");
|
res.end("not found");
|
||||||
log.debug("http", { route, status: 404, latency_ms: Date.now() - started });
|
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(
|
function handleUpgrade(
|
||||||
wss: WebSocketServer,
|
wss: WebSocketServer,
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
|
|||||||
Reference in New Issue
Block a user