feat(web): public /join/[token] page + https invite url
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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:
Alejandro Gutiérrez
2026-04-05 16:36:24 +01:00
parent 59e999535d
commit 6acfc252b0
6 changed files with 663 additions and 20 deletions

View 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&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>
<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&apos;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>
);
}