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:
@@ -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)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user