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:
Alejandro Gutiérrez
2026-04-04 22:56:29 +01:00
parent 9d3dbcecaf
commit a486ffd056
6 changed files with 641 additions and 0 deletions

View 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),
};
};

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

View 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 }) });
});