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

@@ -1,3 +1,5 @@
import { randomBytes } from "node:crypto";
import sodium from "libsodium-wrappers";
import { and, eq, isNull } from "@turbostarter/db";
@@ -9,7 +11,8 @@ import type {
CreateMyMeshInput,
} from "../../schema";
const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_URL ?? "ws://localhost:7900";
const BROKER_URL =
process.env.NEXT_PUBLIC_BROKER_URL ?? "wss://ic.claudemesh.com/ws";
const APP_URL = process.env.NEXT_PUBLIC_URL ?? "https://claudemesh.com";
/**
@@ -38,6 +41,35 @@ const ensureSodium = async (): Promise<typeof sodium> => {
return sodium;
};
/**
* Slugify a display name into a URL-safe token. Used only as cosmetic
* metadata embedded in invite payloads for debugging/display — NOT as a
* canonical identifier. `mesh.id` (opaque) is the canonical identity.
*/
const toSlug = (name: string): string =>
name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "mesh";
/**
* Base62 alphabet excluding visually ambiguous characters (0, O, I, l, 1).
* 57 symbols × 8 positions ≈ 1.1e14 combinations — birthday collision at
* ~10M invites, fine for years. We retry-on-conflict at insert time anyway.
*/
const SHORTCODE_ALPHABET =
"23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
const generateShortCode = (len = 8): string => {
const bytes = randomBytes(len);
let out = "";
for (let i = 0; i < len; i++) {
out += SHORTCODE_ALPHABET[bytes[i]! % SHORTCODE_ALPHABET.length];
}
return out;
};
export const createMyMesh = async ({
userId,
input,
@@ -45,16 +77,9 @@ export const createMyMesh = async ({
userId: string;
input: CreateMyMeshInput;
}) => {
// Slug collision check
const [existing] = await db
.select({ id: mesh.id })
.from(mesh)
.where(eq(mesh.slug, input.slug))
.limit(1);
if (existing) {
throw new Error("A mesh with that slug already exists.");
}
// Slug is derived from name and stored non-uniquely — meshes are identified
// by `mesh.id` (opaque). Two users can freely name their meshes "platform".
const slug = toSlug(input.name);
// Generate the mesh owner's ed25519 keypair (signs invites) and a
// 32-byte shared root key (channel encryption in later steps).
@@ -72,7 +97,7 @@ export const createMyMesh = async ({
.insert(mesh)
.values({
name: input.name,
slug: input.slug,
slug,
visibility: input.visibility,
transport: input.transport,
ownerUserId: userId,
@@ -215,28 +240,59 @@ export const createMyInvite = async ({
const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString(
"base64url",
);
const [created] = await db
.insert(invite)
.values({
meshId,
token,
tokenBytes: canonical,
maxUses: input.maxUses,
role: input.role,
expiresAt,
createdBy: userId,
})
.returning({
id: invite.id,
token: invite.token,
expiresAt: invite.expiresAt,
});
// Short URL shortener code. Retry on the (extremely unlikely) collision
// against the unique index. 3 attempts is plenty given the keyspace.
let code = generateShortCode();
let created:
| { id: string; token: string; code: string | null; expiresAt: Date }
| undefined;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const rows = await db
.insert(invite)
.values({
meshId,
token,
tokenBytes: canonical,
code,
maxUses: input.maxUses,
role: input.role,
expiresAt,
createdBy: userId,
})
.returning({
id: invite.id,
token: invite.token,
code: invite.code,
expiresAt: invite.expiresAt,
});
created = rows[0];
break;
} catch (e) {
// Only retry on short-code collision; rethrow anything else.
if (e instanceof Error && e.message.includes("invite_code_unique_idx")) {
code = generateShortCode();
continue;
}
throw e;
}
}
if (!created) {
throw new Error("Could not allocate a unique invite code — retry.");
}
const appBase = APP_URL.replace(/\/$/, "");
return {
id: created!.id,
token: created!.token,
expiresAt: created!.expiresAt,
id: created.id,
token: created.token,
code: created.code,
expiresAt: created.expiresAt,
inviteLink: `ic://join/${token}`,
joinUrl: `${APP_URL.replace(/\/$/, "")}/join/${token}`,
joinUrl: `${appBase}/join/${token}`,
// The human-friendly short URL. Redirects to joinUrl server-side.
// Prefer this when sharing. See spec for why this is NOT a capability
// boundary (the long token still carries the root_key).
shortUrl: created.code ? `${appBase}/i/${created.code}` : null,
};
};

View File

@@ -232,6 +232,29 @@ export const publicRouter = new Hono()
}
return c.json(result);
})
/**
* Resolve a short invite code to its canonical long token.
*
* URL shortener only — the long token still carries the root_key,
* so this endpoint is NOT a security boundary. See the v2 invite
* protocol spec for the real fix.
*
* Returns 404 if the code is unknown OR the invite was revoked/
* archived so stale short URLs don't leak mesh metadata.
*/
.get("/invite-code/:code", async (c) => {
const code = c.req.param("code");
const [row] = await db
.select({ token: invite.token, revokedAt: invite.revokedAt })
.from(invite)
.where(eq(invite.code, code))
.limit(1);
c.header("cache-control", "no-store");
if (!row || row.revokedAt) {
return c.json({ found: false as const }, 404);
}
return c.json({ found: true as const, token: row.token });
})
.get("/stats", async (c) => {
const now = Date.now();
if (cachedStats && cachedStats.expiresAt > now) {

View File

@@ -54,11 +54,6 @@ export type GetMyMeshesResponse = z.infer<typeof getMyMeshesResponseSchema>;
export const createMyMeshInputSchema = z.object({
name: z.string().min(2).max(80),
slug: z
.string()
.min(2)
.max(40)
.regex(/^[a-z0-9-]+$/, "slug must be lowercase letters, digits, hyphens"),
visibility: meshVisibilityEnum.default("private"),
transport: meshTransportEnum.default("managed"),
});
@@ -130,8 +125,10 @@ export type CreateMyInviteInput = z.infer<typeof createMyInviteInputSchema>;
export const createMyInviteResponseSchema = z.object({
id: z.string(),
token: z.string(),
code: z.string().nullable(),
inviteLink: z.string(),
joinUrl: z.string(),
shortUrl: z.string().nullable(),
expiresAt: z.coerce.date(),
});
export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema>;

View File

@@ -22,6 +22,11 @@ const apiErrorSchema = z.object({
path: z.string(),
});
/** Matches the `{ error: "..." }` shape returned by Hono route catch blocks. */
const routeErrorSchema = z.object({
error: z.string(),
});
export const isAPIError = (e: unknown): e is z.infer<typeof apiErrorSchema> => {
return apiErrorSchema.safeParse(e).success;
};
@@ -70,10 +75,13 @@ export const handle = <
if (!response.ok) {
if (throwOnError) {
const parsed = routeErrorSchema.safeParse(data);
throw new Error(
isAPIError(data)
? data.message
: "Something went wrong. Please try again later.",
: parsed.success
? parsed.data.error
: "Something went wrong. Please try again later.",
);
}
return null as HandleReturn<TResponse, E, S>;

View File

@@ -69,6 +69,7 @@ export const onError = async (
code: "common:error.general",
message: t("common:error.general"),
status,
timestamp,
path,
}),
details,