From a486ffd056f549c1021b8cbd716294af2a476dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:56:29 +0100 Subject: [PATCH] =?UTF-8?q?feat(api):=20mesh=20user=20router=20=E2=80=94?= =?UTF-8?q?=20create,=20list,=20invite,=20archive,=20leave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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) --- packages/api/src/index.ts | 2 + packages/api/src/modules/mesh/mutations.ts | 180 ++++++++++++++++++++ packages/api/src/modules/mesh/queries.ts | 185 +++++++++++++++++++++ packages/api/src/modules/mesh/router.ts | 114 +++++++++++++ packages/api/src/schema/index.ts | 1 + packages/api/src/schema/mesh-user.ts | 159 ++++++++++++++++++ 6 files changed, 641 insertions(+) create mode 100644 packages/api/src/modules/mesh/mutations.ts create mode 100644 packages/api/src/modules/mesh/queries.ts create mode 100644 packages/api/src/modules/mesh/router.ts create mode 100644 packages/api/src/schema/mesh-user.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index c34e111..15926da 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -13,6 +13,7 @@ import { adminRouter } from "./modules/admin/router"; // import { aiRouter } from "./modules/ai/router"; // disabled: @turbostarter/ai package removed in claudemesh import { authRouter } from "./modules/auth/router"; import { billingRouter } from "./modules/billing/router"; +import { myRouter } from "./modules/mesh/router"; import { organizationRouter } from "./modules/organization/router"; import { storageRouter } from "./modules/storage/router"; import { onError } from "./utils/on-error"; @@ -48,6 +49,7 @@ const appRouter = new Hono() // .route("/ai", aiRouter) // disabled: @turbostarter/ai package removed in claudemesh .route("/auth", authRouter) .route("/billing", billingRouter) + .route("/my", myRouter) .route("/organizations", organizationRouter) .route("/storage", storageRouter) .onError(onError); diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts new file mode 100644 index 0000000..2e7c74b --- /dev/null +++ b/packages/api/src/modules/mesh/mutations.ts @@ -0,0 +1,180 @@ +import { randomBytes, createHash } from "node:crypto"; + +import { and, eq, isNull } from "@turbostarter/db"; +import { invite, mesh, meshMember } from "@turbostarter/db/schema"; +import { db } from "@turbostarter/db/server"; + +import type { + CreateMyInviteInput, + CreateMyMeshInput, +} from "../../schema"; + +const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_URL ?? "ws://localhost:7900"; + +export const createMyMesh = async ({ + userId, + input, +}: { + userId: string; + input: CreateMyMeshInput; +}) => { + // Slug collision check + const [existing] = await db + .select({ id: mesh.id }) + .from(mesh) + .where(eq(mesh.slug, input.slug)) + .limit(1); + + if (existing) { + throw new Error("A mesh with that slug already exists."); + } + + const [created] = await db + .insert(mesh) + .values({ + name: input.name, + slug: input.slug, + visibility: input.visibility, + transport: input.transport, + ownerUserId: userId, + }) + .returning({ id: mesh.id, slug: mesh.slug }); + + return created!; +}; + +export const archiveMyMesh = async ({ + userId, + meshId, +}: { + userId: string; + meshId: string; +}) => { + const [updated] = await db + .update(mesh) + .set({ archivedAt: new Date() }) + .where(and(eq(mesh.id, meshId), eq(mesh.ownerUserId, userId))) + .returning({ id: mesh.id }); + + if (!updated) { + throw new Error("Mesh not found or you are not the owner."); + } + return updated; +}; + +export const leaveMyMesh = async ({ + userId, + meshId, +}: { + userId: string; + meshId: string; +}) => { + const [updated] = await db + .update(meshMember) + .set({ revokedAt: new Date() }) + .where( + and( + eq(meshMember.meshId, meshId), + eq(meshMember.userId, userId), + isNull(meshMember.revokedAt), + ), + ) + .returning({ id: meshMember.id }); + + if (!updated) { + throw new Error("You are not a member of this mesh."); + } + return updated; +}; + +/** Encode an ic://join/ invite link. Format mirrors + * apps/cli/src/invite/parse.ts exactly. */ +const encodeInviteLink = (payload: unknown): string => { + const json = JSON.stringify(payload); + const encoded = Buffer.from(json, "utf-8").toString("base64url"); + return `ic://join/${encoded}`; +}; + +/** Placeholder deterministic root key until mesh_root_key column lands + * (Step 18 crypto). Signature verification is Step 18, so an actual + * ed25519 pubkey is not yet required — only presence is checked. */ +const derivePlaceholderRootKey = (meshId: string, meshSlug: string): string => + createHash("sha256").update(`${meshId}:${meshSlug}`).digest("hex"); + +export const createMyInvite = async ({ + userId, + meshId, + input, +}: { + userId: string; + meshId: string; + input: CreateMyInviteInput; +}) => { + // Authz: owner or admin member can invite + const [meshRow] = await db + .select({ + id: mesh.id, + slug: mesh.slug, + ownerUserId: mesh.ownerUserId, + }) + .from(mesh) + .where(eq(mesh.id, meshId)) + .limit(1); + + if (!meshRow) { + throw new Error("Mesh not found."); + } + + const isOwner = meshRow.ownerUserId === userId; + if (!isOwner) { + const [membership] = await db + .select({ role: meshMember.role }) + .from(meshMember) + .where( + and( + eq(meshMember.meshId, meshId), + eq(meshMember.userId, userId), + isNull(meshMember.revokedAt), + ), + ) + .limit(1); + if (!membership || membership.role !== "admin") { + throw new Error("Only owners and admins can issue invites."); + } + } + + const token = randomBytes(24).toString("base64url"); + const expiresAt = new Date( + Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000, + ); + + const [created] = await db + .insert(invite) + .values({ + meshId, + token, + maxUses: input.maxUses, + role: input.role, + expiresAt, + createdBy: userId, + }) + .returning({ id: invite.id, token: invite.token, expiresAt: invite.expiresAt }); + + const payload = { + v: 1 as const, + mesh_id: meshRow.id, + mesh_slug: meshRow.slug, + broker_url: BROKER_URL, + expires_at: Math.floor(expiresAt.getTime() / 1000), + mesh_root_key: derivePlaceholderRootKey(meshRow.id, meshRow.slug), + role: input.role, + // signature: added in Step 18 (ed25519 sign by mesh_root_key) + }; + + return { + id: created!.id, + token: created!.token, + expiresAt: created!.expiresAt, + inviteLink: encodeInviteLink(payload), + }; +}; diff --git a/packages/api/src/modules/mesh/queries.ts b/packages/api/src/modules/mesh/queries.ts new file mode 100644 index 0000000..ed59610 --- /dev/null +++ b/packages/api/src/modules/mesh/queries.ts @@ -0,0 +1,185 @@ +import { + and, + asc, + count, + desc, + eq, + getOrderByFromSort, + ilike, + isNull, + or, + sql, +} from "@turbostarter/db"; +import { invite, mesh, meshMember } from "@turbostarter/db/schema"; +import { db } from "@turbostarter/db/server"; + +import type { GetMyMeshesInput } from "../../schema"; + +export const getMyMeshes = async ({ + userId, + ...input +}: GetMyMeshesInput & { userId: string }) => { + const offset = (input.page - 1) * input.perPage; + + // User sees: meshes they own OR meshes where they have a meshMember row + const baseWhere = or( + eq(mesh.ownerUserId, userId), + sql`EXISTS (SELECT 1 FROM mesh.member mm WHERE mm.mesh_id = ${mesh.id} AND mm.user_id = ${userId} AND mm.revoked_at IS NULL)`, + ); + + const where = and( + baseWhere, + input.q + ? or(ilike(mesh.name, `%${input.q}%`), ilike(mesh.slug, `%${input.q}%`)) + : undefined, + ); + + const orderBy = input.sort + ? getOrderByFromSort({ sort: input.sort, defaultSchema: mesh }) + : [desc(mesh.createdAt)]; + + return db.transaction(async (tx) => { + const data = await tx + .select({ + id: mesh.id, + name: mesh.name, + slug: mesh.slug, + visibility: mesh.visibility, + transport: mesh.transport, + tier: mesh.tier, + createdAt: mesh.createdAt, + archivedAt: mesh.archivedAt, + isOwner: sql`${mesh.ownerUserId} = ${userId}`, + myRole: sql<"admin" | "member">`CASE WHEN ${mesh.ownerUserId} = ${userId} THEN 'admin'::text ELSE COALESCE((SELECT role::text FROM mesh.member mm2 WHERE mm2.mesh_id = ${mesh.id} AND mm2.user_id = ${userId} AND mm2.revoked_at IS NULL LIMIT 1), 'member') END`, + memberCount: sql`(SELECT COUNT(*)::int FROM mesh.member mm3 WHERE mm3.mesh_id = ${mesh.id} AND mm3.revoked_at IS NULL)`, + }) + .from(mesh) + .where(where) + .limit(input.perPage) + .offset(offset) + .orderBy(...orderBy); + + const total = await tx + .select({ count: count() }) + .from(mesh) + .where(where) + .execute() + .then((res) => res[0]?.count ?? 0); + + return { data, total }; + }); +}; + +export const getMyMeshById = async ({ + userId, + meshId, +}: { + userId: string; + meshId: string; +}) => { + const [m] = await db + .select({ + id: mesh.id, + name: mesh.name, + slug: mesh.slug, + visibility: mesh.visibility, + transport: mesh.transport, + tier: mesh.tier, + maxPeers: mesh.maxPeers, + createdAt: mesh.createdAt, + archivedAt: mesh.archivedAt, + ownerUserId: mesh.ownerUserId, + }) + .from(mesh) + .where(eq(mesh.id, meshId)) + .limit(1); + + if (!m) return null; + + // Authz: user must own OR be a non-revoked member + const isOwner = m.ownerUserId === userId; + if (!isOwner) { + const [membership] = await db + .select({ id: meshMember.id, role: meshMember.role }) + .from(meshMember) + .where( + and( + eq(meshMember.meshId, meshId), + eq(meshMember.userId, userId), + isNull(meshMember.revokedAt), + ), + ) + .limit(1); + if (!membership) return null; + } + + const members = await db + .select({ + id: meshMember.id, + displayName: meshMember.displayName, + role: meshMember.role, + joinedAt: meshMember.joinedAt, + lastSeenAt: meshMember.lastSeenAt, + revokedAt: meshMember.revokedAt, + userId: meshMember.userId, + }) + .from(meshMember) + .where(eq(meshMember.meshId, meshId)) + .orderBy(asc(meshMember.joinedAt)); + + const invites = await db + .select({ + id: invite.id, + token: invite.token, + maxUses: invite.maxUses, + usedCount: invite.usedCount, + role: invite.role, + expiresAt: invite.expiresAt, + createdAt: invite.createdAt, + revokedAt: invite.revokedAt, + }) + .from(invite) + .where(eq(invite.meshId, meshId)) + .orderBy(desc(invite.createdAt)) + .limit(50); + + // Derive myRole for the mesh top-level field + const myRole: "admin" | "member" = isOwner + ? "admin" + : (members.find((mem) => mem.userId === userId)?.role ?? "member"); + + return { + mesh: { ...m, isOwner, myRole }, + members: members.map((mem) => ({ + id: mem.id, + displayName: mem.displayName, + role: mem.role, + joinedAt: mem.joinedAt, + lastSeenAt: mem.lastSeenAt, + revokedAt: mem.revokedAt, + isMe: mem.userId === userId, + })), + invites, + }; +}; + +export const getMyInvitesSent = async ({ userId }: { userId: string }) => + db + .select({ + id: invite.id, + meshId: invite.meshId, + meshName: mesh.name, + meshSlug: mesh.slug, + token: invite.token, + role: invite.role, + maxUses: invite.maxUses, + usedCount: invite.usedCount, + expiresAt: invite.expiresAt, + createdAt: invite.createdAt, + revokedAt: invite.revokedAt, + }) + .from(invite) + .leftJoin(mesh, eq(invite.meshId, mesh.id)) + .where(eq(invite.createdBy, userId)) + .orderBy(desc(invite.createdAt)) + .limit(100); diff --git a/packages/api/src/modules/mesh/router.ts b/packages/api/src/modules/mesh/router.ts new file mode 100644 index 0000000..1c87aa9 --- /dev/null +++ b/packages/api/src/modules/mesh/router.ts @@ -0,0 +1,114 @@ +import { Hono } from "hono"; + +import type { User } from "@turbostarter/auth"; + +import { enforceAuth, validate } from "../../middleware"; +import { + createMyInviteInputSchema, + createMyMeshInputSchema, + getMyMeshesInputSchema, +} from "../../schema"; + +import { + archiveMyMesh, + createMyInvite, + createMyMesh, + leaveMyMesh, +} from "./mutations"; +import { + getMyInvitesSent, + getMyMeshById, + getMyMeshes, +} from "./queries"; + +type Env = { Variables: { user: User } }; + +export const myRouter = new Hono() + .use(enforceAuth) + .get("/meshes", validate("query", getMyMeshesInputSchema), async (c) => { + const user = c.var.user; + return c.json( + await getMyMeshes({ userId: user.id, ...c.req.valid("query") }), + ); + }) + .post("/meshes", validate("json", createMyMeshInputSchema), async (c) => { + const user = c.var.user; + try { + const result = await createMyMesh({ + userId: user.id, + input: c.req.valid("json"), + }); + return c.json(result); + } catch (e) { + return c.json( + { error: e instanceof Error ? e.message : "Failed to create mesh." }, + 400, + ); + } + }) + .get("/meshes/:id", async (c) => { + const user = c.var.user; + return c.json( + (await getMyMeshById({ + userId: user.id, + meshId: c.req.param("id"), + })) ?? { mesh: null, members: [], invites: [] }, + ); + }) + .post( + "/meshes/:id/invites", + validate("json", createMyInviteInputSchema), + async (c) => { + const user = c.var.user; + try { + const result = await createMyInvite({ + userId: user.id, + meshId: c.req.param("id"), + input: c.req.valid("json"), + }); + return c.json(result); + } catch (e) { + return c.json( + { + error: + e instanceof Error ? e.message : "Failed to create invite.", + }, + 400, + ); + } + }, + ) + .post("/meshes/:id/archive", async (c) => { + const user = c.var.user; + try { + const result = await archiveMyMesh({ + userId: user.id, + meshId: c.req.param("id"), + }); + return c.json(result); + } catch (e) { + return c.json( + { error: e instanceof Error ? e.message : "Failed to archive." }, + 400, + ); + } + }) + .post("/meshes/:id/leave", async (c) => { + const user = c.var.user; + try { + const result = await leaveMyMesh({ + userId: user.id, + meshId: c.req.param("id"), + }); + return c.json(result); + } catch (e) { + return c.json( + { error: e instanceof Error ? e.message : "Failed to leave." }, + 400, + ); + } + }) + .get("/invites", async (c) => { + const user = c.var.user; + return c.json({ sent: await getMyInvitesSent({ userId: user.id }) }); + }); diff --git a/packages/api/src/schema/index.ts b/packages/api/src/schema/index.ts index 4614655..70f08c3 100644 --- a/packages/api/src/schema/index.ts +++ b/packages/api/src/schema/index.ts @@ -1,3 +1,4 @@ export * from "./admin"; export * from "./mesh-admin"; +export * from "./mesh-user"; export * from "./organization"; diff --git a/packages/api/src/schema/mesh-user.ts b/packages/api/src/schema/mesh-user.ts new file mode 100644 index 0000000..e019deb --- /dev/null +++ b/packages/api/src/schema/mesh-user.ts @@ -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; + +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; + +// --------------------------------------------------------------------- +// 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; + +export const createMyMeshResponseSchema = z.object({ + id: z.string(), + slug: z.string(), +}); +export type CreateMyMeshResponse = z.infer; + +// --------------------------------------------------------------------- +// 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; + +// --------------------------------------------------------------------- +// 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; + +export const createMyInviteResponseSchema = z.object({ + id: z.string(), + token: z.string(), + inviteLink: z.string(), + expiresAt: z.coerce.date(), +}); +export type CreateMyInviteResponse = z.infer; + +// --------------------------------------------------------------------- +// 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;