feat(web): public /join/[token] page + https invite url
Clickable HTTPS invite URLs replace the raw ic://join/<token> as the primary share format. Someone receiving a link in Slack now lands on a friendly page with install instructions, not a dead-end. Backend: - createMyInvite returns a new joinUrl field (https://claudemesh.com/join/<token>) alongside the existing ic://join/<token> inviteLink and raw token. Schema + Hono route updated. ic:// scheme stays — CLI parses both. - New GET /api/public/invite/:token in packages/api/src/modules/public/ (unauthed). Decodes the base64url payload, verifies ed25519 signature against owner_pubkey using the same canonicalInvite() contract the broker enforces on join, then joins mesh/invite/user to return the shape needed by the landing page. Does NOT mutate usedCount — this is a read-only preview. - Error taxonomy: malformed | bad_signature | expired | revoked | exhausted | mesh_archived | not_found. Each returned with any metadata we CAN surface (meshName, inviterName, expiresAt) so the error page can be specific ("ask Jordan for a new one"). - cache-control: public max-age=30 on valid invites, no-store on errors (reasons flip as state changes). Frontend: - New public route /[locale]/join/[token] (no auth). Server Component fetches the preview endpoint, branches on valid/invalid, renders a minimal landing-design-language shell (wordmark header, clay accents, serif headlines, mono commands). - Valid-invite view: "You're invited to {meshName}", inviter + role + member-count lede, install-toggle component. - Invalid-invite view: per-reason error copy + inviter name when available + link back to /. - InstallToggle client component: three-way state (unknown/yes/no). Asks "first time / already set up?", then shows either the 3-step install+init+join path with per-step copy buttons, or the single claudemesh join <token> command for users who have the CLI. Every code block has copy-to-clipboard. - Security footer: "ed25519 keypair generated locally, you keep your keys, broker sees ciphertext only, leave anytime with claudemesh leave <mesh-slug>". Invite generator (/dashboard/meshes/[id]/invite): - QR code now encodes the HTTPS joinUrl instead of ic:// (phone cameras land on the web page → friendly path). - Primary CTA copies the HTTPS URL. Secondary "Copy CLI command" for fast-path users. Footer explanation updated. CLI coordination note: dispatched to broker/db lane — claudemesh CLI needs to accept BOTH ic://join/<token> AND https://claudemesh.com/join/<token> (extract <token> from pathname). Server side already returns both. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
218
apps/web/src/app/[locale]/join/[token]/page.tsx
Normal file
218
apps/web/src/app/[locale]/join/[token]/page.tsx
Normal file
@@ -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<PublicInviteResponse, { valid: false }>["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 (
|
||||
<main
|
||||
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<header className="border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="claudemesh home"
|
||||
className="group flex w-fit items-center gap-2.5"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-[var(--cm-clay)] transition-transform duration-300 group-hover:rotate-180"
|
||||
>
|
||||
<circle cx="12" cy="4" r="2" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="20" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="20" r="2" fill="currentColor" />
|
||||
<path
|
||||
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.45"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="text-[17px] font-medium tracking-tight"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh
|
||||
</span>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
|
||||
{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'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>
|
||||
|
||||
<div className="mt-12">
|
||||
<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)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
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{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh leave {invite.meshSlug}
|
||||
</code>
|
||||
.
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="mt-8 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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="mb-5 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)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{ERROR_COPY[invite.reason].title}
|
||||
</h1>
|
||||
<p
|
||||
className="mt-4 text-base leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{ERROR_COPY[invite.reason].body(invite.inviterName)}
|
||||
</p>
|
||||
{invite.meshName && (
|
||||
<p
|
||||
className="mt-2 text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
mesh: {invite.meshName}
|
||||
{invite.expiresAt &&
|
||||
` · expired ${new Date(invite.expiresAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-10">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg-elevated)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
← claudemesh.com
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
183
apps/web/src/modules/join/install-toggle.tsx
Normal file
183
apps/web/src/modules/join/install-toggle.tsx
Normal file
@@ -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<string | null>(null);
|
||||
|
||||
const copy = async (text: string, key: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
if (hasCli === "unknown") {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
onClick={() => setHasCli("no")}
|
||||
className="flex-1 rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5 text-left transition-colors hover:border-[var(--cm-clay)] hover:bg-[var(--cm-bg-hover)]"
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
first time
|
||||
</div>
|
||||
<div
|
||||
className="text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Install claudemesh →
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setHasCli("yes")}
|
||||
className="flex-1 rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5 text-left transition-colors hover:border-[var(--cm-clay)] hover:bg-[var(--cm-bg-hover)]"
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
already set up
|
||||
</div>
|
||||
<div
|
||||
className="text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Join with CLI →
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCli === "yes") {
|
||||
const cmd = JOIN_CMD(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
run this in your terminal
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{cmd}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(cmd, "join")}
|
||||
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-4 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHasCli("unknown")}
|
||||
className="text-xs text-[var(--cm-fg-tertiary)] underline underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
← Need to install first?
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const joinCmd = JOIN_CMD(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ol className="space-y-3">
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
||||
install + init
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{INSTALL_CMD}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(INSTALL_CMD, "install")}
|
||||
className="rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-3 py-3 text-sm text-[var(--cm-fg-secondary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "install" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Generates your ed25519 keypair locally and wires claudemesh into
|
||||
your Claude Code config. You own the keys.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
|
||||
join the mesh
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{joinCmd}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(joinCmd, "join")}
|
||||
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-3 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
|
||||
verify
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your Claude Code session will announce itself to the mesh. Other
|
||||
peers see you appear as a green dot in their dashboard.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<button
|
||||
onClick={() => setHasCli("unknown")}
|
||||
className="text-xs text-[var(--cm-fg-tertiary)] underline underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<GeneratedInvite | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
|
||||
|
||||
const form = useForm<CreateMyInviteInput>({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border p-6">
|
||||
@@ -109,10 +114,10 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wider">
|
||||
Invite link
|
||||
Share this link
|
||||
</div>
|
||||
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
|
||||
{result.inviteLink}
|
||||
{result.joinUrl}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
@@ -120,9 +125,16 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
expires {result.expiresAt.toLocaleDateString()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onCopy} size="sm">
|
||||
{copied ? "Copied ✓" : "Copy link"}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => copy(result.joinUrl, "url")} size="sm">
|
||||
{copied === "url" ? "Copied ✓" : "Copy link"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copy(cliCmd, "cli")}
|
||||
>
|
||||
{copied === "cli" ? "Copied ✓" : "Copy CLI command"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -140,12 +152,14 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
</div>
|
||||
<div className="text-muted-foreground rounded border border-dashed p-4 text-xs">
|
||||
<p className="mb-2 font-medium">How your teammate joins:</p>
|
||||
<code className="bg-muted block rounded p-2 font-mono text-xs">
|
||||
claudemesh join {result.inviteLink}
|
||||
</code>
|
||||
<p className="mt-2">
|
||||
Or scan the QR code from the claudemesh mobile app (coming soon).
|
||||
<p className="mb-2">
|
||||
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:
|
||||
</p>
|
||||
<code className="bg-muted block rounded p-2 font-mono text-xs">
|
||||
{cliCmd}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<PublicStats> => {
|
||||
};
|
||||
};
|
||||
|
||||
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<InvitePayload, "signature">): 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<number>`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");
|
||||
|
||||
@@ -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<typeof createMyInviteResponseSchema>;
|
||||
@@ -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<typeof publicInviteResponseSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public stats (unauthed landing counter)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user