Files
claudemesh/packages/api/src/schema/mesh-user.ts
Alejandro Gutiérrez 6acfc252b0
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
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>
2026-04-05 16:36:24 +01:00

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>;