feat: anthropic-style mesh + invite redesign (wave 1 checkpoint)

Ships the user-visible friction fixes and the foundation for the v2
invite protocol. API wiring + CLI client + email UI ship in wave 2.

Meshes — shipped
- Drop global UNIQUE on mesh.slug; mesh.id is canonical everywhere
- Server derives slug from name; create form has no slug field
- Two users can freely name their mesh "platform"; no collision errors
- Migration 0017

Invites v1 — shipped (URL shortener, backward compatible)
- New invite.code column (base62, 8 chars, nullable unique index)
- createMyInvite mints both token + short code; returns shortUrl
- GET /api/public/invite-code/:code resolves short code to token
- New route /i/[code] server-redirects to /join/[token]
- Invite generator UI shows short URL; QR encodes short URL
- Advanced fields (role/maxUses/expiresInDays) collapsed under disclosure
- Migration 0018

Invites v2 — foundation (broker + DB only; API+CLI+Web wiring in wave 2)
- Broker: canonicalInviteV2, verifyInviteV2, sealRootKeyToRecipient
- Broker: POST /invites/:code/claim endpoint (atomic single-use accounting)
- Broker tests: invite-v2.test.ts (signature, expiry, revocation, exhaustion)
- DB: mesh.invite gains version/capabilityV2/claimedByPubkey columns
- DB: new mesh.pending_invite table for email invites
- Migration 0019
- Contract locked in docs/protocol.md §v2 + SPEC.md §14b

Consent landing — shipped
- /join/[token] redesigned: explicit role, inviter, mesh stats, consent
- New server components: invite-card, role-badge, inviter-line, consent-summary
- "Join [mesh] as [Role]" primary action (not just "Join")

Error surfacing — shipped
- handle() now parses {error} responses from hono route catch blocks
- onError fallback includes timestamp so handle() can match apiErrorSchema
- Real error messages reach the UI instead of "Something went wrong"

Docs
- SPEC.md §14b: v2 invite protocol
- docs/protocol.md: v2 claim wire format
- docs/roadmap.md: status
- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md

Deferred to wave 2/3
- API claim route wiring (packages/api)
- createMyInvite v2 capability generation
- Email invite mutation + Postmark delivery
- CLI v2 join flow (x25519 keypair + unseal)
- Web invite-generator email field + v2 display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-10 13:41:11 +01:00
parent dbea96960f
commit c1fa3bcb5c
24 changed files with 1932 additions and 196 deletions

View File

@@ -7,7 +7,10 @@
* current member of the claimed mesh.
*/
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import sodium from "libsodium-wrappers";
import { db } from "./db";
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
let ready = false;
async function ensureSodium(): Promise<typeof sodium> {
@@ -69,6 +72,70 @@ export async function verifyEd25519(
}
}
/**
* Canonical v2 invite bytes — signed by the mesh owner's ed25519 secret key.
* NOTE: deliberately does NOT include the root_key or broker_url; the v2
* protocol moves the root_key out of the URL entirely. Format is locked:
* `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` (no trailing newline).
*/
export function canonicalInviteV2(p: {
mesh_id: string;
invite_id: string;
expires_at: number; // unix seconds
role: "admin" | "member";
owner_pubkey: string; // hex
}): string {
return `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;
}
/**
* Verify an ed25519 signature over the v2 canonical invite bytes against
* the mesh owner's public key. Returns true on valid signature.
*/
export async function verifyInviteV2(params: {
canonical: string;
signatureHex: string;
ownerPubkeyHex: string;
}): Promise<boolean> {
return verifyEd25519(
params.canonical,
params.signatureHex,
params.ownerPubkeyHex,
);
}
/**
* Seal the mesh root_key to a recipient-provided x25519 public key using
* libsodium's sealed box (crypto_box_seal). Only the holder of the matching
* x25519 secret key can unseal.
*
* rootKeyBase64url is the mesh.root_key column value (base64url of 32 bytes).
* recipientX25519PubkeyBase64url is the 32-byte x25519 pubkey the recipient
* provided in its claim request. We do NOT convert an ed25519 pubkey here —
* the recipient generates a dedicated x25519 keypair and sends us the pubkey.
*
* Returns base64url of the sealed ciphertext.
*/
export async function sealRootKeyToRecipient(params: {
rootKeyBase64url: string;
recipientX25519PubkeyBase64url: string;
}): Promise<string> {
const s = await ensureSodium();
const rootKeyBytes = s.from_base64(
params.rootKeyBase64url,
s.base64_variants.URLSAFE_NO_PADDING,
);
const recipientPk = s.from_base64(
params.recipientX25519PubkeyBase64url,
s.base64_variants.URLSAFE_NO_PADDING,
);
if (recipientPk.length !== 32) {
throw new Error("recipient_x25519_pubkey must decode to 32 bytes");
}
const sealed = s.crypto_box_seal(rootKeyBytes, recipientPk);
return s.to_base64(sealed, s.base64_variants.URLSAFE_NO_PADDING);
}
export const HELLO_SKEW_MS = 60_000;
/**
@@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: {
return { ok: false, reason: "malformed" };
}
}
// ----------------------------------------------------------------------------
// v2 invite claim core — exported for the HTTP handler in index.ts AND for
// tests that need to exercise the logic without spinning up the broker server.
// ----------------------------------------------------------------------------
//
// capabilityV2 column is stored as JSON:
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
// "signature": "<hex ed25519 detached signature>" }
// The broker recomputes the canonical bytes from the invite row and verifies
// the signature against mesh.ownerPubkey. v1 rows (version === 1 OR
// capabilityV2 === null) skip verification — the legacy path still works
// during the deprecation window.
export type InviteClaimV2Result =
| {
ok: true;
status: 200;
body: {
sealed_root_key: string;
mesh_id: string;
member_id: string;
owner_pubkey: string;
canonical_v2: string;
};
}
| { ok: false; status: 400 | 404 | 410; body: { error: string } };
export async function claimInviteV2Core(params: {
code: string;
recipientX25519PubkeyBase64url: string;
displayName?: string;
now?: number;
}): Promise<InviteClaimV2Result> {
const now = params.now ?? Date.now();
const recipientPk = params.recipientX25519PubkeyBase64url;
if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 1. Look up the invite by opaque code.
const [inv] = await db
.select()
.from(inviteTable)
.where(eq(inviteTable.code, params.code))
.limit(1);
if (!inv) return { ok: false, status: 404, body: { error: "not_found" } };
// 2. Lifecycle checks: revoked → expired → exhausted.
if (inv.revokedAt) {
return { ok: false, status: 410, body: { error: "revoked" } };
}
if (inv.expiresAt.getTime() < now) {
return { ok: false, status: 410, body: { error: "expired" } };
}
if (inv.usedCount >= inv.maxUses) {
return { ok: false, status: 410, body: { error: "exhausted" } };
}
// 3. Load the mesh for owner_pubkey + root_key.
const [m] = await db
.select({
id: mesh.id,
ownerPubkey: mesh.ownerPubkey,
rootKey: mesh.rootKey,
})
.from(mesh)
.where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt)))
.limit(1);
if (!m) return { ok: false, status: 404, body: { error: "not_found" } };
if (!m.ownerPubkey || !m.rootKey) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 4. Compute canonical_v2 from the row (used in the response either way).
const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000);
const canonical = canonicalInviteV2({
mesh_id: inv.meshId,
invite_id: inv.id,
expires_at: expiresAtUnix,
role: inv.role as "admin" | "member",
owner_pubkey: m.ownerPubkey,
});
if (inv.version === 2 && inv.capabilityV2) {
let storedCanonical: string | undefined;
let signatureHex: string | undefined;
try {
const parsed = JSON.parse(inv.capabilityV2) as {
canonical?: string;
signature?: string;
};
storedCanonical = parsed.canonical;
signatureHex = parsed.signature;
} catch {
return { ok: false, status: 400, body: { error: "malformed" } };
}
if (!storedCanonical || !signatureHex) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// Broker-recomputed canonical must match the signed bytes exactly.
if (storedCanonical !== canonical) {
return { ok: false, status: 400, body: { error: "bad_signature" } };
}
const sigOk = await verifyInviteV2({
canonical: storedCanonical,
signatureHex,
ownerPubkeyHex: m.ownerPubkey,
});
if (!sigOk) {
return { ok: false, status: 400, body: { error: "bad_signature" } };
}
}
// v1 rows: skip signature verification (legacy path during migration).
// 5. Atomic consume: increment used_count iff still under max_uses.
const [claimed] = await db
.update(inviteTable)
.set({
usedCount: sql`${inviteTable.usedCount} + 1`,
claimedByPubkey: recipientPk,
})
.where(
and(
eq(inviteTable.id, inv.id),
lt(inviteTable.usedCount, inv.maxUses),
),
)
.returning({ id: inviteTable.id });
if (!claimed) {
return { ok: false, status: 410, body: { error: "exhausted" } };
}
// 6. Create a member row for the claimant.
const preset = (inv.preset as {
displayName?: string;
roleTag?: string;
groups?: Array<{ name: string; role?: string }>;
messageMode?: string;
} | null) ?? {};
const displayName =
preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`;
const [row] = await db
.insert(meshMember)
.values({
meshId: inv.meshId,
peerPubkey: recipientPk,
displayName,
role: inv.role,
roleTag: preset.roleTag ?? null,
defaultGroups: preset.groups ?? [],
messageMode: preset.messageMode ?? "push",
})
.returning({ id: meshMember.id });
if (!row) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
let sealed: string;
try {
sealed = await sealRootKeyToRecipient({
rootKeyBase64url: m.rootKey,
recipientX25519PubkeyBase64url: recipientPk,
});
} catch {
return { ok: false, status: 400, body: { error: "malformed" } };
}
return {
ok: true,
status: 200,
body: {
sealed_root_key: sealed,
mesh_id: inv.meshId,
member_id: row.id,
owner_pubkey: m.ownerPubkey,
canonical_v2: canonical,
},
};
}

View File

@@ -15,10 +15,10 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import { WebSocketServer, type WebSocket } from "ws";
import { and, eq, isNull, sql } from "drizzle-orm";
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import { env } from "./env";
import { db } from "./db";
import { mesh, meshMember, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
import { invite as inviteTable, mesh, meshMember, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
import { user } from "@turbostarter/db/schema/auth";
import { handleCliSync, type CliSyncRequest } from "./cli-sync";
import { updateMemberProfile, listMeshMembers, updateMeshSettings } from "./member-api";
@@ -102,7 +102,7 @@ import { metrics, metricsToText } from "./metrics";
import { TokenBucket } from "./rate-limit";
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
import { buildInfo } from "./build-info";
import { verifyHelloSignature } from "./crypto";
import { canonicalInviteV2, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2 } from "./crypto";
import { handleWebhook } from "./webhooks";
import { audit, loadLastHashes, ensureAuditLogTable, verifyChain, queryAuditLog } from "./audit";
@@ -590,6 +590,16 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
return;
}
// v2 invite claim: POST /invites/:code/claim
// Body: { recipient_x25519_pubkey: "<base64url, 32 bytes>" }
// On success, returns a sealed copy of the mesh root_key the recipient
// alone can unseal. See .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
const claimMatch = req.method === "POST" && req.url?.match(/^\/invites\/([^/]+)\/claim$/);
if (claimMatch) {
handleInviteClaimV2Post(req, res, claimMatch[1]!, started);
return;
}
if (req.method === "POST" && req.url === "/upload") {
handleUploadPost(req, res, started);
return;
@@ -864,6 +874,270 @@ function handleJoinPost(
});
}
// ----------------------------------------------------------------------------
// v2 invite claim — POST /invites/:code/claim
// ----------------------------------------------------------------------------
// The v2 protocol moves the mesh root_key out of the invite URL. Invite
// URLs are short opaque codes; on claim the broker verifies the signed
// capability (stored server-side) and seals the root_key to a recipient-
// provided x25519 pubkey so only that recipient can unseal it.
//
// capabilityV2 is stored as JSON on the invite row:
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
// "signature": "<hex ed25519 detached signature>" }
// The broker recomputes the canonical bytes from the invite row and
// verifies the signature against mesh.ownerPubkey.
//
// v1 rows (version === 1 OR capabilityV2 === null) are still accepted:
// the broker computes the v2 canonical on the fly from the row, but
// skips signature verification since there is no v2 signature on file.
// This lets v2 clients claim legacy invites during the deprecation window.
export type InviteClaimV2Result =
| {
ok: true;
status: 200;
body: {
sealed_root_key: string;
mesh_id: string;
member_id: string;
owner_pubkey: string;
canonical_v2: string;
};
}
| { ok: false; status: 400 | 404 | 410; body: { error: string } };
/**
* Core claim logic, extracted from the HTTP handler so tests can call it
* directly without spinning up the full broker server.
*/
export async function claimInviteV2Core(params: {
code: string;
recipientX25519PubkeyBase64url: string;
displayName?: string;
now?: number;
}): Promise<InviteClaimV2Result> {
const now = params.now ?? Date.now();
const recipientPk = params.recipientX25519PubkeyBase64url;
// Cheap shape check on the recipient pubkey — full length check happens
// inside sealRootKeyToRecipient, but reject obvious garbage early so
// we return 400 malformed before touching the DB.
if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 1. Look up the invite by opaque code.
const [inv] = await db
.select()
.from(inviteTable)
.where(eq(inviteTable.code, params.code))
.limit(1);
if (!inv) return { ok: false, status: 404, body: { error: "not_found" } };
// 2. Lifecycle checks: revoked → expired → exhausted.
if (inv.revokedAt) {
return { ok: false, status: 410, body: { error: "revoked" } };
}
if (inv.expiresAt.getTime() < now) {
return { ok: false, status: 410, body: { error: "expired" } };
}
if (inv.usedCount >= inv.maxUses) {
return { ok: false, status: 410, body: { error: "exhausted" } };
}
// 3. Load the mesh for owner_pubkey + root_key.
const [m] = await db
.select({
id: mesh.id,
ownerPubkey: mesh.ownerPubkey,
rootKey: mesh.rootKey,
})
.from(mesh)
.where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt)))
.limit(1);
if (!m) return { ok: false, status: 404, body: { error: "not_found" } };
if (!m.ownerPubkey || !m.rootKey) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 4. v2 signature verification when applicable.
// Always compute the canonical on the fly so the response can echo it.
const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000);
const canonical = canonicalInviteV2({
mesh_id: inv.meshId,
invite_id: inv.id,
expires_at: expiresAtUnix,
role: inv.role as "admin" | "member",
owner_pubkey: m.ownerPubkey,
});
if (inv.version === 2 && inv.capabilityV2) {
// Parse capability + verify.
let storedCanonical: string | undefined;
let signatureHex: string | undefined;
try {
const parsed = JSON.parse(inv.capabilityV2) as {
canonical?: string;
signature?: string;
};
storedCanonical = parsed.canonical;
signatureHex = parsed.signature;
} catch {
return { ok: false, status: 400, body: { error: "malformed" } };
}
if (!storedCanonical || !signatureHex) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// Broker-recomputed canonical must match the signed bytes exactly.
if (storedCanonical !== canonical) {
return { ok: false, status: 400, body: { error: "bad_signature" } };
}
const sigOk = await verifyInviteV2({
canonical: storedCanonical,
signatureHex,
ownerPubkeyHex: m.ownerPubkey,
});
if (!sigOk) {
return { ok: false, status: 400, body: { error: "bad_signature" } };
}
}
// v1 rows: skip signature verification (legacy path during migration).
// 5. Atomic consume: increment used_count iff still under max_uses.
// Mirrors the invariant enforced for v1 joins in broker.joinMesh().
const [claimed] = await db
.update(inviteTable)
.set({
usedCount: sql`${inviteTable.usedCount} + 1`,
claimedByPubkey: recipientPk,
})
.where(
and(
eq(inviteTable.id, inv.id),
lt(inviteTable.usedCount, inv.maxUses),
),
)
.returning({ id: inviteTable.id });
if (!claimed) {
return { ok: false, status: 410, body: { error: "exhausted" } };
}
// 6. Create a member row for the claimant. The peerPubkey column holds
// the claimant's signing identity; for v2 the recipient hasn't
// necessarily connected over WS yet, so we use the x25519 pubkey as
// a placeholder for the pre-claim phase. This matches the spec's
// "one recipient = one root-key-delivery capability" invariant.
const preset = (inv.preset as {
displayName?: string;
roleTag?: string;
groups?: Array<{ name: string; role?: string }>;
messageMode?: string;
} | null) ?? {};
const displayName =
preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`;
const [row] = await db
.insert(meshMember)
.values({
meshId: inv.meshId,
peerPubkey: recipientPk,
displayName,
role: inv.role,
roleTag: preset.roleTag ?? null,
defaultGroups: preset.groups ?? [],
messageMode: preset.messageMode ?? "push",
})
.returning({ id: meshMember.id });
if (!row) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
let sealed: string;
try {
sealed = await sealRootKeyToRecipient({
rootKeyBase64url: m.rootKey,
recipientX25519PubkeyBase64url: recipientPk,
});
} catch {
return { ok: false, status: 400, body: { error: "malformed" } };
}
return {
ok: true,
status: 200,
body: {
sealed_root_key: sealed,
mesh_id: inv.meshId,
member_id: row.id,
owner_pubkey: m.ownerPubkey,
canonical_v2: canonical,
},
};
}
function handleInviteClaimV2Post(
req: IncomingMessage,
res: ServerResponse,
code: string,
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, { error: "payload too large" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", async () => {
if (aborted) return;
try {
const raw = Buffer.concat(chunks).toString();
let payload: { recipient_x25519_pubkey?: string; display_name?: string };
try {
payload = JSON.parse(raw);
} catch {
writeJson(res, 400, { error: "malformed" });
return;
}
if (
!payload.recipient_x25519_pubkey ||
typeof payload.recipient_x25519_pubkey !== "string"
) {
writeJson(res, 400, { error: "malformed" });
return;
}
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: payload.recipient_x25519_pubkey,
displayName: payload.display_name,
});
writeJson(res, result.status, result.body);
log.info("invite claim v2", {
route: "POST /invites/:code/claim",
code,
status: result.status,
ok: result.ok,
latency_ms: Date.now() - started,
});
} catch (e) {
writeJson(res, 500, {
error: e instanceof Error ? e.message : String(e),
});
log.error("invite claim v2 handler error", {
error: e instanceof Error ? e.message : String(e),
});
}
});
}
function handleUploadPost(
req: IncomingMessage,
res: ServerResponse,
@@ -4282,4 +4556,8 @@ function main(): void {
});
}
main();
// Skip starting the HTTP/WS server when running under vitest — tests import
// claimInviteV2Core() directly and must not bind ports on module load.
if (!process.env.VITEST) {
main();
}

View File

@@ -0,0 +1,268 @@
/**
* v2 invite protocol — broker claim endpoint.
*
* Covers the sealed-root-key delivery flow added in
* .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md :
*
* - happy path: signed v2 invite claim returns a sealed root_key the
* recipient can unseal back to the mesh.rootKey column value
* - tampered signature → 400 bad_signature
* - expired invite → 410 expired
* - revoked invite → 410 revoked
* - exhausted invite (usedCount === maxUses) → 410 exhausted
* - round-trip: recipient-side crypto_box_seal_open recovers the real key
*
* Tests talk directly to claimInviteV2Core() to avoid spinning up the
* full broker HTTP server. The handler delegates to this function with
* zero extra logic, so coverage is equivalent.
*/
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
import { eq } from "drizzle-orm";
import sodium from "libsodium-wrappers";
import { db } from "../src/db";
import { invite, mesh } from "@turbostarter/db/schema/mesh";
import { canonicalInviteV2 } from "../src/crypto";
import { claimInviteV2Core } from "../src/index";
import {
cleanupAllTestMeshes,
setupTestMesh,
type TestMesh,
} from "./helpers";
afterAll(async () => {
await cleanupAllTestMeshes();
});
beforeAll(async () => {
await sodium.ready;
});
/**
* Set a random base64url root_key on an existing test mesh. The helpers
* don't set one by default, so v2 tests prime it per-mesh here.
*/
async function primeRootKey(meshId: string): Promise<Uint8Array> {
const key = sodium.randombytes_buf(32);
const b64 = sodium.to_base64(key, sodium.base64_variants.URLSAFE_NO_PADDING);
await db.update(mesh).set({ rootKey: b64 }).where(eq(mesh.id, meshId));
return key;
}
/**
* Insert a signed v2 invite row. Returns the opaque short code + the
* recipient x25519 keypair the test will use to unseal.
*/
async function insertV2Invite(
m: TestMesh,
opts: {
code: string;
expiresInSec?: number;
maxUses?: number;
role?: "admin" | "member";
tamper?: boolean; // corrupt the signature
revoked?: boolean;
used?: number;
},
): Promise<{ inviteId: string; canonical: string }> {
const expiresInSec = opts.expiresInSec ?? 3600;
const expiresAt = new Date(Date.now() + expiresInSec * 1000);
const maxUses = opts.maxUses ?? 1;
const role = opts.role ?? "member";
// Insert first with a placeholder capability so we have the invite id.
const [row] = await db
.insert(invite)
.values({
meshId: m.meshId,
token: `v2-test-token-${opts.code}`,
code: opts.code,
maxUses,
usedCount: opts.used ?? 0,
role,
expiresAt,
createdBy: "test-user-integration",
version: 2,
revokedAt: opts.revoked ? new Date() : null,
})
.returning({ id: invite.id });
if (!row) throw new Error("v2 invite insert failed");
// Now compute canonical_v2 using the real invite id and sign with the
// mesh owner's ed25519 secret key.
const expiresAtUnix = Math.floor(expiresAt.getTime() / 1000);
const canonical = canonicalInviteV2({
mesh_id: m.meshId,
invite_id: row.id,
expires_at: expiresAtUnix,
role,
owner_pubkey: m.ownerPubkey,
});
let signatureHex = sodium.to_hex(
sodium.crypto_sign_detached(
sodium.from_string(canonical),
sodium.from_hex(m.ownerSecretKey),
),
);
if (opts.tamper) {
// Flip a single hex nibble — keeps length valid, invalidates signature.
const first = signatureHex[0] === "0" ? "1" : "0";
signatureHex = first + signatureHex.slice(1);
}
const capability = JSON.stringify({
canonical,
signature: signatureHex,
});
await db
.update(invite)
.set({ capabilityV2: capability })
.where(eq(invite.id, row.id));
return { inviteId: row.id, canonical };
}
function genRecipientX25519(): { pk: string; sk: Uint8Array } {
const kp = sodium.crypto_box_keypair();
return {
pk: sodium.to_base64(kp.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
sk: kp.privateKey,
};
}
describe("claimInviteV2Core — v2 invite claim", () => {
let m: TestMesh;
afterEach(async () => m && (await m.cleanup()));
test("happy path: signed v2 invite returns sealed root_key and member row", async () => {
m = await setupTestMesh("v2-ok");
const rootKeyBytes = await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
const { inviteId, canonical } = await insertV2Invite(m, { code });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.status).toBe(200);
expect(result.body.mesh_id).toBe(m.meshId);
expect(result.body.owner_pubkey).toBe(m.ownerPubkey);
expect(result.body.canonical_v2).toBe(canonical);
expect(result.body.member_id).toBeTruthy();
// Recipient unseals the sealed_root_key using its x25519 secret key.
const sealed = sodium.from_base64(
result.body.sealed_root_key,
sodium.base64_variants.URLSAFE_NO_PADDING,
);
const recipientPkBytes = sodium.from_base64(
recipient.pk,
sodium.base64_variants.URLSAFE_NO_PADDING,
);
const opened = sodium.crypto_box_seal_open(
sealed,
recipientPkBytes,
recipient.sk,
);
expect(opened).toBeInstanceOf(Uint8Array);
expect(opened.length).toBe(32);
expect(Array.from(opened)).toEqual(Array.from(rootKeyBytes));
// usedCount incremented and claimedByPubkey recorded.
const [updated] = await db
.select({
usedCount: invite.usedCount,
claimedByPubkey: invite.claimedByPubkey,
})
.from(invite)
.where(eq(invite.id, inviteId));
expect(updated?.usedCount).toBe(1);
expect(updated?.claimedByPubkey).toBe(recipient.pk);
});
test("tampered signature → 400 bad_signature", async () => {
m = await setupTestMesh("v2-tampered");
await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
await insertV2Invite(m, { code, tamper: true });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(400);
expect(result.body.error).toBe("bad_signature");
});
test("expired invite → 410 expired", async () => {
m = await setupTestMesh("v2-expired");
await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
await insertV2Invite(m, { code, expiresInSec: -60 });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(410);
expect(result.body.error).toBe("expired");
});
test("revoked invite → 410 revoked", async () => {
m = await setupTestMesh("v2-revoked");
await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
await insertV2Invite(m, { code, revoked: true });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(410);
expect(result.body.error).toBe("revoked");
});
test("exhausted invite (usedCount >= maxUses) → 410 exhausted", async () => {
m = await setupTestMesh("v2-exhausted");
await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
await insertV2Invite(m, { code, maxUses: 1, used: 1 });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(410);
expect(result.body.error).toBe("exhausted");
});
test("unknown code → 404 not_found", async () => {
m = await setupTestMesh("v2-404");
await primeRootKey(m.meshId);
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code: "nonexistent",
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(404);
expect(result.body.error).toBe("not_found");
});
});

View File

@@ -0,0 +1,48 @@
import { notFound, redirect } from "next/navigation";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
export const generateMetadata = getMetadata({
title: "Join a mesh",
description: "You've been invited to a claudemesh mesh.",
});
/**
* Short invite URL: /i/{code}
*
* Resolves the short code to the canonical long token server-side and
* redirects to `/join/[token]`. Keeps the rest of the join UX in a single
* place and leaves the broker protocol untouched.
*
* This is a URL shortener, NOT a security boundary — the long token still
* carries the mesh root_key. See the v2 invite protocol spec:
* .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
*/
export default async function ShortInvitePage({
params,
}: {
params: Promise<{ locale: string; code: string }>;
}) {
const { locale, code } = await params;
// Hit the public resolver. Returns {found, token} or 404.
const res = await api.public["invite-code"][":code"]
.$get({ param: { code } })
.catch(() => null);
if (!res || !res.ok) {
notFound();
}
const body = (await res.json()) as
| { found: true; token: string }
| { found: false };
if (!body.found) {
notFound();
}
// next/navigation `redirect` throws — no need to return anything after.
redirect(`/${locale}/join/${body.token}`);
}

View File

@@ -9,6 +9,7 @@ import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import { InstallToggle } from "~/modules/join/install-toggle";
import { InviteCard } from "~/modules/join/invite-card";
export const generateMetadata = getMetadata({
title: "Join a mesh",
@@ -112,42 +113,29 @@ export default async function JoinPage({
</Link>
</header>
<div className="mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
<div className="mx-auto w-full max-w-2xl px-6 py-12 md:px-12 md:py-20">
{invite.valid ? (
<>
<div
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
invitation
</div>
<h1
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
You&apos;re invited to{" "}
<span className="italic text-[var(--cm-clay)]">
{invite.meshName}
</span>
</h1>
<p
className="mt-4 text-lg leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{invite.inviterName
? `${invite.inviterName} added you as a ${invite.role}.`
: `You've been added as a ${invite.role}.`}{" "}
{invite.memberCount} other{" "}
{invite.memberCount === 1 ? "peer is" : "peers are"} already on
the mesh.
</p>
<InviteCard
meshName={invite.meshName}
inviterName={invite.inviterName}
role={invite.role}
memberCount={invite.memberCount}
expiresAt={new Date(invite.expiresAt)}
/>
<div className="mt-12">
<div id="install" className="mt-14 scroll-mt-24">
<div
className="mb-4 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
to accept, run this in your terminal
</div>
<InstallToggle token={invite.token} />
</div>
<div
className="mt-14 rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-5 text-[13px] leading-[1.65] text-[var(--cm-fg-tertiary)]"
className="mt-12 rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-5 text-[13px] leading-[1.65] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
By joining, you&apos;ll be known as a peer with an ed25519
@@ -163,24 +151,27 @@ export default async function JoinPage({
</div>
<p
className="mt-8 text-xs text-[var(--cm-fg-tertiary)]"
className="mt-6 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
expires {new Date(invite.expiresAt).toLocaleDateString()} ·{" "}
{invite.maxUses - invite.usedCount} of {invite.maxUses} uses
remaining
</p>
</>
) : (
<>
<section
aria-labelledby="invite-error-heading"
className="rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 p-7 md:p-9"
>
<div
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[#c46686]"
className="text-[11px] uppercase tracking-[0.22em] text-[#c46686]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
invitation unavailable
</div>
<h1
className="text-[clamp(1.75rem,3.5vw,2.25rem)] font-medium leading-[1.15] text-[var(--cm-fg)]"
id="invite-error-heading"
className="mt-4 text-[clamp(1.75rem,3.5vw,2.25rem)] font-medium leading-[1.15] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{ERROR_COPY[invite.reason].title}
@@ -210,7 +201,7 @@ export default async function JoinPage({
claudemesh.com
</Link>
</div>
</>
</section>
)}
</div>
</main>

View File

@@ -0,0 +1,45 @@
const BULLETS = [
"Send and receive end-to-end encrypted messages with every peer on the mesh",
"Read the shared audit log of mesh events",
"Generate a local ed25519 keypair — your secret key never leaves your machine",
] as const;
export function ConsentSummary() {
return (
<div
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<div className="text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
Joining this mesh will let you
</div>
<ul className="mt-3 space-y-2">
{BULLETS.map((text) => (
<li
key={text}
className="flex items-start gap-2.5 text-[13.5px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="mt-[3px] shrink-0 text-[var(--cm-clay)]"
>
<path
d="M5 12l4 4 10-10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>{text}</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { ConsentSummary } from "./consent-summary";
import { InviterLine } from "./inviter-line";
import { RoleBadge, roleLabel } from "./role-badge";
interface InviteCardProps {
meshName: string;
inviterName: string | null;
role: "admin" | "member";
memberCount: number;
expiresAt: Date;
}
export function InviteCard({
meshName,
inviterName,
role,
memberCount,
expiresAt,
}: InviteCardProps) {
const peerWord = memberCount === 1 ? "peer" : "peers";
return (
<section
aria-labelledby="invite-heading"
className="relative overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 p-7 md:p-9"
>
{/* Eyebrow */}
<div
className="text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
invitation
</div>
{/* Hero */}
<h1
id="invite-heading"
className="mt-4 text-[clamp(1.9rem,3.6vw,2.65rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
You&apos;ve been invited to join{" "}
<span className="italic text-[var(--cm-clay)]">{meshName}</span>
</h1>
{/* Inviter + stats row */}
<div className="mt-6 flex flex-wrap items-center justify-between gap-4">
<InviterLine inviterName={inviterName} />
<div
className="flex items-center gap-2 text-[12.5px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span
aria-hidden="true"
className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-cactus)]"
/>
<span>
{memberCount} {peerWord} · private mesh
</span>
</div>
</div>
{/* Role badge */}
<div className="mt-6">
<RoleBadge role={role} />
</div>
{/* Consent bullets */}
<div className="mt-5">
<ConsentSummary />
</div>
{/* Primary action block */}
<div className="mt-8 flex flex-col gap-3">
<a
href="#install"
className="inline-flex w-full items-center justify-center gap-2 rounded-[var(--cm-radius-md)] bg-[var(--cm-clay)] px-6 py-4 text-[15px] font-medium text-[var(--cm-gray-050)] transition-colors hover:bg-[var(--cm-clay-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cm-clay)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--cm-bg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
aria-label={`Join ${meshName} as ${roleLabel(role)}`}
>
Join {meshName} as {roleLabel(role)}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M5 12h14M13 5l7 7-7 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
<p
className="flex flex-wrap items-center justify-between gap-2 text-[11.5px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span>
valid until{" "}
{expiresAt.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
<a
href="/auth/logout"
className="underline-offset-4 hover:underline"
>
Not you? Sign out
</a>
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,29 @@
interface InviterLineProps {
inviterName: string | null;
}
export function InviterLine({ inviterName }: InviterLineProps) {
const initial = (inviterName ?? "?").trim().charAt(0).toUpperCase() || "?";
return (
<div
className="flex items-center gap-3"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<div
aria-hidden="true"
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] text-[13px] font-medium text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{initial}
</div>
<div className="flex flex-col leading-tight">
<span className="text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)]">
Invited by
</span>
<span className="text-[14.5px] font-medium text-[var(--cm-fg)]">
{inviterName ?? "the mesh owner"}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
type Role = "admin" | "member";
const ROLE_CONFIG: Record<
Role,
{
label: string;
description: string;
icon: React.ReactNode;
accent: string;
dot: string;
}
> = {
admin: {
label: "Admin",
description:
"Full control: invite and remove peers, manage settings, send and receive messages.",
// subtle warning treatment — fig (pinkish) accent, not alarming
accent: "#c46686",
dot: "#c46686",
icon: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M12 2l3 6 6 1-4.5 4.5L18 20l-6-3-6 3 1.5-6.5L3 9l6-1 3-6z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
),
},
member: {
label: "Member",
description:
"Send and receive messages, read the shared audit log, participate in mesh traffic.",
accent: "var(--cm-clay)",
dot: "var(--cm-clay)",
icon: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6" />
<path
d="M4 20c0-4 4-6 8-6s8 2 8 6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
),
},
};
interface RoleBadgeProps {
role: Role;
}
export function RoleBadge({ role }: RoleBadgeProps) {
const cfg = ROLE_CONFIG[role];
return (
<div
className="flex items-start gap-3 rounded-[var(--cm-radius-md)] border p-4"
style={{
borderColor: cfg.accent,
backgroundColor:
"color-mix(in srgb, var(--cm-bg-elevated) 70%, transparent)",
}}
>
<div
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{
color: cfg.accent,
backgroundColor: "color-mix(in srgb, var(--cm-bg) 60%, transparent)",
border: `1px solid ${cfg.accent}`,
}}
>
{cfg.icon}
</div>
<div className="flex-1 min-w-0">
<div
className="flex items-center gap-2 text-[13px] font-medium"
style={{ color: cfg.accent, fontFamily: "var(--cm-font-sans)" }}
>
<span className="uppercase tracking-[0.14em]">
You&apos;ll join as {cfg.label}
</span>
</div>
<p
className="mt-1 text-[13.5px] leading-[1.55] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{cfg.description}
</p>
</div>
</div>
);
}
export function roleLabel(role: Role) {
return ROLE_CONFIG[role].label;
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -32,14 +31,6 @@ import {
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/client";
const slugify = (s: string) =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
export const CreateMeshForm = ({
onboarding = false,
}: { onboarding?: boolean } = {}) => {
@@ -48,30 +39,16 @@ export const CreateMeshForm = ({
resolver: zodResolver(createMyMeshInputSchema),
defaultValues: {
name: "",
slug: "",
visibility: "private",
transport: "managed",
},
});
const nameValue = form.watch("name");
const slugDirty = form.formState.dirtyFields.slug;
useEffect(() => {
if (!slugDirty && nameValue) {
form.setValue("slug", slugify(nameValue));
}
}, [nameValue, slugDirty, form]);
const onSubmit = async (values: CreateMyMeshInput) => {
try {
const res = (await handle(api.my.meshes.$post)({
json: values,
})) as { id: string; slug: string } | { error: string };
if ("error" in res) {
form.setError("slug", { message: res.error });
return;
}
})) as { id: string; slug: string };
router.push(
onboarding
? `${pathsConfig.dashboard.user.meshes.invite(res.id)}?onboarding=1`
@@ -97,23 +74,7 @@ export const CreateMeshForm = ({
<Input placeholder="Platform team" {...field} />
</FormControl>
<FormDescription>
Display name what teammates see.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="platform-team" {...field} />
</FormControl>
<FormDescription>
URL-safe identifier: lowercase letters, digits, hyphens.
Display name what teammates see. Pick anything.
</FormDescription>
<FormMessage />
</FormItem>

View File

@@ -36,6 +36,8 @@ interface GeneratedInvite {
token: string;
inviteLink: string;
joinUrl: string;
/** Short human-friendly URL, preferred for sharing. Null if the backend didn't mint one. */
shortUrl: string | null;
expiresAt: Date;
qrDataUrl: string;
}
@@ -43,6 +45,7 @@ interface GeneratedInvite {
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
const [result, setResult] = useState<GeneratedInvite | null>(null);
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const form = useForm<CreateMyInviteInput>({
resolver: zodResolver(createMyInviteInputSchema),
@@ -54,24 +57,20 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
const res = (await handle(api.my.meshes[":id"].invites.$post)({
param: { id: meshId },
json: values,
})) as
| {
id: string;
token: string;
inviteLink: string;
joinUrl: string;
expiresAt: string;
}
| { error: string };
})) as {
id: string;
token: string;
inviteLink: string;
joinUrl: string;
shortUrl: string | null;
expiresAt: string;
};
if ("error" in res) {
form.setError("root", { message: res.error });
return;
}
// QR encodes the HTTPS join URL now — anyone with a camera can
// scan and land on the friendly /join/[token] page.
const qrDataUrl = await QRCode.toDataURL(res.joinUrl, {
// QR encodes the SHORT URL when available — scannable at camera distance
// and short enough for the QR to stay low-density. Falls back to the
// long token URL for legacy invites minted before the shortener shipped.
const qrTarget = res.shortUrl ?? res.joinUrl;
const qrDataUrl = await QRCode.toDataURL(qrTarget, {
width: 256,
margin: 1,
color: { dark: "#141413", light: "#ffffff" },
@@ -82,6 +81,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
token: res.token,
inviteLink: res.inviteLink,
joinUrl: res.joinUrl,
shortUrl: res.shortUrl,
expiresAt: new Date(res.expiresAt),
qrDataUrl,
});
@@ -99,6 +99,10 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
};
if (result) {
// Prefer the short URL everywhere it exists. CLI command still uses the
// long token because the broker resolves by token — swapping CLI to short
// codes is part of the v2 protocol, not this URL-shortener change.
const primaryUrl = result.shortUrl ?? result.joinUrl;
const cliCmd = `claudemesh join ${result.token}`;
return (
<div className="space-y-6">
@@ -117,7 +121,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
Share this link
</div>
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
{result.joinUrl}
{primaryUrl}
</code>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs">
@@ -126,7 +130,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
</Badge>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={() => copy(result.joinUrl, "url")} size="sm">
<Button onClick={() => copy(primaryUrl, "url")} size="sm">
{copied === "url" ? "Copied ✓" : "Copy link"}
</Button>
<Button
@@ -168,65 +172,89 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-md space-y-5">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<p className="text-muted-foreground text-sm">
One-time invite for a new member. Valid for 7 days.
</p>
{/* Advanced options — hidden by default. Defaults ship 90% of users. */}
<div className="rounded-md border border-dashed">
<button
type="button"
onClick={() => setShowAdvanced((s) => !s)}
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between px-3 py-2 text-xs uppercase tracking-wider"
aria-expanded={showAdvanced}
>
<span>Advanced</span>
<span aria-hidden="true">{showAdvanced ? "" : "+"}</span>
</button>
{showAdvanced && (
<div className="space-y-4 border-t px-3 py-4">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxUses"
render={({ field }) => (
<FormItem>
<FormLabel>Max uses</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={1000}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiresInDays"
render={({ field }) => (
<FormItem>
<FormLabel>Expires in (days)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={365}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
/>
<FormField
control={form.control}
name="maxUses"
render={({ field }) => (
<FormItem>
<FormLabel>Max uses</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={1000}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiresInDays"
render={({ field }) => (
<FormItem>
<FormLabel>Expires in (days)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={365}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{form.formState.errors.root && (
<p className="text-destructive text-sm">
{form.formState.errors.root.message}