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.