/** * 文件库树服务(Phase 1 服务层,Phase 2 路由直接调用)。 * * 语义锚定: * - D8 无权限 → 404 不泄露;越权 → 403(loadChain / requireAccess) * - D11 creator 不可变 + 自动 MANAGE grant;独立权限开关语义在 permission.ts * - D12 move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁防并发成环 * - D14 命名规则(model.ts)+ 活跃兄弟唯一(DB 部分唯一索引兜底) * - D15 删除只打标本节点;"任一祖先已删"即整支不可见 * - D17 breadcrumb 无 View 的祖先只给占位,不泄露名字 * - 树表示:parentId 权威;pathIds 为 id 编码的派生物化路径(name 不入路径, * rename 不重写后代;move 用一次前缀重写维护) * * 网站管理员(D19)= silo org 的 OWNER/ADMIN(契约 C4 的入驻适配):仅 root 创建 * 与 force_adjust 特权,不隐式穿透内容权限 —— 本文件所有读路径对它同样走 * effectiveRole,没有 admin 旁路。 */ import { randomUUID } from "node:crypto"; import path from "node:path"; import { Prisma } from "@prisma/client"; import type { PrismaClient, FileLibNode } from "@prisma/client"; import { FileLibError, nameKey, normalizeNodeName, type FileLibRole, } from "./model.js"; import { checkAccess, effectiveRole } from "./permission.js"; import type { GroupResolver } from "./groupResolver.js"; import type { VersionStore } from "./versionStore.js"; import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; export interface FileLibActor { readonly userId: string; /** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */ readonly isWebsiteAdmin: boolean; } export interface TreeServiceDeps { readonly prisma: PrismaClient; readonly groupResolver: GroupResolver; readonly versionStore: VersionStore; /** 文件库归属的 silo org(ADR-0020 租户隔离,一切查询 scope 到它)。 */ readonly organizationId: string; /** 项目 git 仓库的磁盘根目录;项目仓 = /。 */ readonly storageRoot: string; } /** 权限判定实际需要的最小依赖(grantService 等兄弟模块复用)。 */ export type AccessDeps = Pick; export interface InitialGrant { readonly principalType: "USER" | "GROUP"; readonly principalId: string; readonly role: FileLibRole; } type Tx = Prisma.TransactionClient; interface Chain { readonly node: FileLibNode; /** 根在前、直接父在后;不含 node 自身。 */ readonly ancestors: readonly FileLibNode[]; } /* ---------------------------------------------------------------- 内部工具 */ /** pathIds = "/rootId/.../selfId";切出祖先 id(不含 self)。 */ function ancestorIdsOf(node: FileLibNode): string[] { return node.pathIds.split("/").filter((seg) => seg !== "").slice(0, -1); } /** 取节点 + 祖先链(org scope);D15:自身或任一祖先已删 → 404。 */ async function loadVisibleChain( tx: Tx, organizationId: string, nodeId: string, ): Promise { const node = await tx.fileLibNode.findFirst({ where: { id: nodeId, organizationId } }); if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); const ancestorIds = ancestorIdsOf(node); const ancestors = ancestorIds.length === 0 ? [] : await tx.fileLibNode.findMany({ where: { organizationId, id: { in: ancestorIds } } }); if (node.deletedAt !== null || ancestors.some((a) => a.deletedAt !== null)) { // D15:已删子树对外"不存在"(D8 不泄露)。 throw new FileLibError(404, "node_not_found", "node not found"); } const byId = new Map(ancestors.map((a) => [a.id, a])); const ordered = ancestorIds .map((id) => byId.get(id)) .filter((a): a is FileLibNode => a !== undefined); return { node, ancestors: ordered }; } /** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */ async function resolveRole( tx: Tx, deps: AccessDeps, actor: FileLibActor, chain: Chain, ): Promise { const chainIds = [...chain.ancestors.map((a) => a.id), chain.node.id]; const grants = await tx.fileLibGrant.findMany({ where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } }, select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, }); let independentPermissionsEnabled = false; if (chain.node.kind === "PROJECT") { const settings = await tx.fileLibProjectSettings.findUnique({ where: { nodeId: chain.node.id }, select: { independentPermissionsEnabled: true }, }); independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false; } const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); return effectiveRole({ nodeId: chain.node.id, nodeKind: chain.node.kind, ancestorIds: chain.ancestors.map((a) => a.id), independentPermissionsEnabled, userId: actor.userId, groupIds, grants, }); } /** D8 门禁:loadVisibleChain + resolveRole + checkAccess,失败抛 FileLibError。 */ async function requireAccess( tx: Tx, deps: AccessDeps, actor: FileLibActor, nodeId: string, minRole: FileLibRole, ): Promise { const chain = await loadVisibleChain(tx, deps.organizationId, nodeId); const role = await resolveRole(tx, deps, actor, chain); const verdict = checkAccess(role, minRole); if (!verdict.allowed) { throw verdict.reason === "not_found" ? new FileLibError(404, "node_not_found", "node not found") : new FileLibError(403, "forbidden", `requires ${minRole}`); } return { ...chain, role: verdict.role }; } /** * 兄弟模块(grantService 等)共用的 tx 内门禁:在调用方自己的事务里做 * 权限校验,校验与后续写同一根事务绳,避免 check-tx / write-tx 之间的竞态。 */ export async function requireAccessInTx( tx: Tx, deps: AccessDeps, actor: FileLibActor, nodeId: string, minRole: FileLibRole, ): Promise { return requireAccess(tx, deps, actor, nodeId, minRole); } /** P2002(活跃兄弟名部分唯一索引)→ 409。 */ function rethrowNameConflict(error: unknown, name: string): never { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { throw new FileLibError(409, "name_conflict", `an active sibling named "${name}" already exists`); } throw error; } function nodeAction(kind: FileLibNode["kind"], verb: "Create" | "Rename" | "Move" | "Delete"): string { const table = kind === "PROJECT" ? { Create: FILE_LIB_AUDIT_ACTIONS.projectCreate, Rename: FILE_LIB_AUDIT_ACTIONS.projectRename, Move: FILE_LIB_AUDIT_ACTIONS.projectMove, Delete: FILE_LIB_AUDIT_ACTIONS.projectDelete } : { Create: FILE_LIB_AUDIT_ACTIONS.folderCreate, Rename: FILE_LIB_AUDIT_ACTIONS.folderRename, Move: FILE_LIB_AUDIT_ACTIONS.folderMove, Delete: FILE_LIB_AUDIT_ACTIONS.folderDelete }; return table[verb]; } function validateInitialGrants(actor: FileLibActor, grants: readonly InitialGrant[]): void { const seen = new Set(); for (const grant of grants) { const key = `${grant.principalType}:${grant.principalId}`; if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate grant principal: ${key}`); seen.add(key); if (grant.principalType === "USER" && grant.principalId === actor.userId) { throw new FileLibError(400, "duplicate_principal", "creator already holds MANAGE via the creator grant"); } // v1:不校验 group 存在性(C2 未提供批量校验口;给不存在 group 的授权天然无效,不危害)。 } } /* ---------------------------------------------------------------- 公共操作 */ export interface CreateNodeInput { readonly parentId: string | null; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly description?: string | undefined; readonly grants?: readonly InitialGrant[] | undefined; } /** * 创建文件夹/项目。root 创建仅网站管理员(契约 2.1);非 root 需父节点 EDIT+。 * creator 自动 MANAGE(D11);项目走 provisioning 状态机:PROVISIONING → init → READY。 */ export async function createNode( deps: TreeServiceDeps, actor: FileLibActor, input: CreateNodeInput, ): Promise { const name = normalizeNodeName(input.name); const initialGrants = input.grants ?? []; validateInitialGrants(actor, initialGrants); const id = randomUUID(); let pathIds: string; let storageDir: string | null = null; const created = await deps.prisma.$transaction(async (tx) => { if (input.parentId === null) { if (!actor.isWebsiteAdmin) { throw new FileLibError(403, "forbidden", "root creation requires website administrator"); } pathIds = `/${id}`; } else { const parent = await requireAccess(tx, deps, actor, input.parentId, "EDIT"); if (parent.node.kind !== "FOLDER") { throw new FileLibError(400, "invalid_parent", "projects cannot have children"); } pathIds = `${parent.node.pathIds}/${id}`; } if (input.kind === "PROJECT") { storageDir = path.join(deps.storageRoot, id); } let node: FileLibNode; try { node = await tx.fileLibNode.create({ data: { id, organizationId: deps.organizationId, parentId: input.parentId, kind: input.kind, name, nameLower: nameKey(name), pathIds, creatorId: actor.userId, provisionStatus: input.kind === "PROJECT" ? "PROVISIONING" : "READY", storageDir, ...(input.description !== undefined && input.description.trim() !== "" ? { description: input.description.trim() } : {}), }, }); } catch (error) { rethrowNameConflict(error, name); } await tx.fileLibGrant.create({ data: { organizationId: deps.organizationId, nodeId: id, principalType: "USER", principalId: actor.userId, role: "MANAGE", isCreatorGrant: true, createdByUserId: actor.userId, }, }); for (const grant of initialGrants) { await tx.fileLibGrant.create({ data: { organizationId: deps.organizationId, nodeId: id, principalType: grant.principalType, principalId: grant.principalId, role: grant.role, createdByUserId: actor.userId, }, }); } if (input.kind === "PROJECT") { await tx.fileLibProjectSettings.create({ data: { nodeId: id, independentPermissionsEnabled: false }, }); } await writeFileLibAudit(tx, { action: nodeAction(input.kind, "Create"), actorUserId: actor.userId, organizationId: deps.organizationId, objectType: input.kind === "PROJECT" ? "project" : "folder", objectId: id, objectPath: pathIds, detail: { name, parentId: input.parentId, initialGrants: initialGrants.length }, }); for (const grant of initialGrants) { await writeFileLibAudit(tx, { action: FILE_LIB_AUDIT_ACTIONS.permissionGrant, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "grant", objectId: id, objectPath: pathIds, detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role }, }); } return node; }); // provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。 if (input.kind === "PROJECT" && storageDir !== null) { try { await deps.versionStore.init(storageDir); return await deps.prisma.fileLibNode.update({ where: { id: created.id }, data: { provisionStatus: "READY" }, }); } catch (error) { await deps.prisma.fileLibNode .update({ where: { id: created.id }, data: { provisionStatus: "FAILED" } }) .catch(() => undefined); throw new FileLibError(500, "provision_failed", `repository initialization failed: ${String(error)}`); } } return created; } /** 重命名(需本节点 MANAGE,契约 8.2)。id 路径不含 name,后代无需重写。 */ export async function renameNode( deps: TreeServiceDeps, actor: FileLibActor, nodeId: string, rawName: string, ): Promise { const name = normalizeNodeName(rawName); return deps.prisma.$transaction(async (tx) => { const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE"); let updated: FileLibNode; try { updated = await tx.fileLibNode.update({ where: { id: node.id }, data: { name, nameLower: nameKey(name) }, }); } catch (error) { rethrowNameConflict(error, name); } await writeFileLibAudit(tx, { action: nodeAction(node.kind, "Rename"), actorUserId: actor.userId, organizationId: deps.organizationId, objectType: node.kind === "PROJECT" ? "project" : "folder", objectId: node.id, objectPath: node.pathIds, detail: { from: node.name, to: name }, }); return updated; }); } /** * 移动(D12):本节点 MANAGE + 目标父 EDIT+(移到 root 需网站管理员); * 事务 + org 级咨询锁防并发成环;后代 pathIds 一次前缀重写。 */ export async function moveNode( deps: TreeServiceDeps, actor: FileLibActor, nodeId: string, newParentId: string | null, ): Promise { return deps.prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${"filelib:tree:" + deps.organizationId}))`; const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE"); if (node.parentId === newParentId) return node; let newPathIds: string; if (newParentId === null) { if (!actor.isWebsiteAdmin) { throw new FileLibError(403, "forbidden", "moving to root requires website administrator"); } newPathIds = `/${node.id}`; } else { const parent = await requireAccess(tx, deps, actor, newParentId, "EDIT"); if (parent.node.kind !== "FOLDER") { throw new FileLibError(400, "invalid_parent", "projects cannot have children"); } if (parent.node.id === node.id || parent.node.pathIds.startsWith(`${node.pathIds}/`)) { throw new FileLibError(400, "move_into_own_subtree", "cannot move a node into its own subtree"); } newPathIds = `${parent.node.pathIds}/${node.id}`; } const oldPrefix = node.pathIds; let updated: FileLibNode; try { updated = await tx.fileLibNode.update({ where: { id: node.id }, data: { parentId: newParentId, pathIds: newPathIds }, }); // 派生列维护:整支后代的前缀重写(id 编码,与 name 无关)。 await tx.$executeRaw` UPDATE "FileLibNode" SET "pathIds" = ${newPathIds} || substring("pathIds" from ${oldPrefix.length + 1}::int) WHERE "organizationId" = ${deps.organizationId} AND "pathIds" LIKE ${oldPrefix + "/%"} `; } catch (error) { rethrowNameConflict(error, node.name); } await writeFileLibAudit(tx, { action: nodeAction(node.kind, "Move"), actorUserId: actor.userId, organizationId: deps.organizationId, objectType: node.kind === "PROJECT" ? "project" : "folder", objectId: node.id, objectPath: newPathIds, detail: { fromParentId: node.parentId, toParentId: newParentId }, }); return updated; }); } /** 软删除(D15):只打标本节点,后代靠"任一祖先已删"过滤;需 MANAGE。 */ export async function softDeleteNode( deps: TreeServiceDeps, actor: FileLibActor, nodeId: string, ): Promise { await deps.prisma.$transaction(async (tx) => { const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE"); await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: new Date() } }); await writeFileLibAudit(tx, { action: nodeAction(node.kind, "Delete"), actorUserId: actor.userId, organizationId: deps.organizationId, objectType: node.kind === "PROJECT" ? "project" : "folder", objectId: node.id, objectPath: node.pathIds, detail: { name: node.name }, }); }); } /** 自查生效权限(契约 9.2 effective-permission)。D8:null 角色即不可见,404。 */ export async function getEffectiveRole( deps: TreeServiceDeps, actor: FileLibActor, nodeId: string, ): Promise { return deps.prisma.$transaction(async (tx) => { const chain = await loadVisibleChain(tx, deps.organizationId, nodeId); const role = await resolveRole(tx, deps, actor, chain); if (role === null) throw new FileLibError(404, "node_not_found", "node not found"); return role; }); } export interface BreadcrumbEntry { readonly depth: number; /** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */ readonly id: string | null; readonly name: string | null; readonly kind: "FOLDER" | "PROJECT"; } /** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */ export async function breadcrumb( deps: TreeServiceDeps, actor: FileLibActor, nodeId: string, ): Promise { return deps.prisma.$transaction(async (tx) => { const chain = await loadVisibleChain(tx, deps.organizationId, nodeId); const selfRole = await resolveRole(tx, deps, actor, chain); if (checkAccess(selfRole, "VIEW").allowed !== true) { throw new FileLibError(404, "node_not_found", "node not found"); } const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); const chainNodes = [...chain.ancestors, chain.node]; const chainIds = chainNodes.map((n) => n.id); const allGrants = await tx.fileLibGrant.findMany({ where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } }, select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, }); const settings = chain.node.kind === "PROJECT" ? await tx.fileLibProjectSettings.findUnique({ where: { nodeId: chain.node.id }, select: { independentPermissionsEnabled: true }, }) : null; return chainNodes.map((current, depth) => { const role = effectiveRole({ nodeId: current.id, nodeKind: current.kind, ancestorIds: chainNodes.slice(0, depth).map((n) => n.id), independentPermissionsEnabled: current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false, userId: actor.userId, groupIds, grants: allGrants, }); const visible = role !== null; return { depth, id: visible ? current.id : null, name: visible ? current.name : null, kind: current.kind, }; }); }); } export interface ChildNodeDto { readonly id: string; readonly parentId: string | null; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly role: FileLibRole; readonly createdAt: Date; readonly updatedAt: Date; } /** 列子节点(parentId=null 列 root);只返回调用者有 View 的(D8/P7)。 */ export async function listChildren( deps: TreeServiceDeps, actor: FileLibActor, parentId: string | null, ): Promise { return deps.prisma.$transaction(async (tx) => { let parentAncestorIds: string[] = []; if (parentId !== null) { const parent = await requireAccess(tx, deps, actor, parentId, "VIEW"); parentAncestorIds = [...parent.ancestors.map((a) => a.id), parent.node.id]; } const children = await tx.fileLibNode.findMany({ where: { organizationId: deps.organizationId, parentId, deletedAt: null }, orderBy: [{ kind: "asc" }, { nameLower: "asc" }], }); if (children.length === 0) return []; // D13:一次请求只 resolve 一次组、拉一次 grant 集,批量计算,不做 per-child 往返。 const idsToFetch = [...parentAncestorIds, ...children.map((c) => c.id)]; const allGrants = await tx.fileLibGrant.findMany({ where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } }, select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, }); const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id); const settingsRows = projectIds.length === 0 ? [] : await tx.fileLibProjectSettings.findMany({ where: { nodeId: { in: projectIds } }, select: { nodeId: true, independentPermissionsEnabled: true }, }); const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled])); const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); const out: ChildNodeDto[] = []; for (const child of children) { const role = effectiveRole({ nodeId: child.id, nodeKind: child.kind, ancestorIds: parentAncestorIds, independentPermissionsEnabled: toggleByNode.get(child.id) ?? false, userId: actor.userId, groupIds, grants: allGrants, }); if (role === null) continue; out.push({ id: child.id, parentId: child.parentId, kind: child.kind, name: child.name, role, createdAt: child.createdAt, updatedAt: child.updatedAt, }); } return out; }); } /** 更新节点简介(需 EDIT+;不记审计,非权限敏感的内容字段)。 */ export async function updateNodeDescription( deps: TreeServiceDeps, actor: FileLibActor, nodeId: string, description: string | null, ): Promise { return deps.prisma.$transaction(async (tx) => { const { node } = await requireAccessInTx(tx, deps, actor, nodeId, "EDIT"); return tx.fileLibNode.update({ where: { id: node.id }, data: { description: description?.trim() || null }, }); }); }