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

@@ -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>