From bb1310167e1bd0707f0fe8891397aa2f9d5b6edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:03:28 +0100 Subject: [PATCH] feat: granular mesh permissions + mesh delete + share picker - Drizzle schema: mesh.permission table with 11 boolean flags - Default permissions by role (owner > admin > member) - Broker: GET/POST /cli/mesh/:slug/permissions - Broker: DELETE /cli/mesh/:slug (owner only, soft delete) - Broker: permission check module (getPermissions, checkPermission, setPermissions) - CLI: mesh share with interactive mesh picker - CLI: mesh delete with server-side delete + confirmation Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/broker/src/index.ts | 187 +++++++++++++++++++++++++++++++++ apps/broker/src/permissions.ts | 112 ++++++++++++++++++++ packages/db/src/schema/mesh.ts | 90 ++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 apps/broker/src/permissions.ts diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index f45dc6b..3d16662 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -674,6 +674,29 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void { return; } + if (req.method === "POST" && req.url === "/cli/session/revoke") { + handleCliSessionRevoke(req, res, started); + return; + } + + if (req.method === "DELETE" && req.url?.startsWith("/cli/mesh/")) { + const slug = req.url.slice("/cli/mesh/".length); + handleMeshDelete(req, slug, res, started); + return; + } + + if (req.method === "GET" && req.url?.startsWith("/cli/mesh/") && req.url?.endsWith("/permissions")) { + const slug = req.url.slice("/cli/mesh/".length).replace("/permissions", ""); + handlePermissionsGet(slug, res, started); + return; + } + + if (req.method === "POST" && req.url?.startsWith("/cli/mesh/") && req.url?.endsWith("/permissions")) { + const slug = req.url.slice("/cli/mesh/".length).replace("/permissions", ""); + handlePermissionsSet(req, slug, res, started); + return; + } + // Telegram connect token (rate-limited: 10 requests/hour per IP) if (req.method === "POST" && req.url === "/tg/token") { const clientIp = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "unknown"; @@ -4860,6 +4883,170 @@ async function handleCliTokenGenerate(req: IncomingMessage, res: ServerResponse, } } +/** POST /cli/session/revoke — revoke a CLI session by token. */ +async function handleCliSessionRevoke(req: IncomingMessage, res: ServerResponse, started: number): Promise { + let body: { token?: string }; + try { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + body = JSON.parse(Buffer.concat(chunks).toString()) as typeof body; + } catch { + writeJson(res, 400, { error: "Invalid body" }); + return; + } + + if (!body.token) { + writeJson(res, 400, { error: "token required" }); + return; + } + + try { + const hash = await hashToken(body.token); + const [session] = await db.select().from(cliSessionTable) + .where(and(eq(cliSessionTable.tokenHash, hash), isNull(cliSessionTable.revokedAt))) + .limit(1); + + if (!session) { + // Token not in DB — might be an old token from before device-code tracking. + // Still return ok since the local token will be cleared. + writeJson(res, 200, { ok: true, found: false }); + return; + } + + await db.update(cliSessionTable) + .set({ revokedAt: new Date() }) + .where(eq(cliSessionTable.id, session.id)); + + writeJson(res, 200, { ok: true, found: true }); + log.info("cli-session-revoke", { route: "POST /cli/session/revoke", session_id: session.id, user_id: session.userId, latency_ms: Date.now() - started }); + } catch (e) { + log.error("cli-session-revoke", { error: e instanceof Error ? e.message : String(e) }); + writeJson(res, 500, { error: "Failed to revoke session" }); + } +} + +// --------------------------------------------------------------------------- +// Mesh management + permissions handlers +// --------------------------------------------------------------------------- + +import { checkPermission, getPermissions, setPermissions } from "./permissions"; +import { meshPermission } from "@turbostarter/db/schema/mesh"; + +/** DELETE /cli/mesh/:slug — delete a mesh (owner only). */ +async function handleMeshDelete(req: IncomingMessage, slug: string, res: ServerResponse, started: number): Promise { + let body: { user_id: string }; + try { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + body = JSON.parse(Buffer.concat(chunks).toString()) as typeof body; + } catch { + writeJson(res, 400, { error: "Invalid body" }); + return; + } + + if (!body.user_id) { + writeJson(res, 400, { error: "user_id required" }); + return; + } + + try { + // Find mesh by slug + const [m] = await db.select().from(mesh).where(eq(mesh.slug, slug)).limit(1); + if (!m) { writeJson(res, 404, { error: "Mesh not found" }); return; } + + // Only owner can delete + if (m.ownerUserId !== body.user_id) { + writeJson(res, 403, { error: "Only the mesh owner can delete it" }); + return; + } + + // Soft delete (archive) + await db.update(mesh).set({ archivedAt: new Date() }).where(eq(mesh.id, m.id)); + + writeJson(res, 200, { ok: true, deleted: slug }); + log.info("mesh-delete", { route: "DELETE /cli/mesh/:slug", slug, user_id: body.user_id, latency_ms: Date.now() - started }); + } catch (e) { + log.error("mesh-delete", { error: e instanceof Error ? e.message : String(e) }); + writeJson(res, 500, { error: "Failed to delete mesh" }); + } +} + +/** GET /cli/mesh/:slug/permissions — get all member permissions for a mesh. */ +async function handlePermissionsGet(slug: string, res: ServerResponse, started: number): Promise { + try { + const [m] = await db.select().from(mesh).where(eq(mesh.slug, slug)).limit(1); + if (!m) { writeJson(res, 404, { error: "Mesh not found" }); return; } + + const members = await db.select().from(meshMember).where(eq(meshMember.meshId, m.id)); + + const result = await Promise.all(members.map(async (member) => ({ + member_id: member.id, + display_name: member.displayName, + role: member.role, + is_owner: m.ownerUserId ? member.userId === m.ownerUserId : false, + permissions: await getPermissions(m.id, member.id), + }))); + + writeJson(res, 200, { mesh: slug, members: result }); + log.info("permissions-get", { route: "GET /cli/mesh/:slug/permissions", slug, count: result.length, latency_ms: Date.now() - started }); + } catch (e) { + log.error("permissions-get", { error: e instanceof Error ? e.message : String(e) }); + writeJson(res, 500, { error: "Failed to get permissions" }); + } +} + +/** POST /cli/mesh/:slug/permissions — set permissions for a member. */ +async function handlePermissionsSet(req: IncomingMessage, slug: string, res: ServerResponse, started: number): Promise { + let body: { requester_id: string; member_id: string; permissions: Record }; + try { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + body = JSON.parse(Buffer.concat(chunks).toString()) as typeof body; + } catch { + writeJson(res, 400, { error: "Invalid body" }); + return; + } + + if (!body.requester_id || !body.member_id || !body.permissions) { + writeJson(res, 400, { error: "requester_id, member_id, and permissions required" }); + return; + } + + try { + const [m] = await db.select().from(mesh).where(eq(mesh.slug, slug)).limit(1); + if (!m) { writeJson(res, 404, { error: "Mesh not found" }); return; } + + // Find requester's member record + const [requester] = await db.select().from(meshMember) + .where(and(eq(meshMember.meshId, m.id), eq(meshMember.userId, body.requester_id))) + .limit(1); + + if (!requester) { writeJson(res, 403, { error: "Not a member of this mesh" }); return; } + + // Check if requester can manage permissions + const canManage = await checkPermission(m.id, requester.id, "canManagePermissions"); + if (!canManage) { + writeJson(res, 403, { error: "You don't have permission to manage permissions" }); + return; + } + + // Apply permission updates + await setPermissions(m.id, body.member_id, body.permissions as any); + + writeJson(res, 200, { ok: true }); + log.info("permissions-set", { + route: "POST /cli/mesh/:slug/permissions", + slug, + requester: body.requester_id, + target: body.member_id, + latency_ms: Date.now() - started, + }); + } catch (e) { + log.error("permissions-set", { error: e instanceof Error ? e.message : String(e) }); + writeJson(res, 500, { error: "Failed to set permissions" }); + } +} + // --------------------------------------------------------------------------- // Skip starting the HTTP/WS server when running under vitest — tests import diff --git a/apps/broker/src/permissions.ts b/apps/broker/src/permissions.ts new file mode 100644 index 0000000..eeb990e --- /dev/null +++ b/apps/broker/src/permissions.ts @@ -0,0 +1,112 @@ +/** + * Granular permission checks for mesh operations. + * + * If a meshPermission row exists for the member, use it. + * Otherwise, derive defaults from the member's role. + */ + +import { and, eq } from "drizzle-orm"; +import { db } from "./db"; +import { meshPermission, meshMember, mesh, DEFAULT_PERMISSIONS } from "@turbostarter/db/schema/mesh"; +import type { PermissionKey } from "@turbostarter/db/schema/mesh"; + +export interface ResolvedPermissions { + canInvite: boolean; + canDeployMcp: boolean; + canManageFiles: boolean; + canManageVault: boolean; + canManageWatches: boolean; + canManageWebhooks: boolean; + canWriteState: boolean; + canSend: boolean; + canUseTools: boolean; + canDeleteMesh: boolean; + canManagePermissions: boolean; +} + +/** + * Get effective permissions for a member in a mesh. + * Checks for explicit permission row, falls back to role defaults. + */ +export async function getPermissions(meshId: string, memberId: string): Promise { + // Get the explicit permission row if it exists + const [perm] = await db.select().from(meshPermission) + .where(and(eq(meshPermission.meshId, meshId), eq(meshPermission.memberId, memberId))) + .limit(1); + + if (perm) { + return { + canInvite: perm.canInvite, + canDeployMcp: perm.canDeployMcp, + canManageFiles: perm.canManageFiles, + canManageVault: perm.canManageVault, + canManageWatches: perm.canManageWatches, + canManageWebhooks: perm.canManageWebhooks, + canWriteState: perm.canWriteState, + canSend: perm.canSend, + canUseTools: perm.canUseTools, + canDeleteMesh: perm.canDeleteMesh, + canManagePermissions: perm.canManagePermissions, + }; + } + + // Fall back to role-based defaults + const [member] = await db.select().from(meshMember) + .where(eq(meshMember.id, memberId)) + .limit(1); + + if (!member) return DEFAULT_PERMISSIONS.member; + + // Check if member is mesh owner + const [m] = await db.select().from(mesh) + .where(eq(mesh.id, meshId)) + .limit(1); + + if (m && m.ownerUserId && member.userId === m.ownerUserId) { + return DEFAULT_PERMISSIONS.owner; + } + + return DEFAULT_PERMISSIONS[member.role] ?? DEFAULT_PERMISSIONS.member; +} + +/** + * Check a single permission for a member. + * Returns true if allowed, false if denied. + */ +export async function checkPermission( + meshId: string, + memberId: string, + permission: PermissionKey, +): Promise { + const perms = await getPermissions(meshId, memberId); + return perms[permission]; +} + +/** + * Set explicit permissions for a member (partial update). + * Creates the row if it doesn't exist. + */ +export async function setPermissions( + meshId: string, + memberId: string, + updates: Partial, +): Promise { + const [existing] = await db.select().from(meshPermission) + .where(and(eq(meshPermission.meshId, meshId), eq(meshPermission.memberId, memberId))) + .limit(1); + + if (existing) { + await db.update(meshPermission) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(meshPermission.id, existing.id)); + } else { + // Get role defaults first, then overlay updates + const defaults = await getPermissions(meshId, memberId); + await db.insert(meshPermission).values({ + meshId, + memberId, + ...defaults, + ...updates, + }); + } +} diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index e3d9d8a..b6d09b7 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -712,6 +712,96 @@ export const meshMemberRelations = relations(meshMember, ({ one, many }) => ({ sentMessages: many(messageQueue), })); +// --------------------------------------------------------------------------- +// Granular mesh permissions +// --------------------------------------------------------------------------- + +/** + * Per-member permission overrides. If no row exists for a member, + * defaults are derived from the member's role: + * owner → all true + * admin → all true except can_delete_mesh + * member → can_send, can_read_state, can_use_tools only + * + * Explicit rows override these defaults (allow or deny). + */ +export const meshPermission = meshSchema.table("permission", { + id: text().primaryKey().notNull().$defaultFn(generateId), + meshId: text() + .references(() => mesh.id, { onDelete: "cascade" }) + .notNull(), + memberId: text() + .references(() => meshMember.id, { onDelete: "cascade" }) + .notNull(), + /** Invite other users to the mesh. */ + canInvite: boolean().notNull().default(false), + /** Deploy/undeploy MCP services. */ + canDeployMcp: boolean().notNull().default(false), + /** Upload/delete shared files. */ + canManageFiles: boolean().notNull().default(false), + /** Read/write vault secrets. */ + canManageVault: boolean().notNull().default(false), + /** Create/manage URL watches. */ + canManageWatches: boolean().notNull().default(false), + /** Create/manage webhooks. */ + canManageWebhooks: boolean().notNull().default(false), + /** Write shared state (read is always allowed). */ + canWriteState: boolean().notNull().default(true), + /** Send messages to peers. */ + canSend: boolean().notNull().default(true), + /** Use deployed MCP tools. */ + canUseTools: boolean().notNull().default(true), + /** Delete the mesh entirely (owner only). */ + canDeleteMesh: boolean().notNull().default(false), + /** Manage other members' permissions. */ + canManagePermissions: boolean().notNull().default(false), + updatedAt: timestamp().defaultNow().notNull(), +}, (table) => [ + uniqueIndex("permission_member_mesh_idx").on(table.meshId, table.memberId), +]); + +export const meshPermissionRelations = relations(meshPermission, ({ one }) => ({ + mesh: one(mesh, { + fields: [meshPermission.meshId], + references: [mesh.id], + }), + member: one(meshMember, { + fields: [meshPermission.memberId], + references: [meshMember.id], + }), +})); + +export const selectMeshPermissionSchema = createSelectSchema(meshPermission); +export const insertMeshPermissionSchema = createInsertSchema(meshPermission); +export type SelectMeshPermission = typeof meshPermission.$inferSelect; +export type InsertMeshPermission = typeof meshPermission.$inferInsert; + +/** + * Default permissions by role (used when no explicit permission row exists). + */ +export const DEFAULT_PERMISSIONS = { + owner: { + canInvite: true, canDeployMcp: true, canManageFiles: true, + canManageVault: true, canManageWatches: true, canManageWebhooks: true, + canWriteState: true, canSend: true, canUseTools: true, + canDeleteMesh: true, canManagePermissions: true, + }, + admin: { + canInvite: true, canDeployMcp: true, canManageFiles: true, + canManageVault: true, canManageWatches: true, canManageWebhooks: true, + canWriteState: true, canSend: true, canUseTools: true, + canDeleteMesh: false, canManagePermissions: true, + }, + member: { + canInvite: false, canDeployMcp: false, canManageFiles: false, + canManageVault: false, canManageWatches: false, canManageWebhooks: false, + canWriteState: true, canSend: true, canUseTools: true, + canDeleteMesh: false, canManagePermissions: false, + }, +} as const; + +export type PermissionKey = keyof typeof DEFAULT_PERMISSIONS.member; + export const presenceRelations = relations(presence, ({ one }) => ({ member: one(meshMember, { fields: [presence.memberId],