feat(api): mesh user router — create, list, invite, archive, leave
New /my/* Hono router scoped by session.user.id. User can only see meshes they own OR have a non-revoked meshMember row for. All 7 endpoints guard authz at the query level (ownerUserId = userId OR EXISTS membership). - GET /my/meshes — paginated list with myRole, isOwner, memberCount - POST /my/meshes — create mesh (slug collision check, returns id + slug) - GET /my/meshes/:id — detail (mesh + members + invites) - POST /my/meshes/:id/invites — generate ic://join/<base64url(JSON)> link. Matches apps/cli/src/invite/parse.ts format exactly. mesh_root_key is a deterministic sha256(mesh.id:slug) placeholder until Step 18 ed25519 signing lands. - POST /my/meshes/:id/archive — owner-only - POST /my/meshes/:id/leave — member self-removal (sets revokedAt) - GET /my/invites — list invites this user has issued Schemas live in packages/api/src/schema/mesh-user.ts. All enums mirror the DB enums from packages/db/src/schema/mesh.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export * from "./admin";
|
||||
export * from "./mesh-admin";
|
||||
export * from "./mesh-user";
|
||||
export * from "./organization";
|
||||
|
||||
159
packages/api/src/schema/mesh-user.ts
Normal file
159
packages/api/src/schema/mesh-user.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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(),
|
||||
expiresAt: z.coerce.date(),
|
||||
});
|
||||
export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// List my invites (pending + sent)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
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>;
|
||||
Reference in New Issue
Block a user