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:
@@ -13,6 +13,7 @@ import { adminRouter } from "./modules/admin/router";
|
|||||||
// import { aiRouter } from "./modules/ai/router"; // disabled: @turbostarter/ai package removed in claudemesh
|
// import { aiRouter } from "./modules/ai/router"; // disabled: @turbostarter/ai package removed in claudemesh
|
||||||
import { authRouter } from "./modules/auth/router";
|
import { authRouter } from "./modules/auth/router";
|
||||||
import { billingRouter } from "./modules/billing/router";
|
import { billingRouter } from "./modules/billing/router";
|
||||||
|
import { myRouter } from "./modules/mesh/router";
|
||||||
import { organizationRouter } from "./modules/organization/router";
|
import { organizationRouter } from "./modules/organization/router";
|
||||||
import { storageRouter } from "./modules/storage/router";
|
import { storageRouter } from "./modules/storage/router";
|
||||||
import { onError } from "./utils/on-error";
|
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("/ai", aiRouter) // disabled: @turbostarter/ai package removed in claudemesh
|
||||||
.route("/auth", authRouter)
|
.route("/auth", authRouter)
|
||||||
.route("/billing", billingRouter)
|
.route("/billing", billingRouter)
|
||||||
|
.route("/my", myRouter)
|
||||||
.route("/organizations", organizationRouter)
|
.route("/organizations", organizationRouter)
|
||||||
.route("/storage", storageRouter)
|
.route("/storage", storageRouter)
|
||||||
.onError(onError);
|
.onError(onError);
|
||||||
|
|||||||
180
packages/api/src/modules/mesh/mutations.ts
Normal file
180
packages/api/src/modules/mesh/mutations.ts
Normal file
@@ -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/<base64url(JSON)> 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),
|
||||||
|
};
|
||||||
|
};
|
||||||
185
packages/api/src/modules/mesh/queries.ts
Normal file
185
packages/api/src/modules/mesh/queries.ts
Normal file
@@ -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<boolean>`${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<number>`(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);
|
||||||
114
packages/api/src/modules/mesh/router.ts
Normal file
114
packages/api/src/modules/mesh/router.ts
Normal file
@@ -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<Env>()
|
||||||
|
.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 }) });
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./admin";
|
export * from "./admin";
|
||||||
export * from "./mesh-admin";
|
export * from "./mesh-admin";
|
||||||
|
export * from "./mesh-user";
|
||||||
export * from "./organization";
|
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