feat(web): public /join/[token] page + https invite url
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:
@@ -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}`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user