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>
255 lines
7.5 KiB
TypeScript
255 lines
7.5 KiB
TypeScript
import * as z from "zod";
|
|
|
|
import {
|
|
offsetPaginationSchema,
|
|
sortSchema,
|
|
} from "@turbostarter/shared/schema";
|
|
|
|
export const meshVisibilityEnum = z.enum(["private", "public"]);
|
|
export const meshTransportEnum = z.enum([
|
|
"managed",
|
|
"tailscale",
|
|
"self_hosted",
|
|
]);
|
|
export const meshRoleEnum = z.enum(["admin", "member"]);
|
|
|
|
// ---------------------------------------------------------------------
|
|
// List my meshes
|
|
// ---------------------------------------------------------------------
|
|
|
|
export const getMyMeshesInputSchema = offsetPaginationSchema.extend({
|
|
sort: z
|
|
.string()
|
|
.transform((val) =>
|
|
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
|
|
)
|
|
.optional(),
|
|
q: z.string().optional(),
|
|
});
|
|
export type GetMyMeshesInput = z.infer<typeof getMyMeshesInputSchema>;
|
|
|
|
export const getMyMeshesResponseSchema = z.object({
|
|
data: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
slug: z.string(),
|
|
visibility: meshVisibilityEnum,
|
|
transport: meshTransportEnum,
|
|
tier: z.enum(["free", "pro", "team", "enterprise"]),
|
|
createdAt: z.coerce.date(),
|
|
archivedAt: z.coerce.date().nullable(),
|
|
myRole: meshRoleEnum,
|
|
isOwner: z.boolean(),
|
|
memberCount: z.number(),
|
|
}),
|
|
),
|
|
total: z.number(),
|
|
});
|
|
export type GetMyMeshesResponse = z.infer<typeof getMyMeshesResponseSchema>;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Create mesh
|
|
// ---------------------------------------------------------------------
|
|
|
|
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"),
|
|
});
|
|
export type CreateMyMeshInput = z.infer<typeof createMyMeshInputSchema>;
|
|
|
|
export const createMyMeshResponseSchema = z.object({
|
|
id: z.string(),
|
|
slug: z.string(),
|
|
});
|
|
export type CreateMyMeshResponse = z.infer<typeof createMyMeshResponseSchema>;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Single mesh (user view)
|
|
// ---------------------------------------------------------------------
|
|
|
|
export const getMyMeshResponseSchema = z.object({
|
|
mesh: z
|
|
.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
slug: z.string(),
|
|
visibility: meshVisibilityEnum,
|
|
transport: meshTransportEnum,
|
|
tier: z.enum(["free", "pro", "team", "enterprise"]),
|
|
maxPeers: z.number().nullable(),
|
|
createdAt: z.coerce.date(),
|
|
archivedAt: z.coerce.date().nullable(),
|
|
isOwner: z.boolean(),
|
|
myRole: meshRoleEnum,
|
|
})
|
|
.nullable(),
|
|
members: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
displayName: z.string(),
|
|
role: meshRoleEnum,
|
|
joinedAt: z.coerce.date(),
|
|
lastSeenAt: z.coerce.date().nullable(),
|
|
revokedAt: z.coerce.date().nullable(),
|
|
isMe: z.boolean(),
|
|
}),
|
|
),
|
|
invites: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
token: z.string(),
|
|
maxUses: z.number(),
|
|
usedCount: z.number(),
|
|
role: meshRoleEnum,
|
|
expiresAt: z.coerce.date(),
|
|
createdAt: z.coerce.date(),
|
|
revokedAt: z.coerce.date().nullable(),
|
|
}),
|
|
),
|
|
});
|
|
export type GetMyMeshResponse = z.infer<typeof getMyMeshResponseSchema>;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Generate invite
|
|
// ---------------------------------------------------------------------
|
|
|
|
export const createMyInviteInputSchema = z.object({
|
|
role: meshRoleEnum.default("member"),
|
|
maxUses: z.number().int().min(1).max(1000).default(1),
|
|
expiresInDays: z.number().int().min(1).max(365).default(7),
|
|
});
|
|
export type CreateMyInviteInput = z.infer<typeof createMyInviteInputSchema>;
|
|
|
|
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>;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// List my invites (pending + sent)
|
|
// ---------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Live mesh stream (presences + recent envelopes + recent audit events)
|
|
// ---------------------------------------------------------------------
|
|
|
|
export const getMyMeshStreamResponseSchema = z.object({
|
|
presences: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
memberId: z.string(),
|
|
displayName: z.string().nullable(),
|
|
sessionId: z.string(),
|
|
pid: z.number(),
|
|
cwd: z.string(),
|
|
status: z.enum(["idle", "working", "dnd"]),
|
|
statusSource: z.enum(["hook", "manual", "jsonl"]),
|
|
statusUpdatedAt: z.coerce.date(),
|
|
lastPingAt: z.coerce.date(),
|
|
disconnectedAt: z.coerce.date().nullable(),
|
|
}),
|
|
),
|
|
envelopes: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
senderMemberId: z.string(),
|
|
senderDisplayName: z.string().nullable(),
|
|
targetSpec: z.string(),
|
|
priority: z.enum(["now", "next", "low"]),
|
|
ciphertextPreview: z.string(),
|
|
size: z.number(),
|
|
createdAt: z.coerce.date(),
|
|
deliveredAt: z.coerce.date().nullable(),
|
|
}),
|
|
),
|
|
auditEvents: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
eventType: z.string(),
|
|
actorPeerId: z.string().nullable(),
|
|
targetPeerId: z.string().nullable(),
|
|
createdAt: z.coerce.date(),
|
|
}),
|
|
),
|
|
});
|
|
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)
|
|
// ---------------------------------------------------------------------
|
|
|
|
export const publicStatsResponseSchema = z.object({
|
|
messagesRouted: z.number(),
|
|
meshesCreated: z.number(),
|
|
peersActive: z.number(),
|
|
lastUpdated: z.string(),
|
|
});
|
|
export type PublicStatsResponse = z.infer<typeof publicStatsResponseSchema>;
|
|
|
|
export const getMyInvitesResponseSchema = z.object({
|
|
sent: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
meshId: z.string(),
|
|
meshName: z.string().nullable(),
|
|
meshSlug: z.string().nullable(),
|
|
token: z.string(),
|
|
role: meshRoleEnum,
|
|
maxUses: z.number(),
|
|
usedCount: z.number(),
|
|
expiresAt: z.coerce.date(),
|
|
createdAt: z.coerce.date(),
|
|
revokedAt: z.coerce.date().nullable(),
|
|
}),
|
|
),
|
|
});
|
|
export type GetMyInvitesResponse = z.infer<typeof getMyInvitesResponseSchema>;
|