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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -69,6 +69,7 @@ export const onError = async (
|
||||
code: "common:error.general",
|
||||
message: t("common:error.general"),
|
||||
status,
|
||||
timestamp,
|
||||
path,
|
||||
}),
|
||||
details,
|
||||
|
||||
11
packages/db/migrations/0017_mesh-slug-non-unique.sql
Normal file
11
packages/db/migrations/0017_mesh-slug-non-unique.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Drop global uniqueness on mesh.slug.
|
||||
--
|
||||
-- Identity for a mesh is mesh.id (opaque, generated). The slug is now
|
||||
-- cosmetic only — derived from the display name at creation time and
|
||||
-- embedded in invite payloads for debugging/display. Two meshes may
|
||||
-- freely share a slug.
|
||||
--
|
||||
-- Safe to run on populated tables: the constraint is removed, no data
|
||||
-- is altered, no rows are locked for content changes.
|
||||
|
||||
ALTER TABLE "mesh"."mesh" DROP CONSTRAINT IF EXISTS "mesh_slug_unique";
|
||||
20
packages/db/migrations/0018_invite-short-code.sql
Normal file
20
packages/db/migrations/0018_invite-short-code.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Add a short opaque URL-shortener code to mesh invites.
|
||||
--
|
||||
-- Purpose: make invite URLs human-friendly (claudemesh.com/i/abc12345)
|
||||
-- instead of ~400 char base64url payloads. The short code resolves
|
||||
-- server-side to the existing long token — the broker protocol and
|
||||
-- canonical signed payload are UNCHANGED.
|
||||
--
|
||||
-- This is NOT the v2 invite protocol (see spec
|
||||
-- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md).
|
||||
-- It is a backward-compatible URL shortener only. The root_key is
|
||||
-- still embedded in the underlying long token; v2 will address that
|
||||
-- in a coordinated broker + CLI + web change.
|
||||
--
|
||||
-- Column is nullable so existing invites remain valid without backfill.
|
||||
|
||||
ALTER TABLE "mesh"."invite" ADD COLUMN IF NOT EXISTS "code" text;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "invite_code_unique_idx"
|
||||
ON "mesh"."invite" ("code")
|
||||
WHERE "code" IS NOT NULL;
|
||||
54
packages/db/migrations/0019_invite-v2-and-email.sql
Normal file
54
packages/db/migrations/0019_invite-v2-and-email.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- v2 invite protocol + email invites.
|
||||
--
|
||||
-- Spec: .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
|
||||
--
|
||||
-- Two concerns in one migration (both touch the invite surface):
|
||||
--
|
||||
-- 1. v2 invite protocol — the mesh root_key no longer travels in the
|
||||
-- invite URL. Instead the recipient generates a curve25519 keypair at
|
||||
-- claim time and sends the pubkey to the broker; the broker seals
|
||||
-- root_key with crypto_box_seal to that pubkey. The DB captures the
|
||||
-- protocol version, the canonical signed bytes that the broker
|
||||
-- re-verifies against mesh.owner_pubkey, and an audit-only record of
|
||||
-- which recipient pubkey received the sealed key.
|
||||
--
|
||||
-- 2. Email invites — admins can send invites to an email address. A
|
||||
-- pending_invite row tracks the send; when the recipient lands on
|
||||
-- /i/{code} it is matched to an underlying mesh.invite row (mint on
|
||||
-- send). acceptedAt / revokedAt capture lifecycle.
|
||||
--
|
||||
-- Both additions are backward-compatible: version defaults to 1, new
|
||||
-- columns are nullable, the new table is independent of existing rows.
|
||||
|
||||
ALTER TABLE "mesh"."invite"
|
||||
ADD COLUMN IF NOT EXISTS "version" integer NOT NULL DEFAULT 1;
|
||||
|
||||
ALTER TABLE "mesh"."invite"
|
||||
ADD COLUMN IF NOT EXISTS "capability_v2" text;
|
||||
|
||||
ALTER TABLE "mesh"."invite"
|
||||
ADD COLUMN IF NOT EXISTS "claimed_by_pubkey" text;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mesh"."pending_invite" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"mesh_id" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"sent_at" timestamp DEFAULT now() NOT NULL,
|
||||
"accepted_at" timestamp,
|
||||
"revoked_at" timestamp,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pending_invite_mesh_id_fk"
|
||||
FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "pending_invite_created_by_fk"
|
||||
FOREIGN KEY ("created_by") REFERENCES "public"."user"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "pending_invite_email_idx"
|
||||
ON "mesh"."pending_invite" ("email");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "pending_invite_mesh_idx"
|
||||
ON "mesh"."pending_invite" ("mesh_id");
|
||||
@@ -78,7 +78,13 @@ export const messagePriorityEnum = meshSchema.enum("message_priority", [
|
||||
export const mesh = meshSchema.table("mesh", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
name: text().notNull(),
|
||||
slug: text().notNull().unique(),
|
||||
/**
|
||||
* Cosmetic slug derived from name at creation. NOT unique, NOT used for
|
||||
* identity — `mesh.id` is the canonical identifier everywhere (URLs,
|
||||
* invites, broker lookups). Kept for display/debugging only. Two meshes
|
||||
* can freely share a slug.
|
||||
*/
|
||||
slug: text().notNull(),
|
||||
ownerUserId: text()
|
||||
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
@@ -176,6 +182,15 @@ export const invite = meshSchema.table("invite", {
|
||||
.notNull(),
|
||||
token: text().notNull().unique(),
|
||||
tokenBytes: text(),
|
||||
/**
|
||||
* Short opaque URL shortener code (base62, 8 chars). Resolves server-side
|
||||
* to the full canonical `token` for landing page rendering. Nullable for
|
||||
* pre-shortcode invites. Not a capability boundary — the long token still
|
||||
* carries the root_key. See .artifacts/specs/2026-04-10-anthropic-vision-
|
||||
* meshes-invites.md for the v2 protocol that moves the root_key out of
|
||||
* the URL entirely.
|
||||
*/
|
||||
code: text().unique(),
|
||||
maxUses: integer().notNull().default(1),
|
||||
usedCount: integer().notNull().default(0),
|
||||
role: meshRoleEnum().notNull().default("member"),
|
||||
@@ -192,8 +207,47 @@ export const invite = meshSchema.table("invite", {
|
||||
.notNull(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
revokedAt: timestamp(),
|
||||
/** Protocol version — 1 = legacy (root_key in URL), 2 = sealed delivery. Default 1 for backward compat. */
|
||||
version: integer().notNull().default(1),
|
||||
/**
|
||||
* v2 canonical signed bytes (the string the broker re-verifies against mesh.ownerPubkey).
|
||||
* Format: `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey`
|
||||
* Nullable for legacy v1 rows.
|
||||
*/
|
||||
capabilityV2: text(),
|
||||
/**
|
||||
* Recipient curve25519 pubkey (base64url) that the mesh root_key was sealed to
|
||||
* when this invite was claimed. Audit-only — do NOT use as an authN check.
|
||||
* Nullable until claim.
|
||||
*/
|
||||
claimedByPubkey: text(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Tracks invites sent by email — one row per (mesh, email) pairing.
|
||||
* `code` references an underlying mesh.invite row that will be minted
|
||||
* on send; when the recipient lands on /i/{code} they claim the real invite.
|
||||
*/
|
||||
export const pendingInvite = meshSchema.table("pending_invite", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
email: text().notNull(),
|
||||
/** The short code of the underlying `mesh.invite.code` row this email links to. */
|
||||
code: text().notNull(),
|
||||
sentAt: timestamp().defaultNow().notNull(),
|
||||
acceptedAt: timestamp(),
|
||||
revokedAt: timestamp(),
|
||||
createdBy: text()
|
||||
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index("pending_invite_email_idx").on(table.email),
|
||||
index("pending_invite_mesh_idx").on(table.meshId),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Signed, hash-chained audit log. NEVER stores message content — every
|
||||
* payload between peers is E2E encrypted client-side (libsodium), so
|
||||
@@ -687,6 +741,11 @@ export const inviteRelations = relations(invite, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const pendingInviteRelations = relations(pendingInvite, ({ one }) => ({
|
||||
mesh: one(mesh, { fields: [pendingInvite.meshId], references: [mesh.id] }),
|
||||
inviter: one(user, { fields: [pendingInvite.createdBy], references: [user.id] }),
|
||||
}));
|
||||
|
||||
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [auditLog.meshId],
|
||||
|
||||
Reference in New Issue
Block a user