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) <noreply@anthropic.com>
This commit is contained in:
@@ -674,6 +674,29 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
return;
|
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)
|
// Telegram connect token (rate-limited: 10 requests/hour per IP)
|
||||||
if (req.method === "POST" && req.url === "/tg/token") {
|
if (req.method === "POST" && req.url === "/tg/token") {
|
||||||
const clientIp = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "unknown";
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
let body: { requester_id: string; member_id: string; permissions: Record<string, boolean> };
|
||||||
|
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
|
// Skip starting the HTTP/WS server when running under vitest — tests import
|
||||||
|
|||||||
112
apps/broker/src/permissions.ts
Normal file
112
apps/broker/src/permissions.ts
Normal file
@@ -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<ResolvedPermissions> {
|
||||||
|
// 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<boolean> {
|
||||||
|
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<ResolvedPermissions>,
|
||||||
|
): Promise<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -712,6 +712,96 @@ export const meshMemberRelations = relations(meshMember, ({ one, many }) => ({
|
|||||||
sentMessages: many(messageQueue),
|
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 }) => ({
|
export const presenceRelations = relations(presence, ({ one }) => ({
|
||||||
member: one(meshMember, {
|
member: one(meshMember, {
|
||||||
fields: [presence.memberId],
|
fields: [presence.memberId],
|
||||||
|
|||||||
Reference in New Issue
Block a user