Files
claudemesh/packages/api/src/schema/mesh-user.ts
Alejandro Gutiérrez fb7a84aed6 feat: v2 invite API + CLI claim flow + CLI friction reducer (wave 2)
Wires the v2 invite protocol end-to-end from a CLI user's perspective.
Broker foundation landed in c1fa3bc; this commit is the glue between
it and the human.

API (packages/api)
- createMyInvite now mints BOTH v1 token (legacy) AND v2 capability.
  Two-phase insert: row first (to get invite.id), then UPDATE with
  signed canonical bytes stored as JSON {canonical, signature} in the
  capabilityV2 column. Broker's claim handler parses the same shape.
- canonicalInviteV2 locked to `v=2|mesh_id|invite_id|expires_at|role|
  owner_pubkey_hex` — byte-identical to apps/broker/src/crypto.ts.
- brokerHttpBase() helper rewrites wss://host/ws → https://host for
  server-to-server calls.
- POST /api/public/invites/:code/claim — thin proxy to broker;
  passes status + body through, 502 broker_unreachable on fetch fail,
  cache-control: no-store.
- POST /api/my/meshes/:id/invites/email — mints a normal v2 invite
  via createMyInvite, records a pending_invite row, calls stubbed
  sendEmailInvite (logs TODO for Postmark wiring in a later PR).
- New schemas: claimInviteInput/ResponseSchema,
  createEmailInviteInput/ResponseSchema, v2 fields on
  createMyInviteResponseSchema.
- v1 paths untouched — legacy /join/[token] and /api/public/invite/:token
  continue to work throughout v0.1.x.

CLI (apps/cli)
- New `claudemesh join <code-or-url>` subcommand.
- Accepts bare code (abc12345), short URL (claudemesh.com/i/abc12345),
  or legacy ic://join/<token>. Detects v2 vs v1 and dispatches.
- v2 path: generates fresh ephemeral x25519 keypair (separate from
  the ed25519 identity) → POST /api/public/invites/:code/claim →
  unseals sealed_root_key via crypto_box_seal_open → persists mesh
  with inviteVersion: 2 and base64url rootKey to local config.
- Signature verification skipped with TODO — v0.1.x trusts broker;
  seal-open is already authenticated.
- apps/cli/src/lib/invite-v2.ts: generateX25519Keypair, claimInviteV2,
  parseV2InviteInput.
- state/config.ts: additive rootKey?/inviteVersion? fields.

CLI friction reducer
- apps/cli/src/index.ts: flag-first invocations
  (`claudemesh --resume xxx`, `claudemesh -c`, `claudemesh -- --model
  opus`) now route through `launch` automatically. Bare `claudemesh`
  still shows welcome; known subcommands dispatch normally.
- Removes one word of cognitive load: users never type `launch`.

No schema changes. No new deps. v1 fully backward compatible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:35:21 +01:00

299 lines
9.2 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),
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(),
code: z.string().nullable(),
inviteLink: z.string(),
joinUrl: z.string(),
shortUrl: z.string().nullable(),
expiresAt: z.coerce.date(),
// v2 fields — present on every new invite. v1-only rows will return
// these as undefined on the legacy list endpoint; new rows always set
// them because createMyInvite now mints v2 capabilities by default.
version: z.literal(2).optional(),
canonicalV2: z.string().optional(),
ownerPubkey: z.string().optional(),
});
export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema>;
// ---------------------------------------------------------------------
// Email invites
// ---------------------------------------------------------------------
export const createEmailInviteInputSchema = z.object({
email: z.string().email(),
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 CreateEmailInviteInput = z.infer<typeof createEmailInviteInputSchema>;
export const createEmailInviteResponseSchema = z.object({
pendingInviteId: z.string(),
code: z.string(),
email: z.string(),
shortUrl: z.string(),
expiresAt: z.coerce.date(),
});
export type CreateEmailInviteResponse = z.infer<
typeof createEmailInviteResponseSchema
>;
// ---------------------------------------------------------------------
// v2 invite claim (public, proxies to broker)
// ---------------------------------------------------------------------
export const claimInviteInputSchema = z.object({
recipient_x25519_pubkey: z.string().min(32),
});
export type ClaimInviteInput = z.infer<typeof claimInviteInputSchema>;
export const claimInviteResponseSchema = z.object({
sealed_root_key: z.string(),
mesh_id: z.string(),
member_id: z.string(),
owner_pubkey: z.string(),
canonical_v2: z.string(),
});
export type ClaimInviteResponse = z.infer<typeof claimInviteResponseSchema>;
// ---------------------------------------------------------------------
// 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>;