feat(web): public /join/[token] page + https invite url
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Clickable HTTPS invite URLs replace the raw ic://join/<token> as the
primary share format. Someone receiving a link in Slack now lands on
a friendly page with install instructions, not a dead-end.

Backend:
- createMyInvite returns a new joinUrl field
  (https://claudemesh.com/join/<token>) alongside the existing
  ic://join/<token> inviteLink and raw token. Schema + Hono route
  updated. ic:// scheme stays — CLI parses both.
- New GET /api/public/invite/:token in packages/api/src/modules/public/
  (unauthed). Decodes the base64url payload, verifies ed25519
  signature against owner_pubkey using the same canonicalInvite()
  contract the broker enforces on join, then joins mesh/invite/user
  to return the shape needed by the landing page. Does NOT mutate
  usedCount — this is a read-only preview.
- Error taxonomy: malformed | bad_signature | expired | revoked |
  exhausted | mesh_archived | not_found. Each returned with any
  metadata we CAN surface (meshName, inviterName, expiresAt) so the
  error page can be specific ("ask Jordan for a new one").
- cache-control: public max-age=30 on valid invites, no-store on
  errors (reasons flip as state changes).

Frontend:
- New public route /[locale]/join/[token] (no auth). Server
  Component fetches the preview endpoint, branches on valid/invalid,
  renders a minimal landing-design-language shell (wordmark header,
  clay accents, serif headlines, mono commands).
- Valid-invite view: "You're invited to {meshName}", inviter +
  role + member-count lede, install-toggle component.
- Invalid-invite view: per-reason error copy + inviter name when
  available + link back to /.
- InstallToggle client component: three-way state
  (unknown/yes/no). Asks "first time / already set up?", then shows
  either the 3-step install+init+join path with per-step copy
  buttons, or the single claudemesh join <token> command for users
  who have the CLI. Every code block has copy-to-clipboard.
- Security footer: "ed25519 keypair generated locally, you keep
  your keys, broker sees ciphertext only, leave anytime with
  claudemesh leave <mesh-slug>".

Invite generator (/dashboard/meshes/[id]/invite):
- QR code now encodes the HTTPS joinUrl instead of ic:// (phone
  cameras land on the web page → friendly path).
- Primary CTA copies the HTTPS URL. Secondary "Copy CLI command"
  for fast-path users. Footer explanation updated.

CLI coordination note: dispatched to broker/db lane — claudemesh CLI
needs to accept BOTH ic://join/<token> AND
https://claudemesh.com/join/<token> (extract <token> from pathname).
Server side already returns both.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-05 16:36:24 +01:00
parent 59e999535d
commit 6acfc252b0
6 changed files with 663 additions and 20 deletions

View File

@@ -10,6 +10,7 @@ import type {
} from "../../schema";
const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_URL ?? "ws://localhost:7900";
const APP_URL = process.env.NEXT_PUBLIC_URL ?? "https://claudemesh.com";
/**
* Canonical invite bytes — MUST match the broker's canonicalInvite()
@@ -236,5 +237,6 @@ export const createMyInvite = async ({
token: created!.token,
expiresAt: created!.expiresAt,
inviteLink: `ic://join/${token}`,
joinUrl: `${APP_URL.replace(/\/$/, "")}/join/${token}`,
};
};

View File

@@ -1,7 +1,15 @@
import { Hono } from "hono";
import sodium from "libsodium-wrappers";
import { count, isNull } from "@turbostarter/db";
import { mesh, messageQueue, presence } from "@turbostarter/db/schema";
import { count, eq, isNull, sql } from "@turbostarter/db";
import { user } from "@turbostarter/db/schema";
import {
invite,
mesh,
meshMember,
messageQueue,
presence,
} from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
/**
@@ -42,7 +50,189 @@ const fetchStats = async (): Promise<PublicStats> => {
};
};
export const publicRouter = new Hono().get("/stats", async (c) => {
// ---------------------------------------------------------------------
// Invite preview (read-only, no counter mutation)
// ---------------------------------------------------------------------
interface InvitePayload {
v: number;
mesh_id: string;
mesh_slug: string;
broker_url: string;
expires_at: number;
mesh_root_key: string;
role: "admin" | "member";
owner_pubkey: string;
signature?: string;
}
const canonicalInvite = (p: Omit<InvitePayload, "signature">): string =>
`${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`;
let sodiumReady = false;
const ensureSodium = async () => {
if (!sodiumReady) {
await sodium.ready;
sodiumReady = true;
}
return sodium;
};
const decodeInviteToken = (
token: string,
): InvitePayload | null => {
try {
const json = Buffer.from(token, "base64url").toString("utf-8");
const obj = JSON.parse(json) as unknown;
if (
typeof obj !== "object" ||
obj === null ||
!("mesh_id" in obj) ||
!("signature" in obj)
) {
return null;
}
return obj as InvitePayload;
} catch {
return null;
}
};
// Invite preview handler — route is mounted below alongside /stats.
const inviteHandler = async (rawToken: string) => {
const payload = decodeInviteToken(rawToken);
if (!payload || !payload.signature) {
return {
valid: false as const,
reason: "malformed" as const,
meshName: null,
inviterName: null,
expiresAt: null,
};
}
// Verify ed25519 signature matches owner_pubkey from payload
const s = await ensureSodium();
let sigValid = false;
try {
sigValid = s.crypto_sign_verify_detached(
s.from_hex(payload.signature),
s.from_string(canonicalInvite(payload)),
s.from_hex(payload.owner_pubkey),
);
} catch {
sigValid = false;
}
if (!sigValid) {
return {
valid: false as const,
reason: "bad_signature" as const,
meshName: null,
inviterName: null,
expiresAt: null,
};
}
// DB lookup — mesh + invite row + inviter
const [row] = await db
.select({
inviteId: invite.id,
maxUses: invite.maxUses,
usedCount: invite.usedCount,
role: invite.role,
expiresAt: invite.expiresAt,
revokedAt: invite.revokedAt,
meshId: mesh.id,
meshName: mesh.name,
meshSlug: mesh.slug,
meshArchivedAt: mesh.archivedAt,
inviterName: user.name,
})
.from(invite)
.leftJoin(mesh, eq(invite.meshId, mesh.id))
.leftJoin(user, eq(invite.createdBy, user.id))
.where(eq(invite.token, rawToken))
.limit(1);
if (!row || !row.meshId) {
return {
valid: false as const,
reason: "not_found" as const,
meshName: null,
inviterName: null,
expiresAt: null,
};
}
if (row.revokedAt) {
return {
valid: false as const,
reason: "revoked" as const,
meshName: row.meshName,
inviterName: row.inviterName,
expiresAt: row.expiresAt,
};
}
if (row.meshArchivedAt) {
return {
valid: false as const,
reason: "mesh_archived" as const,
meshName: row.meshName,
inviterName: row.inviterName,
expiresAt: row.expiresAt,
};
}
if (row.expiresAt < new Date()) {
return {
valid: false as const,
reason: "expired" as const,
meshName: row.meshName,
inviterName: row.inviterName,
expiresAt: row.expiresAt,
};
}
if (row.usedCount >= row.maxUses) {
return {
valid: false as const,
reason: "exhausted" as const,
meshName: row.meshName,
inviterName: row.inviterName,
expiresAt: row.expiresAt,
};
}
// Count active members
const [memberCountRow] = await db
.select({ c: sql<number>`COUNT(*)::int` })
.from(meshMember)
.where(eq(meshMember.meshId, row.meshId));
return {
valid: true as const,
meshName: row.meshName ?? "",
meshSlug: row.meshSlug ?? "",
inviterName: row.inviterName,
memberCount: memberCountRow?.c ?? 0,
role: row.role,
expiresAt: row.expiresAt,
maxUses: row.maxUses,
usedCount: row.usedCount,
token: rawToken,
};
};
export const publicRouter = new Hono()
.get("/invite/:token", async (c) => {
const result = await inviteHandler(c.req.param("token"));
// Small cache on valid invites, no cache on errors (reason can change)
if (result.valid) {
c.header("cache-control", "public, max-age=30");
} else {
c.header("cache-control", "no-store");
}
return c.json(result);
})
.get("/stats", async (c) => {
const now = Date.now();
if (cachedStats && cachedStats.expiresAt > now) {
c.header("x-cache", "HIT");

View File

@@ -131,6 +131,7 @@ export const createMyInviteResponseSchema = z.object({
id: z.string(),
token: z.string(),
inviteLink: z.string(),
joinUrl: z.string(),
expiresAt: z.coerce.date(),
});
export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema>;
@@ -186,6 +187,41 @@ export type GetMyMeshStreamResponse = z.infer<
typeof getMyMeshStreamResponseSchema
>;
// ---------------------------------------------------------------------
// Public invite preview (unauthed invite-landing page)
// ---------------------------------------------------------------------
export const publicInviteResponseSchema = z.discriminatedUnion("valid", [
z.object({
valid: z.literal(true),
meshName: z.string(),
meshSlug: z.string(),
inviterName: z.string().nullable(),
memberCount: z.number(),
role: z.enum(["admin", "member"]),
expiresAt: z.coerce.date(),
maxUses: z.number(),
usedCount: z.number(),
token: z.string(),
}),
z.object({
valid: z.literal(false),
reason: z.enum([
"malformed",
"bad_signature",
"expired",
"revoked",
"exhausted",
"mesh_archived",
"not_found",
]),
meshName: z.string().nullable(),
inviterName: z.string().nullable(),
expiresAt: z.coerce.date().nullable(),
}),
]);
export type PublicInviteResponse = z.infer<typeof publicInviteResponseSchema>;
// ---------------------------------------------------------------------
// Public stats (unauthed landing counter)
// ---------------------------------------------------------------------