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";
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user