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,45 @@
const BULLETS = [
"Send and receive end-to-end encrypted messages with every peer on the mesh",
"Read the shared audit log of mesh events",
"Generate a local ed25519 keypair — your secret key never leaves your machine",
] as const;
export function ConsentSummary() {
return (
<div
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<div className="text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
Joining this mesh will let you
</div>
<ul className="mt-3 space-y-2">
{BULLETS.map((text) => (
<li
key={text}
className="flex items-start gap-2.5 text-[13.5px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="mt-[3px] shrink-0 text-[var(--cm-clay)]"
>
<path
d="M5 12l4 4 10-10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>{text}</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { ConsentSummary } from "./consent-summary";
import { InviterLine } from "./inviter-line";
import { RoleBadge, roleLabel } from "./role-badge";
interface InviteCardProps {
meshName: string;
inviterName: string | null;
role: "admin" | "member";
memberCount: number;
expiresAt: Date;
}
export function InviteCard({
meshName,
inviterName,
role,
memberCount,
expiresAt,
}: InviteCardProps) {
const peerWord = memberCount === 1 ? "peer" : "peers";
return (
<section
aria-labelledby="invite-heading"
className="relative overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 p-7 md:p-9"
>
{/* Eyebrow */}
<div
className="text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
invitation
</div>
{/* Hero */}
<h1
id="invite-heading"
className="mt-4 text-[clamp(1.9rem,3.6vw,2.65rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
You&apos;ve been invited to join{" "}
<span className="italic text-[var(--cm-clay)]">{meshName}</span>
</h1>
{/* Inviter + stats row */}
<div className="mt-6 flex flex-wrap items-center justify-between gap-4">
<InviterLine inviterName={inviterName} />
<div
className="flex items-center gap-2 text-[12.5px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span
aria-hidden="true"
className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-cactus)]"
/>
<span>
{memberCount} {peerWord} · private mesh
</span>
</div>
</div>
{/* Role badge */}
<div className="mt-6">
<RoleBadge role={role} />
</div>
{/* Consent bullets */}
<div className="mt-5">
<ConsentSummary />
</div>
{/* Primary action block */}
<div className="mt-8 flex flex-col gap-3">
<a
href="#install"
className="inline-flex w-full items-center justify-center gap-2 rounded-[var(--cm-radius-md)] bg-[var(--cm-clay)] px-6 py-4 text-[15px] font-medium text-[var(--cm-gray-050)] transition-colors hover:bg-[var(--cm-clay-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cm-clay)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--cm-bg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
aria-label={`Join ${meshName} as ${roleLabel(role)}`}
>
Join {meshName} as {roleLabel(role)}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M5 12h14M13 5l7 7-7 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
<p
className="flex flex-wrap items-center justify-between gap-2 text-[11.5px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span>
valid until{" "}
{expiresAt.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
<a
href="/auth/logout"
className="underline-offset-4 hover:underline"
>
Not you? Sign out
</a>
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,29 @@
interface InviterLineProps {
inviterName: string | null;
}
export function InviterLine({ inviterName }: InviterLineProps) {
const initial = (inviterName ?? "?").trim().charAt(0).toUpperCase() || "?";
return (
<div
className="flex items-center gap-3"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<div
aria-hidden="true"
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] text-[13px] font-medium text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{initial}
</div>
<div className="flex flex-col leading-tight">
<span className="text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)]">
Invited by
</span>
<span className="text-[14.5px] font-medium text-[var(--cm-fg)]">
{inviterName ?? "the mesh owner"}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
type Role = "admin" | "member";
const ROLE_CONFIG: Record<
Role,
{
label: string;
description: string;
icon: React.ReactNode;
accent: string;
dot: string;
}
> = {
admin: {
label: "Admin",
description:
"Full control: invite and remove peers, manage settings, send and receive messages.",
// subtle warning treatment — fig (pinkish) accent, not alarming
accent: "#c46686",
dot: "#c46686",
icon: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M12 2l3 6 6 1-4.5 4.5L18 20l-6-3-6 3 1.5-6.5L3 9l6-1 3-6z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
),
},
member: {
label: "Member",
description:
"Send and receive messages, read the shared audit log, participate in mesh traffic.",
accent: "var(--cm-clay)",
dot: "var(--cm-clay)",
icon: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6" />
<path
d="M4 20c0-4 4-6 8-6s8 2 8 6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
),
},
};
interface RoleBadgeProps {
role: Role;
}
export function RoleBadge({ role }: RoleBadgeProps) {
const cfg = ROLE_CONFIG[role];
return (
<div
className="flex items-start gap-3 rounded-[var(--cm-radius-md)] border p-4"
style={{
borderColor: cfg.accent,
backgroundColor:
"color-mix(in srgb, var(--cm-bg-elevated) 70%, transparent)",
}}
>
<div
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{
color: cfg.accent,
backgroundColor: "color-mix(in srgb, var(--cm-bg) 60%, transparent)",
border: `1px solid ${cfg.accent}`,
}}
>
{cfg.icon}
</div>
<div className="flex-1 min-w-0">
<div
className="flex items-center gap-2 text-[13px] font-medium"
style={{ color: cfg.accent, fontFamily: "var(--cm-font-sans)" }}
>
<span className="uppercase tracking-[0.14em]">
You&apos;ll join as {cfg.label}
</span>
</div>
<p
className="mt-1 text-[13.5px] leading-[1.55] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{cfg.description}
</p>
</div>
</div>
);
}
export function roleLabel(role: Role) {
return ROLE_CONFIG[role].label;
}