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:
48
apps/web/src/app/[locale]/i/[code]/page.tsx
Normal file
48
apps/web/src/app/[locale]/i/[code]/page.tsx
Normal 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}`);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
|
||||
Reference in New Issue
Block a user