import { createHash, randomUUID } from "node:crypto"; import { mkdir, rm } from "node:fs/promises"; import { dirname, relative, resolve } from "node:path"; import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client"; import { createPermissionAuthorizer } from "./permission.js"; import { writeAudit } from "./audit.js"; import { lockActiveOrganization, requireActiveOrganizationStatus } from "./org/status.js"; import { scopedFeishuPrincipalId } from "./feishu/identityNamespace.js"; export interface OrganizationProjectPolicy { readonly organizationId: string; readonly membersCanCreateProjects: boolean; } export interface ProjectOnboardingResult { readonly projectId: string; readonly organizationId: string; readonly folderId: string | null; readonly workspaceDir: string; readonly chatId?: string | undefined; } export interface CreateOrgAdminProjectInput { readonly organizationId: string; readonly actorFeishuOpenId: string; readonly name: string; readonly workspaceRoot: string; readonly folderId?: string | undefined; readonly sortKey?: string | undefined; /** Stable internal identifier for resumable imports; ordinary callers must omit it. */ readonly projectId?: string | undefined; } export interface CreateFeishuChatProjectInput { readonly organizationId: string; readonly actorFeishuOpenId: string; readonly chatId: string; readonly name: string; readonly workspaceRoot: string; readonly folderId?: string | undefined; readonly sortKey?: string | undefined; } export interface BindFeishuChatToProjectInput { readonly projectId: string; readonly actorFeishuOpenId: string; readonly chatId: string; } export interface ArchiveFeishuChatBindingInput { readonly projectId?: string | undefined; readonly chatId?: string | undefined; readonly actorFeishuOpenId: string; } export interface RenameProjectForActorInput { readonly organizationId: string; readonly projectId: string; readonly actorFeishuOpenId: string; readonly name: string; } export interface CreateFolderInput { readonly organizationId: string; readonly name: string; readonly parentId?: string | undefined; readonly sortKey?: string | undefined; } export interface MoveProjectToFolderInput { readonly projectId: string; readonly folderId: string | null; } export async function ensureOrganizationProjectSettings( prisma: PrismaClient, organizationId: string, ): Promise { return prisma.$transaction(async (tx) => ensureOrganizationProjectSettingsTx(tx, organizationId)); } export async function setMembersCanCreateProjects( prisma: PrismaClient, input: { readonly organizationId: string; readonly enabled: boolean }, ): Promise { return prisma.$transaction(async (tx) => { await requireActiveOrganizationTx(tx, input.organizationId); return tx.organizationProjectSettings.upsert({ where: { organizationId: input.organizationId }, update: { membersCanCreateProjects: input.enabled }, create: { organizationId: input.organizationId, membersCanCreateProjects: input.enabled, }, select: { organizationId: true, membersCanCreateProjects: true }, }); }); } export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise { const name = requireNonEmpty(input.name, "folder name"); return prisma.$transaction(async (tx) => { await requireActiveOrganizationTx(tx, input.organizationId); if (input.parentId !== undefined) { await assertFolderInOrganization(tx, input.parentId, input.organizationId); } return tx.folder.create({ data: { organizationId: input.organizationId, name, sortKey: input.sortKey ?? "", ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), }, }); }); } export async function moveProjectToFolder( prisma: PrismaClient, input: MoveProjectToFolderInput, ): Promise<{ readonly projectId: string; readonly folderId: string | null }> { return prisma.$transaction(async (tx) => { const project = await tx.project.findUnique({ where: { id: input.projectId }, select: { id: true, organizationId: true }, }); if (project === null) throw new Error(`project not found: ${input.projectId}`); await requireActiveOrganizationTx(tx, project.organizationId); if (input.folderId !== null) { await assertFolderInOrganization(tx, input.folderId, project.organizationId); } const updated = await tx.project.update({ where: { id: input.projectId }, data: { folderId: input.folderId }, select: { id: true, folderId: true }, }); return { projectId: updated.id, folderId: updated.folderId }; }); } export async function renameProjectForActor( prisma: PrismaClient, input: RenameProjectForActorInput, ): Promise<{ readonly projectId: string; readonly name: string }> { const name = requireNonEmpty(input.name, "project name"); const project = await prisma.project.findUnique({ where: { id: input.projectId }, select: { id: true, organizationId: true, name: true }, }); if (project === null) throw new Error(`project not found: ${input.projectId}`); if (project.organizationId !== input.organizationId) { throw new Error(`project ${project.id} does not belong to organization ${input.organizationId}`); } const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId); const updated = await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, project.organizationId); const current = await tx.project.findUniqueOrThrow({ where: { id: project.id }, select: { name: true }, }); const renamed = await tx.project.update({ where: { id: project.id }, data: { name }, select: { id: true, name: true }, }); await tx.auditEntry.create({ data: { organizationId: project.organizationId, projectId: project.id, actorUserId: actor.userId, action: "project.renamed", metadata: { oldName: current.name, newName: name, actorVia: actor.via }, }, }); return renamed; }); return { projectId: updated.id, name: updated.name }; } export async function createProjectFromOrgAdmin( prisma: PrismaClient, input: CreateOrgAdminProjectInput, ): Promise { const name = requireNonEmpty(input.name, "project name"); const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId); if (!isOrgAdminRole(actor.role)) { throw new Error(`org admin project creation requires OWNER or ADMIN in organization ${input.organizationId}`); } return createManagedProject(prisma, { organizationId: input.organizationId, actorUserId: actor.userId, actorPrincipalId: actor.principalId, feishuConnectionId: actor.connectionId, name, workspaceRoot: input.workspaceRoot, folderId: input.folderId, sortKey: input.sortKey, projectId: input.projectId, chatId: undefined, }); } export async function createProjectFromFeishuChat( prisma: PrismaClient, input: CreateFeishuChatProjectInput, ): Promise { const chatId = requireNonEmpty(input.chatId, "chat id"); const name = requireNonEmpty(input.name, "project name"); const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId); const settings = await ensureOrganizationProjectSettings(prisma, input.organizationId); if (!settings.membersCanCreateProjects && !isOrgAdminRole(actor.role)) { throw new Error(`members cannot create projects in organization ${input.organizationId}`); } const activeBinding = await prisma.projectGroupBinding.findFirst({ where: { chatId, archivedAt: null }, select: { projectId: true }, }); if (activeBinding !== null) { throw new Error(`Feishu chat ${chatId} is already bound to project ${activeBinding.projectId}`); } return createManagedProject(prisma, { organizationId: input.organizationId, actorUserId: actor.userId, actorPrincipalId: actor.principalId, feishuConnectionId: actor.connectionId, name, workspaceRoot: input.workspaceRoot, folderId: input.folderId, sortKey: input.sortKey, chatId, }); } export async function bindFeishuChatToProject( prisma: PrismaClient, input: BindFeishuChatToProjectInput, ): Promise { const chatId = requireNonEmpty(input.chatId, "chat id"); const project = await prisma.project.findUnique({ where: { id: input.projectId }, select: { id: true, organizationId: true, folderId: true, workspaceDir: true }, }); if (project === null) throw new Error(`project not found: ${input.projectId}`); const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId); await prisma.$transaction(async (tx) => { await requireActiveOrganizationTx(tx, project.organizationId); const defaultRole = await requireDefaultAgentRole(tx, project.organizationId); const activeProjectBinding = await tx.projectGroupBinding.findFirst({ where: { projectId: project.id, archivedAt: null }, select: { chatId: true }, }); if (activeProjectBinding !== null) { throw new Error(`project ${project.id} is already bound to Feishu chat ${activeProjectBinding.chatId}`); } const activeChatBinding = await tx.projectGroupBinding.findFirst({ where: { chatId, archivedAt: null }, select: { projectId: true }, }); if (activeChatBinding !== null) { throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`); } await tx.projectGroupBinding.create({ data: { organizationId: project.organizationId, projectId: project.id, chatId, createdByUserId: actor.userId, selectedAgentRoleId: defaultRole.id, }, }); await replaceProjectGrant(tx, { projectId: project.id, principalType: "FEISHU_CHAT", principalId: scopedChatPrincipalId(actor.connectionId, chatId), role: "EDIT", createdByUserId: actor.userId, }); }); await writeAudit(prisma, { projectId: project.id, actorUserId: actor.userId, action: "project.chat_bound", metadata: { chatId, actorVia: actor.via }, }); return { projectId: project.id, organizationId: project.organizationId, folderId: project.folderId, workspaceDir: project.workspaceDir, chatId, }; } export async function archiveFeishuChatBinding( prisma: PrismaClient, input: ArchiveFeishuChatBindingInput, ): Promise<{ readonly archived: boolean; readonly projectId?: string | undefined; readonly chatId?: string | undefined }> { if ((input.projectId === undefined || input.projectId === "") && (input.chatId === undefined || input.chatId === "")) { throw new Error("archiveFeishuChatBinding requires projectId or chatId"); } if (input.projectId !== undefined && input.projectId !== "" && input.chatId !== undefined && input.chatId !== "") { throw new Error("archiveFeishuChatBinding accepts only one of projectId or chatId"); } const binding = await prisma.projectGroupBinding.findFirst({ where: { archivedAt: null, ...(input.projectId !== undefined && input.projectId !== "" ? { projectId: input.projectId } : { chatId: input.chatId! }), }, select: { id: true, projectId: true, chatId: true, project: { select: { organizationId: true } } }, }); if (binding === null) return { archived: false }; const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId); await prisma.$transaction(async (tx) => { await requireActiveOrganizationTx(tx, binding.project.organizationId); await tx.projectGroupBinding.update({ where: { id: binding.id }, data: { archivedAt: new Date() }, }); await tx.permissionGrant.updateMany({ where: { resourceType: "PROJECT", resourceId: binding.projectId, principalType: "FEISHU_CHAT", principalId: scopedChatPrincipalId(actor.connectionId, binding.chatId), revokedAt: null, }, data: { revokedAt: new Date() }, }); }); await writeAudit(prisma, { projectId: binding.projectId, actorUserId: actor.userId, action: "project.chat_binding_archived", metadata: { chatId: binding.chatId, actorVia: actor.via }, }); return { archived: true, projectId: binding.projectId, chatId: binding.chatId }; } async function createManagedProject( prisma: PrismaClient, input: { readonly organizationId: string; readonly actorUserId: string; readonly actorPrincipalId: string; readonly feishuConnectionId: string | undefined; readonly name: string; readonly workspaceRoot: string; readonly folderId: string | undefined; readonly sortKey?: string | undefined; readonly projectId?: string | undefined; readonly chatId: string | undefined; }, ): Promise { const organization = await prisma.organization.findUnique({ where: { id: input.organizationId }, select: { id: true, slug: true, status: true }, }); if (organization === null) throw new Error(`organization not found: ${input.organizationId}`); requireActiveOrganizationStatus(organization.id, organization.status); const projectId = input.projectId ?? createProjectId(); const workspaceDir = projectWorkspaceDir({ workspaceRoot: input.workspaceRoot, organizationSlug: organization.slug, projectId, }); let workspaceCreated = false; let project: { readonly id: string; readonly organizationId: string; readonly folderId: string | null; readonly workspaceDir: string }; try { project = await prisma.$transaction(async (tx) => { await ensureOrganizationProjectSettingsTx(tx, organization.id); const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id; await assertFolderInOrganization(tx, folderId, organization.id); await mkdir(dirname(workspaceDir), { recursive: true }); await mkdir(workspaceDir); workspaceCreated = true; const created = await tx.project.create({ data: { id: projectId, organizationId: organization.id, folderId, name: input.name, workspaceDir, createdByUserId: input.actorUserId, }, select: { id: true, organizationId: true, folderId: true, workspaceDir: true }, }); await tx.permissionSettings.create({ data: defaultProjectPermissionSettings(created.id), }); await replaceProjectGrant(tx, { projectId: created.id, principalType: "USER", principalId: input.actorPrincipalId, role: "MANAGE", createdByUserId: input.actorUserId, }); if (input.chatId !== undefined) { const defaultRole = await requireDefaultAgentRole(tx, organization.id); await tx.projectGroupBinding.create({ data: { organizationId: organization.id, projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId, selectedAgentRoleId: defaultRole.id, }, }); await replaceProjectGrant(tx, { projectId: created.id, principalType: "FEISHU_CHAT", principalId: scopedChatPrincipalId(input.feishuConnectionId, input.chatId), role: "EDIT", createdByUserId: input.actorUserId, }); } return created; }); } catch (error) { if (workspaceCreated) { try { await rm(workspaceDir, { recursive: true }); } catch (cleanupError) { throw new AggregateError( [error, cleanupError], `project creation failed and workspace cleanup also failed: ${workspaceDir}`, ); } } throw error; } await writeAudit(prisma, { projectId: project.id, actorUserId: input.actorUserId, action: input.chatId === undefined ? "project.created" : "project.created_from_feishu_chat", metadata: { folderId: project.folderId, workspaceDir, chatId: input.chatId ?? null }, }); return { projectId: project.id, organizationId: project.organizationId, folderId: project.folderId, workspaceDir: project.workspaceDir, ...(input.chatId !== undefined ? { chatId: input.chatId } : {}), }; } async function requireProjectManager( prisma: PrismaClient, projectId: string, organizationId: string, actorFeishuOpenId: string, ): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole; readonly principalId: string; readonly connectionId: string | undefined; readonly via: "org-admin" | "project-manage"; }> { const actor = await requireActiveOrgMember(prisma, organizationId, actorFeishuOpenId); if (isOrgAdminRole(actor.role)) { return { ...actor, via: "org-admin" }; } const decision = await createPermissionAuthorizer(prisma).can({ actor: { feishuOpenId: actorFeishuOpenId }, action: "collaborator.manage", resource: { type: "PROJECT", id: projectId }, }); if (!decision.allowed) { throw new Error(`project ${projectId} requires MANAGE for ${actorFeishuOpenId}: ${decision.reason}`); } return { ...actor, via: "project-manage" }; } async function requireActiveOrgMember( prisma: PrismaClient, organizationId: string, feishuOpenId: string, ): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole; readonly principalId: string; readonly connectionId: string | undefined; }> { await requireActiveOrganization(prisma, organizationId); const identity = await prisma.feishuUserIdentity.findFirst({ where: { openId: feishuOpenId, connection: { organizationId, status: "ACTIVE" } }, select: { connectionId: true, user: { select: { id: true, organizationMemberships: { where: { organizationId, revokedAt: null }, select: { role: true }, take: 1, }, } }, }, }); if (identity !== null) { const membership = identity.user.organizationMemberships[0]; if (membership === undefined) { throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`); } return { userId: identity.user.id, role: membership.role, principalId: scopedFeishuPrincipalId("USER", identity.connectionId, feishuOpenId), connectionId: identity.connectionId, }; } // Explicit compatibility path for pre-ADR-0024 rows while the Silo branch // completes migration. New bootstrap/member writes always create identities. const user = process.env["NODE_ENV"] === "production" ? null : await prisma.user.findUnique({ where: { feishuOpenId }, select: { id: true, organizationMemberships: { where: { organizationId, revokedAt: null }, select: { role: true }, take: 1, }, }, }); if (user === null) { throw new Error(`Feishu user ${feishuOpenId} must log in before project onboarding`); } const membership = user.organizationMemberships[0]; if (membership === undefined) { throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`); } return { userId: user.id, role: membership.role, principalId: feishuOpenId, connectionId: undefined }; } function scopedChatPrincipalId(connectionId: string | undefined, chatId: string): string { return connectionId === undefined ? chatId : scopedFeishuPrincipalId("CHAT", connectionId, chatId); } async function ensureOrganizationProjectSettingsTx( prisma: Prisma.TransactionClient, organizationId: string, ): Promise { await requireActiveOrganizationTx(prisma, organizationId); return prisma.organizationProjectSettings.upsert({ where: { organizationId }, update: {}, create: { organizationId, membersCanCreateProjects: true }, select: { organizationId: true, membersCanCreateProjects: true }, }); } async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> { const existing = await prisma.folder.findFirst({ where: { organizationId, kind: "SYSTEM_INBOX", archivedAt: null }, select: { id: true }, }); if (existing !== null) return existing; return prisma.folder.create({ data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" }, select: { id: true }, }); } async function assertFolderInOrganization( prisma: Prisma.TransactionClient, folderId: string, organizationId: string, ): Promise { const folder = await prisma.folder.findUnique({ where: { id: folderId }, select: { organizationId: true, archivedAt: true }, }); if (folder === null || folder.archivedAt !== null) { throw new Error(`active folder not found: ${folderId}`); } if (folder.organizationId !== organizationId) { throw new Error(`folder ${folderId} is in ${folder.organizationId}, not organization ${organizationId}`); } } async function requireDefaultAgentRole( tx: Prisma.TransactionClient, organizationId: string, ): Promise<{ readonly id: string }> { const roles = await tx.organizationAgentRole.findMany({ where: { organizationId, isDefault: true, disabledAt: null }, select: { id: true }, take: 2, }); if (roles.length !== 1) { throw new Error(`organization ${organizationId} must have exactly one active default Agent role`); } return roles[0]!; } async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise { const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true, status: true }, }); if (organization === null) throw new Error(`organization not found: ${organizationId}`); requireActiveOrganizationStatus(organization.id, organization.status); } async function requireActiveOrganizationTx(prisma: Prisma.TransactionClient, organizationId: string): Promise { await lockActiveOrganization(prisma, organizationId); } async function replaceProjectGrant( prisma: Prisma.TransactionClient, input: { readonly projectId: string; readonly principalType: "USER" | "FEISHU_CHAT"; readonly principalId: string; readonly role: PermissionRole; readonly createdByUserId: string; }, ): Promise { await prisma.permissionGrant.updateMany({ where: { resourceType: "PROJECT", resourceId: input.projectId, principalType: input.principalType, principalId: input.principalId, revokedAt: null, }, data: { revokedAt: new Date() }, }); await prisma.permissionGrant.create({ data: { resourceType: "PROJECT", resourceId: input.projectId, principalType: input.principalType, principalId: input.principalId, role: input.role, createdByUserId: input.createdByUserId, }, }); } function defaultProjectPermissionSettings(projectId: string): Prisma.PermissionSettingsCreateInput { return { resourceType: "PROJECT", resourceId: projectId, externalShare: "DISABLED", comment: "ROLE", copyDownload: "ROLE", collaboratorMgmt: "MANAGE_ONLY", agentTrigger: "ROLE", agentCancel: "MANAGE_ONLY", }; } function isOrgAdminRole(role: OrganizationMemberRole): boolean { return role === "OWNER" || role === "ADMIN"; } function requireNonEmpty(value: string, label: string): string { const trimmed = value.trim(); if (trimmed === "") throw new Error(`${label} is required`); return trimmed; } function createProjectId(): string { return `project_${randomUUID().replaceAll("-", "")}`; } function projectWorkspaceDir(input: { readonly workspaceRoot: string; readonly organizationSlug: string; readonly projectId: string; }): string { const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root")); const dir = resolve( root, compactWorkspaceSegment("o", input.organizationSlug, "organization slug"), compactWorkspaceSegment("p", input.projectId, "project id"), ); const rel = relative(root, dir); if (rel === "" || rel.startsWith("..")) { throw new Error(`allocated workspace escapes root: ${dir}`); } return dir; } function compactWorkspaceSegment(prefix: "o" | "p", value: string, label: string): string { const normalized = safePathSegment(value, label); const digest = createHash("sha256").update(normalized).digest("base64url").slice(0, 16); return `${prefix}_${digest}`; } function safePathSegment(value: string, label: string): string { const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_"); if (segment === "." || segment === ".." || segment === "") { throw new Error(`${label} is not a safe path segment`); } return segment; }