diff --git a/apps/web/src/app/[locale]/join/[token]/page.tsx b/apps/web/src/app/[locale]/join/[token]/page.tsx new file mode 100644 index 0000000..c0bf996 --- /dev/null +++ b/apps/web/src/app/[locale]/join/[token]/page.tsx @@ -0,0 +1,218 @@ +import Link from "next/link"; + +import { + publicInviteResponseSchema, + type PublicInviteResponse, +} from "@turbostarter/api/schema"; +import { handle } from "@turbostarter/api/utils"; + +import { api } from "~/lib/api/server"; +import { getMetadata } from "~/lib/metadata"; +import { InstallToggle } from "~/modules/join/install-toggle"; + +export const generateMetadata = getMetadata({ + title: "Join a mesh", + description: "You've been invited to a claudemesh mesh.", +}); + +const ERROR_COPY: Record< + Extract["reason"], + { title: string; body: (inviter: string | null) => string } +> = { + expired: { + title: "This invite expired", + body: (inviter) => + `The invite is no longer valid. Ask ${inviter ?? "the person who sent it"} for a fresh link.`, + }, + revoked: { + title: "This invite was revoked", + body: (inviter) => + `${inviter ?? "The mesh owner"} revoked this invite. Ask for a new one if you still need access.`, + }, + exhausted: { + title: "This invite has no uses left", + body: (inviter) => + `Every allowed use has been redeemed. Ask ${inviter ?? "the person who sent it"} for a new link.`, + }, + mesh_archived: { + title: "This mesh is no longer active", + body: () => "The mesh was archived. There is nothing to join.", + }, + bad_signature: { + title: "This invite is invalid", + body: () => + "The signature does not verify. The link was modified or forged — ask for a fresh one through a trusted channel.", + }, + malformed: { + title: "This invite is unreadable", + body: () => + "The token could not be decoded. Check the link you received — it may be truncated.", + }, + not_found: { + title: "This invite does not exist", + body: () => + "Nothing matches this token. It may have been deleted, or the link was mis-pasted.", + }, +}; + +export default async function JoinPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + const invite = await handle(api.public.invite[":token"].$get, { + schema: publicInviteResponseSchema, + })({ param: { token } }).catch( + () => + ({ + valid: false, + reason: "malformed", + meshName: null, + inviterName: null, + expiresAt: null, + }) as const, + ); + + return ( +
+
+ + + + + + + + + + claudemesh + + +
+ +
+ {invite.valid ? ( + <> +
+ — invitation +
+

+ You're invited to{" "} + + {invite.meshName} + +

+

+ {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. +

+ +
+ +
+ +
+ By joining, you'll be known as a peer with an ed25519 + keypair generated locally. You keep your keys. claudemesh sees + ciphertext only. Leave anytime with{" "} + + claudemesh leave {invite.meshSlug} + + . +
+ +

+ expires {new Date(invite.expiresAt).toLocaleDateString()} ·{" "} + {invite.maxUses - invite.usedCount} of {invite.maxUses} uses + remaining +

+ + ) : ( + <> +
+ — invitation unavailable +
+

+ {ERROR_COPY[invite.reason].title} +

+

+ {ERROR_COPY[invite.reason].body(invite.inviterName)} +

+ {invite.meshName && ( +

+ mesh: {invite.meshName} + {invite.expiresAt && + ` · expired ${new Date(invite.expiresAt).toLocaleDateString()}`} +

+ )} +
+ + ← claudemesh.com + +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/modules/join/install-toggle.tsx b/apps/web/src/modules/join/install-toggle.tsx new file mode 100644 index 0000000..9542afd --- /dev/null +++ b/apps/web/src/modules/join/install-toggle.tsx @@ -0,0 +1,183 @@ +"use client"; +import { useState } from "react"; + +interface Props { + token: string; +} + +const JOIN_CMD = (token: string) => `claudemesh join ${token}`; +const INSTALL_CMD = "npx claudemesh@latest init"; + +export const InstallToggle = ({ token }: Props) => { + const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown"); + const [copiedKey, setCopiedKey] = useState(null); + + const copy = async (text: string, key: string) => { + await navigator.clipboard.writeText(text); + setCopiedKey(key); + setTimeout(() => setCopiedKey(null), 2000); + }; + + if (hasCli === "unknown") { + return ( +
+ + +
+ ); + } + + if (hasCli === "yes") { + const cmd = JOIN_CMD(token); + return ( +
+
+
+ run this in your terminal +
+
+ + {cmd} + + +
+
+ +
+ ); + } + + const joinCmd = JOIN_CMD(token); + return ( +
+
    +
  1. +
    + 1 + install + init +
    +
    + + {INSTALL_CMD} + + +
    +

    + Generates your ed25519 keypair locally and wires claudemesh into + your Claude Code config. You own the keys. +

    +
  2. +
  3. +
    + 2 + join the mesh +
    +
    + + {joinCmd} + + +
    +
  4. +
  5. +
    + 3 + verify +
    +

    + Your Claude Code session will announce itself to the mesh. Other + peers see you appear as a green dot in their dashboard. +

    +
  6. +
+ +
+ ); +}; diff --git a/apps/web/src/modules/mesh/invite-generator.tsx b/apps/web/src/modules/mesh/invite-generator.tsx index dde0d45..9dae42b 100644 --- a/apps/web/src/modules/mesh/invite-generator.tsx +++ b/apps/web/src/modules/mesh/invite-generator.tsx @@ -35,13 +35,14 @@ interface GeneratedInvite { id: string; token: string; inviteLink: string; + joinUrl: string; expiresAt: Date; qrDataUrl: string; } export const InviteGenerator = ({ meshId }: { meshId: string }) => { const [result, setResult] = useState(null); - const [copied, setCopied] = useState(false); + const [copied, setCopied] = useState<"url" | "cli" | null>(null); const form = useForm({ resolver: zodResolver(createMyInviteInputSchema), @@ -58,6 +59,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { id: string; token: string; inviteLink: string; + joinUrl: string; expiresAt: string; } | { error: string }; @@ -67,7 +69,9 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { return; } - const qrDataUrl = await QRCode.toDataURL(res.inviteLink, { + // 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, { width: 256, margin: 1, color: { dark: "#141413", light: "#ffffff" }, @@ -77,6 +81,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { id: res.id, token: res.token, inviteLink: res.inviteLink, + joinUrl: res.joinUrl, expiresAt: new Date(res.expiresAt), qrDataUrl, }); @@ -87,14 +92,14 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { } }; - const onCopy = async () => { - if (!result) return; - await navigator.clipboard.writeText(result.inviteLink); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const copy = async (text: string, key: "url" | "cli") => { + await navigator.clipboard.writeText(text); + setCopied(key); + setTimeout(() => setCopied(null), 2000); }; if (result) { + const cliCmd = `claudemesh join ${result.token}`; return (
@@ -109,10 +114,10 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
- Invite link + Share this link
- {result.inviteLink} + {result.joinUrl}
@@ -120,9 +125,16 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { expires {result.expiresAt.toLocaleDateString()}
-
- +

How your teammate joins:

- - claudemesh join {result.inviteLink} - -

- Or scan the QR code from the claudemesh mobile app (coming soon). +

+ Paste the link in Slack / Telegram / email. They land on a page + with step-by-step install, or run the CLI directly if they already + have it:

+ + {cliCmd} +
); diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts index 88b327b..c7f84fd 100644 --- a/packages/api/src/modules/mesh/mutations.ts +++ b/packages/api/src/modules/mesh/mutations.ts @@ -10,6 +10,7 @@ import type { } from "../../schema"; const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_URL ?? "ws://localhost:7900"; +const APP_URL = process.env.NEXT_PUBLIC_URL ?? "https://claudemesh.com"; /** * Canonical invite bytes — MUST match the broker's canonicalInvite() @@ -236,5 +237,6 @@ export const createMyInvite = async ({ token: created!.token, expiresAt: created!.expiresAt, inviteLink: `ic://join/${token}`, + joinUrl: `${APP_URL.replace(/\/$/, "")}/join/${token}`, }; }; diff --git a/packages/api/src/modules/public/router.ts b/packages/api/src/modules/public/router.ts index 3bb1004..d71b0db 100644 --- a/packages/api/src/modules/public/router.ts +++ b/packages/api/src/modules/public/router.ts @@ -1,7 +1,15 @@ import { Hono } from "hono"; +import sodium from "libsodium-wrappers"; -import { count, isNull } from "@turbostarter/db"; -import { mesh, messageQueue, presence } from "@turbostarter/db/schema"; +import { count, eq, isNull, sql } from "@turbostarter/db"; +import { user } from "@turbostarter/db/schema"; +import { + invite, + mesh, + meshMember, + messageQueue, + presence, +} from "@turbostarter/db/schema"; import { db } from "@turbostarter/db/server"; /** @@ -42,7 +50,189 @@ const fetchStats = async (): Promise => { }; }; -export const publicRouter = new Hono().get("/stats", async (c) => { +// --------------------------------------------------------------------- +// Invite preview (read-only, no counter mutation) +// --------------------------------------------------------------------- + +interface InvitePayload { + v: number; + mesh_id: string; + mesh_slug: string; + broker_url: string; + expires_at: number; + mesh_root_key: string; + role: "admin" | "member"; + owner_pubkey: string; + signature?: string; +} + +const canonicalInvite = (p: Omit): string => + `${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`; + +let sodiumReady = false; +const ensureSodium = async () => { + if (!sodiumReady) { + await sodium.ready; + sodiumReady = true; + } + return sodium; +}; + +const decodeInviteToken = ( + token: string, +): InvitePayload | null => { + try { + const json = Buffer.from(token, "base64url").toString("utf-8"); + const obj = JSON.parse(json) as unknown; + if ( + typeof obj !== "object" || + obj === null || + !("mesh_id" in obj) || + !("signature" in obj) + ) { + return null; + } + return obj as InvitePayload; + } catch { + return null; + } +}; + +// Invite preview handler — route is mounted below alongside /stats. +const inviteHandler = async (rawToken: string) => { + const payload = decodeInviteToken(rawToken); + if (!payload || !payload.signature) { + return { + valid: false as const, + reason: "malformed" as const, + meshName: null, + inviterName: null, + expiresAt: null, + }; + } + + // Verify ed25519 signature matches owner_pubkey from payload + const s = await ensureSodium(); + let sigValid = false; + try { + sigValid = s.crypto_sign_verify_detached( + s.from_hex(payload.signature), + s.from_string(canonicalInvite(payload)), + s.from_hex(payload.owner_pubkey), + ); + } catch { + sigValid = false; + } + if (!sigValid) { + return { + valid: false as const, + reason: "bad_signature" as const, + meshName: null, + inviterName: null, + expiresAt: null, + }; + } + + // DB lookup — mesh + invite row + inviter + const [row] = await db + .select({ + inviteId: invite.id, + maxUses: invite.maxUses, + usedCount: invite.usedCount, + role: invite.role, + expiresAt: invite.expiresAt, + revokedAt: invite.revokedAt, + meshId: mesh.id, + meshName: mesh.name, + meshSlug: mesh.slug, + meshArchivedAt: mesh.archivedAt, + inviterName: user.name, + }) + .from(invite) + .leftJoin(mesh, eq(invite.meshId, mesh.id)) + .leftJoin(user, eq(invite.createdBy, user.id)) + .where(eq(invite.token, rawToken)) + .limit(1); + + if (!row || !row.meshId) { + return { + valid: false as const, + reason: "not_found" as const, + meshName: null, + inviterName: null, + expiresAt: null, + }; + } + + if (row.revokedAt) { + return { + valid: false as const, + reason: "revoked" as const, + meshName: row.meshName, + inviterName: row.inviterName, + expiresAt: row.expiresAt, + }; + } + if (row.meshArchivedAt) { + return { + valid: false as const, + reason: "mesh_archived" as const, + meshName: row.meshName, + inviterName: row.inviterName, + expiresAt: row.expiresAt, + }; + } + if (row.expiresAt < new Date()) { + return { + valid: false as const, + reason: "expired" as const, + meshName: row.meshName, + inviterName: row.inviterName, + expiresAt: row.expiresAt, + }; + } + if (row.usedCount >= row.maxUses) { + return { + valid: false as const, + reason: "exhausted" as const, + meshName: row.meshName, + inviterName: row.inviterName, + expiresAt: row.expiresAt, + }; + } + + // Count active members + const [memberCountRow] = await db + .select({ c: sql`COUNT(*)::int` }) + .from(meshMember) + .where(eq(meshMember.meshId, row.meshId)); + + return { + valid: true as const, + meshName: row.meshName ?? "", + meshSlug: row.meshSlug ?? "", + inviterName: row.inviterName, + memberCount: memberCountRow?.c ?? 0, + role: row.role, + expiresAt: row.expiresAt, + maxUses: row.maxUses, + usedCount: row.usedCount, + token: rawToken, + }; +}; + +export const publicRouter = new Hono() + .get("/invite/:token", async (c) => { + const result = await inviteHandler(c.req.param("token")); + // Small cache on valid invites, no cache on errors (reason can change) + if (result.valid) { + c.header("cache-control", "public, max-age=30"); + } else { + c.header("cache-control", "no-store"); + } + return c.json(result); + }) + .get("/stats", async (c) => { const now = Date.now(); if (cachedStats && cachedStats.expiresAt > now) { c.header("x-cache", "HIT"); diff --git a/packages/api/src/schema/mesh-user.ts b/packages/api/src/schema/mesh-user.ts index ae05cd5..c0bd988 100644 --- a/packages/api/src/schema/mesh-user.ts +++ b/packages/api/src/schema/mesh-user.ts @@ -131,6 +131,7 @@ export const createMyInviteResponseSchema = z.object({ id: z.string(), token: z.string(), inviteLink: z.string(), + joinUrl: z.string(), expiresAt: z.coerce.date(), }); export type CreateMyInviteResponse = z.infer; @@ -186,6 +187,41 @@ export type GetMyMeshStreamResponse = z.infer< typeof getMyMeshStreamResponseSchema >; +// --------------------------------------------------------------------- +// Public invite preview (unauthed invite-landing page) +// --------------------------------------------------------------------- + +export const publicInviteResponseSchema = z.discriminatedUnion("valid", [ + z.object({ + valid: z.literal(true), + meshName: z.string(), + meshSlug: z.string(), + inviterName: z.string().nullable(), + memberCount: z.number(), + role: z.enum(["admin", "member"]), + expiresAt: z.coerce.date(), + maxUses: z.number(), + usedCount: z.number(), + token: z.string(), + }), + z.object({ + valid: z.literal(false), + reason: z.enum([ + "malformed", + "bad_signature", + "expired", + "revoked", + "exhausted", + "mesh_archived", + "not_found", + ]), + meshName: z.string().nullable(), + inviterName: z.string().nullable(), + expiresAt: z.coerce.date().nullable(), + }), +]); +export type PublicInviteResponse = z.infer; + // --------------------------------------------------------------------- // Public stats (unauthed landing counter) // ---------------------------------------------------------------------