From 1f8510a47f9e0ba34dee19f297d8149c1ea16abe Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 10 Jul 2026 00:55:26 +0800 Subject: [PATCH] feat(hub): add org project explorer admin APIs Expose folder/project tree, create/move/rename/archive, and Feishu binding archive under /api/org/:orgSlug for OWNER/ADMIN sessions. --- hub/src/admin/routes/explorerRoutes.ts | 228 +++++++++++++ hub/src/admin/routes/orgRoutes.ts | 7 + hub/src/org/explorer.ts | 350 ++++++++++++++++++++ hub/test/integration/admin-explorer.test.ts | 224 +++++++++++++ 4 files changed, 809 insertions(+) create mode 100644 hub/src/admin/routes/explorerRoutes.ts create mode 100644 hub/src/org/explorer.ts create mode 100644 hub/test/integration/admin-explorer.test.ts diff --git a/hub/src/admin/routes/explorerRoutes.ts b/hub/src/admin/routes/explorerRoutes.ts new file mode 100644 index 0000000..dad15b5 --- /dev/null +++ b/hub/src/admin/routes/explorerRoutes.ts @@ -0,0 +1,228 @@ +/** + * Org explorer HTTP routes under `/api/org/:orgSlug`. + */ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { + archiveFolder, + archiveOrgProjectChatBinding, + archiveProject, + createOrgFolder, + createOrgProject, + getOrgProjectDetail, + listOrgExplorer, + moveOrgProjectToFolder, + renameFolder, + renameProject, +} from "../../org/explorer.js"; +import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; + +export interface ExplorerRouteConfig { + readonly prisma: PrismaClient; + readonly sessionSecret: string; + readonly projectWorkspaceRoot: string; +} + +export async function registerExplorerRoutes( + app: FastifyInstance, + config: ExplorerRouteConfig, +): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + + app.get("/api/org/:orgSlug/explorer", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await listOrgExplorer(config.prisma, auth.organization.id); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/folders", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { + name?: unknown; + parentId?: unknown; + sortKey?: unknown; + }; + if (typeof body.name !== "string") { + return reply.status(400).send({ + error: { code: "bad_request", message: "name is required" }, + }); + } + const folder = await createOrgFolder(config.prisma, { + organizationId: auth.organization.id, + name: body.name, + ...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}), + ...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}), + }); + return reply.status(201).send({ + id: folder.id, + name: folder.name, + parentId: folder.parentId, + sortKey: folder.sortKey, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.patch("/api/org/:orgSlug/folders/:folderId", async (request, reply) => { + try { + const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { + name?: unknown; + sortKey?: unknown; + parentId?: unknown; + }; + const folder = await renameFolder(config.prisma, { + organizationId: auth.organization.id, + folderId, + ...(typeof body.name === "string" ? { name: body.name } : {}), + ...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}), + ...(body.parentId === null || typeof body.parentId === "string" + ? { parentId: body.parentId as string | null } + : {}), + }); + return { + id: folder.id, + name: folder.name, + parentId: folder.parentId, + sortKey: folder.sortKey, + }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/folders/:folderId/archive", async (request, reply) => { + try { + const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await archiveFolder(config.prisma, { + organizationId: auth.organization.id, + folderId, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/projects", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { name?: unknown; folderId?: unknown }; + if (typeof body.name !== "string") { + return reply.status(400).send({ + error: { code: "bad_request", message: "name is required" }, + }); + } + const result = await createOrgProject(config.prisma, { + organizationId: auth.organization.id, + actorFeishuOpenId: auth.user.feishuOpenId, + name: body.name, + workspaceRoot: config.projectWorkspaceRoot, + ...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}), + }); + return reply.status(201).send(result); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => { + try { + const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await getOrgProjectDetail(config.prisma, { + organizationId: auth.organization.id, + projectId, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.patch("/api/org/:orgSlug/projects/:projectId", async (request, reply) => { + try { + const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { name?: unknown }; + if (typeof body.name !== "string") { + return reply.status(400).send({ + error: { code: "bad_request", message: "name is required" }, + }); + } + return await renameProject(config.prisma, { + organizationId: auth.organization.id, + projectId, + name: body.name, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.patch("/api/org/:orgSlug/projects/:projectId/folder", async (request, reply) => { + try { + const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { folderId?: unknown }; + if (body.folderId !== null && typeof body.folderId !== "string") { + return reply.status(400).send({ + error: { code: "bad_request", message: "folderId must be string or null" }, + }); + } + return await moveOrgProjectToFolder(config.prisma, { + organizationId: auth.organization.id, + projectId, + folderId: body.folderId as string | null, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/projects/:projectId/archive", async (request, reply) => { + try { + const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await archiveProject(config.prisma, { + organizationId: auth.organization.id, + projectId, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/projects/:projectId/binding/archive", async (request, reply) => { + try { + const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await archiveOrgProjectChatBinding(config.prisma, { + organizationId: auth.organization.id, + projectId, + actorFeishuOpenId: auth.user.feishuOpenId, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); +} diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index bf8163d..1533055 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -9,6 +9,7 @@ import { ensureOrganizationProjectSettings, setMembersCanCreateProjects, } from "../../projectOnboarding.js"; +import { registerExplorerRoutes } from "./explorerRoutes.js"; export interface OrgRouteConfig { readonly prisma: PrismaClient; @@ -74,4 +75,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo return handleRouteError(reply, err); } }); + + await registerExplorerRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + projectWorkspaceRoot: config.projectWorkspaceRoot, + }); } diff --git a/hub/src/org/explorer.ts b/hub/src/org/explorer.ts new file mode 100644 index 0000000..34e7857 --- /dev/null +++ b/hub/src/org/explorer.ts @@ -0,0 +1,350 @@ +/** + * Org project explorer (ADR-0021). + * + * Folders are transparent navigation nodes (not ACL resources). Project grants + * stay on PROJECT. Archive folder refuses when active children/projects remain. + */ +import type { Prisma, PrismaClient } from "@prisma/client"; +import { + archiveFeishuChatBinding, + createFolder, + createProjectFromOrgAdmin, + moveProjectToFolder, +} from "../projectOnboarding.js"; + +export interface ExplorerFolderNode { + readonly id: string; + readonly name: string; + readonly parentId: string | null; + readonly sortKey: string; + readonly projectCount: number; + readonly childFolderCount: number; +} + +export interface ExplorerProjectNode { + readonly id: string; + readonly name: string; + readonly folderId: string | null; + readonly createdAt: string; + readonly binding: { readonly chatId: string; readonly createdAt: string } | null; +} + +export interface OrgExplorer { + readonly folders: readonly ExplorerFolderNode[]; + readonly projects: readonly ExplorerProjectNode[]; +} + +export async function listOrgExplorer( + prisma: PrismaClient, + organizationId: string, +): Promise { + const [folders, projects] = await Promise.all([ + prisma.folder.findMany({ + where: { organizationId, archivedAt: null }, + select: { + id: true, + name: true, + parentId: true, + sortKey: true, + _count: { + select: { + children: { where: { archivedAt: null } }, + projects: { where: { archivedAt: null } }, + }, + }, + }, + orderBy: [{ sortKey: "asc" }, { name: "asc" }], + }), + prisma.project.findMany({ + where: { organizationId, archivedAt: null }, + select: { + id: true, + name: true, + folderId: true, + createdAt: true, + groupBindings: { + where: { archivedAt: null }, + select: { chatId: true, createdAt: true }, + take: 1, + }, + }, + orderBy: [{ name: "asc" }], + }), + ]); + + return { + folders: folders.map((f) => ({ + id: f.id, + name: f.name, + parentId: f.parentId, + sortKey: f.sortKey, + projectCount: f._count.projects, + childFolderCount: f._count.children, + })), + projects: projects.map((p) => { + const binding = p.groupBindings[0]; + return { + id: p.id, + name: p.name, + folderId: p.folderId, + createdAt: p.createdAt.toISOString(), + binding: + binding === undefined + ? null + : { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() }, + }; + }), + }; +} + +export async function createOrgFolder( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly name: string; + readonly parentId?: string | undefined; + readonly sortKey?: string | undefined; + }, +) { + return createFolder(prisma, input); +} + +export async function renameFolder( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly folderId: string; + readonly name?: string | undefined; + readonly sortKey?: string | undefined; + readonly parentId?: string | null | undefined; + }, +) { + return prisma.$transaction(async (tx) => { + const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); + if (input.parentId !== undefined && input.parentId !== null) { + if (input.parentId === folder.id) { + throw new Error("folder cannot be its own parent"); + } + await requireActiveFolder(tx, input.parentId, input.organizationId); + } + const name = + input.name !== undefined ? requireNonEmpty(input.name, "folder name") : undefined; + return tx.folder.update({ + where: { id: folder.id }, + data: { + ...(name !== undefined ? { name } : {}), + ...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}), + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + }, + }); + }); +} + +/** + * Soft-archive a folder. Refuses if it still has active child folders or projects. + */ +export async function archiveFolder( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly folderId: string }, +): Promise<{ readonly archived: true; readonly folderId: string }> { + return prisma.$transaction(async (tx) => { + const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); + const childFolders = await tx.folder.count({ + where: { parentId: folder.id, archivedAt: null }, + }); + if (childFolders > 0) { + throw new Error( + `cannot archive folder ${folder.id}: still has ${childFolders} active child folder(s)`, + ); + } + const projects = await tx.project.count({ + where: { folderId: folder.id, archivedAt: null }, + }); + if (projects > 0) { + throw new Error( + `cannot archive folder ${folder.id}: still has ${projects} active project(s)`, + ); + } + await tx.folder.update({ + where: { id: folder.id }, + data: { archivedAt: new Date() }, + }); + return { archived: true as const, folderId: folder.id }; + }); +} + +export async function createOrgProject( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly actorFeishuOpenId: string; + readonly name: string; + readonly workspaceRoot: string; + readonly folderId?: string | undefined; + }, +) { + return createProjectFromOrgAdmin(prisma, input); +} + +export async function renameProject( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly projectId: string; + readonly name: string; + }, +) { + const name = requireNonEmpty(input.name, "project name"); + const project = await requireActiveProject(prisma, input.projectId, input.organizationId); + return prisma.project.update({ + where: { id: project.id }, + data: { name }, + select: { id: true, name: true, folderId: true }, + }); +} + +export async function moveOrgProjectToFolder( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly projectId: string; + readonly folderId: string | null; + }, +) { + await requireActiveProject(prisma, input.projectId, input.organizationId); + return moveProjectToFolder(prisma, { + projectId: input.projectId, + folderId: input.folderId, + }); +} + +export async function archiveProject( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly projectId: string }, +): Promise<{ readonly archived: true; readonly projectId: string }> { + return prisma.$transaction(async (tx) => { + const project = await requireActiveProject(tx, input.projectId, input.organizationId); + const activeBinding = await tx.projectGroupBinding.findFirst({ + where: { projectId: project.id, archivedAt: null }, + select: { id: true, chatId: true }, + }); + const now = new Date(); + if (activeBinding !== null) { + await tx.projectGroupBinding.update({ + where: { id: activeBinding.id }, + data: { archivedAt: now }, + }); + await tx.permissionGrant.updateMany({ + where: { + resourceType: "PROJECT", + resourceId: project.id, + principalType: "FEISHU_CHAT", + principalId: activeBinding.chatId, + revokedAt: null, + }, + data: { revokedAt: now }, + }); + } + await tx.project.update({ + where: { id: project.id }, + data: { archivedAt: now }, + }); + return { archived: true as const, projectId: project.id }; + }); +} + +export async function getOrgProjectDetail( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly projectId: string }, +) { + const project = await prisma.project.findFirst({ + where: { + id: input.projectId, + organizationId: input.organizationId, + }, + select: { + id: true, + name: true, + folderId: true, + workspaceDir: true, + createdAt: true, + archivedAt: true, + createdBy: { select: { id: true, displayName: true, feishuOpenId: true } }, + folder: { select: { id: true, name: true } }, + groupBindings: { + where: { archivedAt: null }, + select: { chatId: true, createdAt: true }, + take: 1, + }, + }, + }); + if (project === null) { + throw new Error(`project not found: ${input.projectId}`); + } + const binding = project.groupBindings[0]; + return { + id: project.id, + name: project.name, + folderId: project.folderId, + folder: project.folder, + workspaceDir: project.workspaceDir, + createdAt: project.createdAt.toISOString(), + archivedAt: project.archivedAt?.toISOString() ?? null, + createdBy: project.createdBy, + binding: + binding === undefined + ? null + : { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() }, + }; +} + +export async function archiveOrgProjectChatBinding( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly projectId: string; + readonly actorFeishuOpenId: string; + }, +) { + await requireActiveProject(prisma, input.projectId, input.organizationId); + return archiveFeishuChatBinding(prisma, { + projectId: input.projectId, + actorFeishuOpenId: input.actorFeishuOpenId, + }); +} + +async function requireActiveFolder( + prisma: PrismaClient | Prisma.TransactionClient, + folderId: string, + organizationId: string, +): Promise<{ readonly id: string; readonly organizationId: string }> { + const folder = await prisma.folder.findUnique({ + where: { id: folderId }, + select: { id: true, organizationId: true, archivedAt: true }, + }); + if (folder === null || folder.archivedAt !== null || folder.organizationId !== organizationId) { + throw new Error(`active folder not found: ${folderId}`); + } + return folder; +} + +async function requireActiveProject( + prisma: PrismaClient | Prisma.TransactionClient, + projectId: string, + organizationId: string, +): Promise<{ readonly id: string; readonly organizationId: string }> { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { id: true, organizationId: true, archivedAt: true }, + }); + if (project === null || project.archivedAt !== null || project.organizationId !== organizationId) { + throw new Error(`active project not found: ${projectId}`); + } + return project; +} + +function requireNonEmpty(value: string, label: string): string { + const trimmed = value.trim(); + if (trimmed === "") throw new Error(`${label} is required`); + return trimmed; +} diff --git a/hub/test/integration/admin-explorer.test.ts b/hub/test/integration/admin-explorer.test.ts new file mode 100644 index 0000000..5e99e70 --- /dev/null +++ b/hub/test/integration/admin-explorer.test.ts @@ -0,0 +1,224 @@ +/** + * Org explorer API integration tests (ADR-0021). + */ +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Fastify from "fastify"; +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { registerAdminPlugin } from "../../src/admin/plugin.js"; +import { + mintSessionToken, + sessionCookieHeader, +} from "../../src/admin/routes/authRoutes.js"; +import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; + +const SESSION_SECRET = "integration-test-session-secret"; +const workspaceRoots: string[] = []; + +async function buildApp(workspaceRoot: string) { + const app = Fastify({ logger: false }); + await registerAdminPlugin(app, { + prisma, + sessionSecret: SESSION_SECRET, + publicBaseUrl: "http://127.0.0.1:8788", + feishuAppId: "cli_test", + feishuAppSecret: "secret_test", + projectWorkspaceRoot: workspaceRoot, + cookieSecure: false, + }); + await app.ready(); + return app; +} + +async function seedAdmin(): Promise { + await prisma.user.create({ + data: { id: "u-admin", feishuOpenId: "ou_admin", displayName: "Admin" }, + }); + await prisma.organizationMembership.create({ + data: { organizationId: DEFAULT_ORG_ID, userId: "u-admin", role: "ADMIN" }, + }); + return mintSessionToken({ userId: "u-admin", feishuOpenId: "ou_admin" }, SESSION_SECRET); +} + +async function tempWorkspaceRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "cph-admin-explorer-")); + workspaceRoots.push(root); + return root; +} + +describe("admin explorer API", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterEach(async () => { + while (workspaceRoots.length > 0) { + const root = workspaceRoots.pop(); + if (root !== undefined) await rm(root, { recursive: true, force: true }); + } + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("creates folder + project and lists them in explorer", async () => { + const token = await seedAdmin(); + const workspaceRoot = await tempWorkspaceRoot(); + const app = await buildApp(workspaceRoot); + try { + const folderRes = await app.inject({ + method: "POST", + url: "/api/org/test-default/folders", + headers: { + cookie: sessionCookieHeader(token), + "content-type": "application/json", + }, + payload: { name: "Grade 7", sortKey: "010000" }, + }); + expect(folderRes.statusCode).toBe(201); + const folder = folderRes.json() as { id: string }; + + const projectRes = await app.inject({ + method: "POST", + url: "/api/org/test-default/projects", + headers: { + cookie: sessionCookieHeader(token), + "content-type": "application/json", + }, + payload: { name: "Newton", folderId: folder.id }, + }); + expect(projectRes.statusCode).toBe(201); + const project = projectRes.json() as { + projectId: string; + folderId: string; + workspaceDir: string; + }; + expect(project.folderId).toBe(folder.id); + expect((await stat(project.workspaceDir)).isDirectory()).toBe(true); + + const explorerRes = await app.inject({ + method: "GET", + url: "/api/org/test-default/explorer", + headers: { cookie: sessionCookieHeader(token) }, + }); + expect(explorerRes.statusCode).toBe(200); + const explorer = explorerRes.json() as { + folders: Array<{ id: string; name: string; projectCount: number }>; + projects: Array<{ id: string; name: string; folderId: string | null }>; + }; + expect(explorer.folders.some((f) => f.id === folder.id && f.projectCount === 1)).toBe(true); + expect(explorer.projects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: project.projectId, + name: "Newton", + folderId: folder.id, + }), + ]), + ); + } finally { + await app.close(); + } + }); + + it("moves project between folders and refuses archive of non-empty folder", async () => { + const token = await seedAdmin(); + const workspaceRoot = await tempWorkspaceRoot(); + const app = await buildApp(workspaceRoot); + try { + const cookie = sessionCookieHeader(token); + const a = ( + await app.inject({ + method: "POST", + url: "/api/org/test-default/folders", + headers: { cookie, "content-type": "application/json" }, + payload: { name: "A" }, + }) + ).json() as { id: string }; + const b = ( + await app.inject({ + method: "POST", + url: "/api/org/test-default/folders", + headers: { cookie, "content-type": "application/json" }, + payload: { name: "B" }, + }) + ).json() as { id: string }; + const project = ( + await app.inject({ + method: "POST", + url: "/api/org/test-default/projects", + headers: { cookie, "content-type": "application/json" }, + payload: { name: "P", folderId: a.id }, + }) + ).json() as { projectId: string }; + + const moveRes = await app.inject({ + method: "PATCH", + url: `/api/org/test-default/projects/${project.projectId}/folder`, + headers: { cookie, "content-type": "application/json" }, + payload: { folderId: b.id }, + }); + expect(moveRes.statusCode).toBe(200); + expect(moveRes.json()).toEqual({ + projectId: project.projectId, + folderId: b.id, + }); + + const refuse = await app.inject({ + method: "POST", + url: `/api/org/test-default/folders/${b.id}/archive`, + headers: { cookie }, + }); + expect({ status: refuse.statusCode, body: refuse.json() }).toEqual({ + status: 403, + body: expect.objectContaining({ + error: expect.objectContaining({ code: "forbidden" }), + }), + }); + + await app.inject({ + method: "POST", + url: `/api/org/test-default/projects/${project.projectId}/archive`, + headers: { cookie }, + }); + const ok = await app.inject({ + method: "POST", + url: `/api/org/test-default/folders/${b.id}/archive`, + headers: { cookie }, + }); + expect(ok.statusCode).toBe(200); + expect(ok.json()).toEqual({ archived: true, folderId: b.id }); + } finally { + await app.close(); + } + }); + + it("blocks cross-org project access by id", async () => { + const token = await seedAdmin(); + await prisma.organization.create({ + data: { id: "org_other", slug: "other", name: "Other" }, + }); + await prisma.project.create({ + data: { + id: "p-other", + organizationId: "org_other", + name: "Other project", + workspaceDir: "/tmp/other", + }, + }); + const workspaceRoot = await tempWorkspaceRoot(); + const app = await buildApp(workspaceRoot); + try { + const res = await app.inject({ + method: "GET", + url: "/api/org/test-default/projects/p-other", + headers: { cookie: sessionCookieHeader(token) }, + }); + expect(res.statusCode).toBe(404); + } finally { + await app.close(); + } + }); +});