feat(database): init database folder frontend and permission

This commit is contained in:
ymy
2026-07-23 23:41:11 +08:00
parent 5df1900ca8
commit 4021e58d5d
67 changed files with 9436 additions and 150 deletions
+72
View File
@@ -0,0 +1,72 @@
/**
* 文件库审计 sink(契约 C3 的入驻适配)。
*
* 契约原文:本地 outbox 表(与业务同事务)→ 中继 POST 到独立审计服务。
* 入驻 hub 后的适配:审计同事 = 本库 AuditEntry,与业务写在同一 Prisma 事务
* 内落库 —— 同库同事务天然满足"操作成功则日志必存在",比 outbox+relay 更强。
* 若审计团队日后独立成服务,只换本文件的实现,action 词汇表保持不变。
*/
import type { Prisma } from "@prisma/client";
/** C3 §6.3:文件库审计动作词汇表(与契约文档逐条对应,改词需升契约版本)。 */
export const FILE_LIB_AUDIT_ACTIONS = {
folderCreate: "folder.create",
folderRename: "folder.rename",
folderMove: "folder.move",
folderDelete: "folder.delete",
projectCreate: "project.create",
projectRename: "project.rename",
projectMove: "project.move",
projectDelete: "project.delete",
permissionGrant: "permission.grant",
permissionUpdate: "permission.update",
permissionRevoke: "permission.revoke",
independentEnable: "project.independent_permission.enable",
independentDisable: "project.independent_permission.disable",
independentChange: "project.independent_permission.change",
fileUpload: "file.upload",
fileRename: "file.rename",
fileDelete: "file.delete",
fileCommit: "file.commit",
fileConflictDetected: "file.conflict_detected",
exportRun: "export.run",
adminForceAdjust: "admin.force_adjust",
} as const;
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job";
export interface FileLibAuditEntry {
readonly action: string;
readonly actorUserId: string;
readonly organizationId: string;
readonly objectType: FileLibAuditObjectType;
readonly objectId: string;
/** 节点 id 路径(pathIds)或项目内文件路径,便于按路径检索。 */
readonly objectPath: string;
readonly detail?: Record<string, unknown> | undefined;
}
/**
* 在调用方的事务里写一条审计。刻意不吞错:写不出来整个业务操作回滚
* (需求 5.1"操作成功则日志必存在"的强保证)。
*/
export async function writeFileLibAudit(
tx: Prisma.TransactionClient,
entry: FileLibAuditEntry,
): Promise<void> {
const metadata: Record<string, unknown> = {
objectType: entry.objectType,
objectId: entry.objectId,
objectPath: entry.objectPath,
...(entry.detail ?? {}),
};
await tx.auditEntry.create({
data: {
action: entry.action,
actorUserId: entry.actorUserId,
organizationId: entry.organizationId,
metadata: metadata as Prisma.InputJsonValue,
},
});
}
+195
View File
@@ -0,0 +1,195 @@
/**
* 导出(契约 D10):异步任务 + 状态机 QUEUED → RUNNING → DONE/FAILED。
*
* ExportAdapter 是外部导出工具的 port(参数清单 OPEN-6,真身到位后替换)。
* 当前 stub 适配器产出"文件清单 manifest"文本,证明状态机端到端可跑;
* 产物存进程内存(v1 stub;生产应落对象存储/磁盘 —— 见 OPEN 清单)。
*/
import { randomUUID } from "node:crypto";
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import { requireAccessInTx, type FileLibActor } from "./treeService.js";
import type { FileDeps } from "./fileService.js";
export interface ExportAdapterInput {
readonly storageDir: string;
readonly target: string;
readonly params: Record<string, unknown>;
readonly listFiles: (prefix?: string) => Promise<readonly { path: string; size: number }[]>;
readonly readFile: (path: string) => Promise<Buffer>;
}
export interface ExportArtifact {
readonly filename: string;
readonly content: Buffer;
}
export interface ExportAdapter {
readonly target: string;
run(input: ExportAdapterInput): Promise<ExportArtifact>;
}
/** stub 适配器:生成项目文件清单,端到端验证 job 状态机。OPEN-6 后换真导出工具。 */
export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]): ExportAdapter {
return {
target: "manifest",
async run(input) {
const files = await input.listFiles();
const lines = [
`# Export manifest (stub adapter)`,
`target: ${input.target}`,
`storageDir: ${input.storageDir}`,
`files: ${files.length}`,
``,
...files.map((f) => `${String(f.size).padStart(10)} ${f.path}`),
];
return { filename: "manifest.txt", content: Buffer.from(lines.join("\n"), "utf8") };
},
};
}
// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。
const artifacts = new Map<string, ExportArtifact>();
export interface ExportDeps extends FileDeps {
readonly adapters: readonly ExportAdapter[];
}
export interface ExportJobDto {
readonly id: string;
readonly nodeId: string;
readonly target: string;
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
readonly error: string | null;
readonly createdAt: Date;
}
/** 提交导出(需 VIEW):建行(QUEUED)+ export.run 审计,同事务;异步执行。 */
export async function submitExport(
deps: ExportDeps,
actor: FileLibActor,
projectId: string,
target: string,
params: Record<string, unknown>,
): Promise<ExportJobDto> {
const { node } = await deps.prisma.$transaction(async (tx) =>
requireAccessInTx(tx, deps, actor, projectId, "VIEW"),
);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "export applies to projects only");
}
const adapter = deps.adapters.find((a) => a.target === target);
if (adapter === undefined) {
throw new FileLibError(400, "unknown_target", `no export adapter for target "${target}"`);
}
if (node.storageDir === null) {
throw new FileLibError(409, "project_not_ready", "project repository is not ready");
}
const jobId = randomUUID();
const job = await deps.prisma.$transaction(async (tx) => {
const created = await tx.fileLibExportJob.create({
data: {
id: jobId,
organizationId: deps.organizationId,
nodeId: node.id,
target,
params: params as never,
status: "QUEUED",
createdByUserId: actor.userId,
},
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.exportRun,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "export_job",
objectId: jobId,
objectPath: node.pathIds,
detail: { target, params },
});
return created;
});
const storageDir = node.storageDir;
setImmediate(() => {
void runExportJob(deps, adapter, jobId, storageDir, target, params).catch(() => undefined);
});
return toDto(job);
}
async function runExportJob(
deps: ExportDeps,
adapter: ExportAdapter,
jobId: string,
storageDir: string,
target: string,
params: Record<string, unknown>,
): Promise<void> {
await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "RUNNING" } });
try {
const artifact = await adapter.run({
storageDir,
target,
params,
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
readFile: (path) => deps.versionStore.read(storageDir, path),
});
artifacts.set(jobId, artifact);
await deps.prisma.fileLibExportJob.update({
where: { id: jobId },
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
});
} catch (error) {
await deps.prisma.fileLibExportJob.update({
where: { id: jobId },
data: { status: "FAILED", error: String(error) },
});
}
}
export async function getExportJob(
deps: ExportDeps,
actor: FileLibActor,
jobId: string,
): Promise<ExportJobDto> {
const job = await deps.prisma.fileLibExportJob.findFirst({
where: { id: jobId, organizationId: deps.organizationId },
});
if (job === null) throw new FileLibError(404, "export_not_found", "export job not found");
// D8:对源项目无 View → 404(不泄露 job 存在性)。
await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, job.nodeId, "VIEW"));
return toDto(job);
}
export async function downloadExport(
deps: ExportDeps,
actor: FileLibActor,
jobId: string,
): Promise<ExportArtifact> {
await getExportJob(deps, actor, jobId);
const artifact = artifacts.get(jobId);
if (artifact === undefined) {
throw new FileLibError(409, "export_not_ready", "export artifact is not available");
}
return artifact;
}
function toDto(job: {
id: string;
nodeId: string;
target: string;
status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
error: string | null;
createdAt: Date;
}): ExportJobDto {
return {
id: job.id,
nodeId: job.nodeId,
target: job.target,
status: job.status,
error: job.error,
createdAt: job.createdAt,
};
}
+275
View File
@@ -0,0 +1,275 @@
/**
* 文件内容服务(Phase 3):路径安全 + 版本化文件操作 + 审计。
*
* 顺序铁律(Metis 风险#1 的落地):
* - 内容写:先 versionStore.commit(业务事实本体)→ 再 DB 事务(审计)。
* 宁多一个无审计的版本,不造一条假审计。
* - conflict:写 file.conflict_detected(冲突本身就是事件),再抛 409。
* - 读:随取随读,不写审计(需求 5.2 未列读操作)。
*/
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import type { CommitResult, FileEntry, VersionInfo, VersionStore } from "./versionStore.js";
import type { AccessDeps, FileLibActor } from "./treeService.js";
import { requireAccessInTx } from "./treeService.js";
import type { FileLibNode, PrismaClient } from "@prisma/client";
export const FILE_PATH_MAX_LENGTH = 512;
export const FILE_PATH_MAX_DEPTH = 32;
export const FILE_CONTENT_MAX_BYTES = 10 * 1024 * 1024; // OPEN-5 初值
const CONTROL_CHARS = /[\p{C}]/u;
const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]);
/**
* 路径安全(Metis 安全红线):NFC;拒绝反斜杠、控制字符、空段、
* "." / ".." / ".git" 段、绝对路径、超长/超深。返回规范化相对路径。
*/
export function validateFilePath(raw: string): string {
const normalized = raw.normalize("NFC");
if (normalized.length === 0 || normalized.length > FILE_PATH_MAX_LENGTH) {
throw new FileLibError(400, "invalid_path", `path must be 1..${FILE_PATH_MAX_LENGTH} characters`);
}
if (normalized.includes("\\")) {
throw new FileLibError(400, "invalid_path", "path must use '/' separators");
}
if (CONTROL_CHARS.test(normalized)) {
throw new FileLibError(400, "invalid_path", "path must not contain control characters");
}
const segments = normalized.split("/");
if (segments.length > FILE_PATH_MAX_DEPTH) {
throw new FileLibError(400, "invalid_path", `path depth exceeds ${FILE_PATH_MAX_DEPTH}`);
}
for (const segment of segments) {
if (FORBIDDEN_SEGMENTS.has(segment)) {
throw new FileLibError(400, "invalid_path", `forbidden path segment: "${segment}"`);
}
}
return normalized;
}
export interface FileDeps extends AccessDeps {
readonly prisma: PrismaClient;
readonly versionStore: VersionStore;
}
type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string };
async function requireProject(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
minRole: "VIEW" | "EDIT",
): Promise<ProjectChain> {
const { node } = await deps.prisma.$transaction(async (tx) =>
requireAccessInTx(tx, deps, actor, projectId, minRole),
);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "file operations apply to projects only");
}
if (node.provisionStatus === "PROVISIONING") {
throw new FileLibError(409, "project_not_ready", "project repository is still provisioning");
}
if (node.provisionStatus === "FAILED") {
throw new FileLibError(409, "project_not_ready", "project repository provisioning failed");
}
if (node.storageDir === null) {
throw new FileLibError(500, "storage_missing", "project has no storage directory");
}
return { node, storageDir: node.storageDir };
}
export type FileContentEncoding = "utf8" | "base64";
export interface FileContentDto {
readonly path: string;
readonly version: string;
readonly encoding: FileContentEncoding;
readonly content: string;
readonly size: number;
}
export function decodeContent(content: string, encoding: FileContentEncoding): string | Buffer {
return encoding === "base64" ? Buffer.from(content, "base64") : content;
}
function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding; readonly content: string } {
// 粗判二进制:含 NUL 字节即按 base64 返回(需求 2.5 在线编辑仅针对文本)。
return buffer.includes(0)
? { encoding: "base64", content: buffer.toString("base64") }
: { encoding: "utf8", content: buffer.toString("utf8") };
}
function checkSize(content: string | Buffer): void {
const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
if (bytes > FILE_CONTENT_MAX_BYTES) {
throw new FileLibError(413, "file_too_large", `file exceeds ${FILE_CONTENT_MAX_BYTES} bytes`);
}
}
async function auditFile(
deps: FileDeps,
actor: FileLibActor,
action: string,
project: ProjectChain,
filePath: string,
detail: Record<string, unknown>,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
await writeFileLibAudit(tx, {
action,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "file",
objectId: project.node.id,
objectPath: `${project.node.pathIds}:${filePath}`,
detail,
});
});
}
/* ---------------------------------------------------------------- 读操作 */
export async function listFiles(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
prefix?: string,
): Promise<readonly FileEntry[]> {
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.list(project.storageDir, prefix === undefined ? undefined : validateFilePath(prefix));
}
export async function readFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
): Promise<FileContentDto> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
const [version, buffer] = await Promise.all([
deps.versionStore.head(project.storageDir, filePath),
deps.versionStore.read(project.storageDir, filePath),
]);
const { encoding, content } = encodeContent(buffer);
return { path: filePath, version, encoding, content, size: buffer.byteLength };
}
/** 原始字节下载(浏览器 save-as 用):JSON 之外的第二条读取通道,同样的 VIEW 门禁。 */
export async function readFileRaw(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
): Promise<{ readonly buffer: Buffer; readonly version: string; readonly filename: string }> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
const [version, buffer] = await Promise.all([
deps.versionStore.head(project.storageDir, filePath),
deps.versionStore.read(project.storageDir, filePath),
]);
return { buffer, version, filename: filePath.split("/").pop() ?? "download" };
}
export async function fileHistory(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
limit?: number,
): Promise<readonly VersionInfo[]> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.history(project.storageDir, filePath, limit);
}
export async function diffFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
from: string,
to: string,
): Promise<{ readonly diff: string }> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
return { diff: await deps.versionStore.diff(project.storageDir, filePath, from, to) };
}
/* ---------------------------------------------------------------- 写操作 */
export interface CommitInput {
readonly path: string;
/** null = 新建(已存在则 409);编辑时传 readFile 拿到的 version。 */
readonly baseVersion: string | null;
readonly content: string;
readonly encoding?: FileContentEncoding | undefined;
readonly message?: string | undefined;
}
/**
* 统一写入口(上传/编辑共用):先 commit,成功写 file.commit / file.upload 审计;
* 冲突写 file.conflict_detected 后抛 409(details 带 currentVersion,编辑 UI 用它拉 diff)。
*/
export async function commitFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
input: CommitInput,
): Promise<{ readonly version: string }> {
const filePath = validateFilePath(input.path);
const content = decodeContent(input.content, input.encoding ?? "utf8");
checkSize(content);
const project = await requireProject(deps, actor, projectId, "EDIT");
const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, {
baseVersion: input.baseVersion,
content,
message: input.message,
author: actor.userId,
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion: input.baseVersion,
currentVersion: result.currentVersion,
});
throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", {
currentVersion: result.currentVersion,
});
}
await auditFile(
deps,
actor,
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
project,
filePath,
{ version: result.version, message: input.message ?? null },
);
return { version: result.version };
}
export async function deleteFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
baseVersion: string,
): Promise<void> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "EDIT");
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion);
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion,
currentVersion: result.currentVersion,
});
throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", {
currentVersion: result.currentVersion,
});
}
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, { baseVersion });
}
+319
View File
@@ -0,0 +1,319 @@
/**
* 授权管理(契约 8.1 矩阵的服务端强制)。
*
* 矩阵:
* - 创建者(creatorId 不可变):可授/改/收 MANAGE、EDIT、VIEW;自身 creator grant 不可动
* - MANAGE 持有者:可授/改/收 EDIT、VIEW;不可碰 MANAGE;不可动创建者
* - EDIT/VIEW:无授权能力(requireAccess MANAGE 已挡)
* - 网站管理员:走 forceAdjustGrants(不查节点权限,D19),全部留 admin.force_adjust 审计
*
* D8:目标节点不可见/无权限 → 404;有权限但矩阵禁止 → 403。
*/
import { Prisma } from "@prisma/client";
import type { FileLibGrant, FileLibNode, PrismaClient } from "@prisma/client";
import { FileLibError, type FileLibRole } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import {
requireAccessInTx,
type AccessDeps,
type FileLibActor,
type InitialGrant,
} from "./treeService.js";
export interface GrantDto {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: FileLibRole;
readonly isCreatorGrant: boolean;
readonly createdAt: Date;
}
function toDto(grant: FileLibGrant): GrantDto {
return {
id: grant.id,
principalType: grant.principalType,
principalId: grant.principalId,
role: grant.role,
isCreatorGrant: grant.isCreatorGrant,
createdAt: grant.createdAt,
};
}
type Tx = Prisma.TransactionClient;
type Deps = AccessDeps & { readonly prisma: PrismaClient };
/** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */
async function requireManage(
deps: Deps,
actor: FileLibActor,
nodeId: string,
tx?: Tx,
): Promise<{ readonly node: FileLibNode; readonly role: FileLibRole }> {
if (tx !== undefined) return requireAccessInTx(tx, deps, actor, nodeId, "MANAGE");
return deps.prisma.$transaction(async (inner) => requireAccessInTx(inner, deps, actor, nodeId, "MANAGE"));
}
/** 列出节点活跃授权(需 MANAGE)。 */
export async function listGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
): Promise<readonly GrantDto[]> {
await requireManage(deps, actor, nodeId);
const grants = await deps.prisma.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return grants.map(toDto);
}
export interface PutGrantsResult {
readonly granted: number;
readonly updated: number;
readonly grants: readonly GrantDto[];
}
/**
* 批量授予/修改(upsert 语义):同 principal 已有活跃授权 → 改级别(permission.update);
* 没有 → 新建(permission.grant)。8.1 矩阵在写之前整体校验。
*/
export async function putGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
items: readonly InitialGrant[],
): Promise<PutGrantsResult> {
validateGrantItems(items);
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
const isCreator = node.creatorId === actor.userId;
for (const item of items) {
if (item.role === "MANAGE" && !isCreator) {
throw new FileLibError(403, "only_creator_can_grant_manage", "only the creator can grant MANAGE");
}
if (item.principalType === "USER" && item.principalId === node.creatorId) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
}
let granted = 0;
let updated = 0;
for (const item of items) {
const existing = await tx.fileLibGrant.findFirst({
where: {
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
revokedAt: null,
},
});
if (existing !== null) {
if (existing.isCreatorGrant) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
if (existing.role !== item.role) {
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
updated += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionUpdate, node.id, node.pathIds, { ...item });
}
} else {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
createdByUserId: actor.userId,
},
});
granted += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node.id, node.pathIds, { ...item });
}
}
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: grants.map(toDto) };
});
}
/** 收回授权(需 MANAGE;creator grant 与 MANAGE grant 有额外限制,见 8.1)。 */
export async function revokeGrant(
deps: Deps,
actor: FileLibActor,
nodeId: string,
grantId: string,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
const grant = await tx.fileLibGrant.findFirst({
where: { id: grantId, nodeId: node.id, revokedAt: null },
});
if (grant === null) throw new FileLibError(404, "grant_not_found", "grant not found");
if (grant.isCreatorGrant) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
if (grant.role === "MANAGE" && node.creatorId !== actor.userId) {
throw new FileLibError(403, "only_creator_can_revoke_manage", "only the creator can revoke MANAGE");
}
await tx.fileLibGrant.update({ where: { id: grant.id }, data: { revokedAt: new Date() } });
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionRevoke, node.id, node.pathIds, {
principalType: grant.principalType,
principalId: grant.principalId,
role: grant.role,
});
});
}
/**
* 网站管理员强制调整(D19):凭 node id 操作,不查操作者节点权限;矩阵豁免;
* 每一条变更都落 admin.force_adjust 审计(高危留痕)。
*/
export async function forceAdjustGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
items: readonly InitialGrant[],
): Promise<PutGrantsResult> {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "force adjust requires website administrator");
}
validateGrantItems(items);
return deps.prisma.$transaction(async (tx) => {
const node = await tx.fileLibNode.findFirst({
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: null },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
let granted = 0;
let updated = 0;
for (const item of items) {
const existing = await tx.fileLibGrant.findFirst({
where: {
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
revokedAt: null,
},
});
if (existing !== null) {
if (existing.role !== item.role) {
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
updated += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
change: "update",
principalType: item.principalType,
principalId: item.principalId,
from: existing.role,
to: item.role,
});
}
} else {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
createdByUserId: actor.userId,
},
});
granted += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
change: "grant",
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
});
}
}
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: grants.map(toDto) };
});
}
/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */
export async function setIndependentPermission(
deps: Deps,
actor: FileLibActor,
nodeId: string,
enabled: boolean,
): Promise<{ readonly enabled: boolean }> {
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only");
}
const current = await tx.fileLibProjectSettings.findUnique({
where: { nodeId: node.id },
select: { independentPermissionsEnabled: true },
});
if ((current?.independentPermissionsEnabled ?? false) === enabled) {
return { enabled }; // 状态未变:空操作,不产生审计
}
await tx.fileLibProjectSettings.upsert({
where: { nodeId: node.id },
update: { independentPermissionsEnabled: enabled },
create: { nodeId: node.id, independentPermissionsEnabled: enabled },
});
await writeFileLibAudit(tx, {
action: enabled
? FILE_LIB_AUDIT_ACTIONS.independentEnable
: FILE_LIB_AUDIT_ACTIONS.independentDisable,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "project",
objectId: node.id,
objectPath: node.pathIds,
detail: { enabled },
});
return { enabled };
});
}
function validateGrantItems(items: readonly InitialGrant[]): void {
if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty");
const seen = new Set<string>();
for (const item of items) {
if (item.principalType !== "USER" && item.principalType !== "GROUP") {
throw new FileLibError(400, "invalid_request", `bad principalType: ${String(item.principalType)}`);
}
if (item.role !== "VIEW" && item.role !== "EDIT" && item.role !== "MANAGE") {
throw new FileLibError(400, "invalid_request", `bad role: ${String(item.role)}`);
}
if (item.principalId.trim() === "") {
throw new FileLibError(400, "invalid_request", "principalId must not be empty");
}
const key = `${item.principalType}:${item.principalId}`;
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate principal: ${key}`);
seen.add(key);
}
}
async function audit(
tx: Prisma.TransactionClient,
deps: Deps,
actor: FileLibActor,
action: string,
nodeId: string,
pathIds: string,
detail: Record<string, unknown>,
): Promise<void> {
await writeFileLibAudit(tx, {
action,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "grant",
objectId: nodeId,
objectPath: pathIds,
detail,
});
}
+48
View File
@@ -0,0 +1,48 @@
/**
* GroupResolver port(契约 C2)。
*
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
* Group 系统(需求系统二:全局、无限嵌套)由别的团队交付;调用方只依赖此
* port,真身到位后替换实现,不换调用点。
*/
import type { PrismaClient } from "@prisma/client";
export interface GroupResolver {
resolveMemberGroupIds(userId: string): Promise<readonly string[]>;
}
/**
* 过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
* 需求 3.2 的祖先递归语义在嵌套 Group 落地前无从谈起;此实现保证权限引擎
* 的 Group 通路今天就是真的,而不是 mock。
*/
export function createTeamGroupResolver(
prisma: PrismaClient,
organizationId: string,
): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
const memberships = await prisma.teamMembership.findMany({
where: {
userId,
revokedAt: null,
team: { organizationId, archivedAt: null },
},
select: { teamId: true },
});
return memberships.map((m) => m.teamId);
},
};
}
/** 单测 mock:静态 用户→组 映射。 */
export function createStaticGroupResolver(
map: Readonly<Record<string, readonly string[]>>,
): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
return map[userId] ?? [];
},
};
}
@@ -0,0 +1,49 @@
/**
* GroupResolver 的 HTTP 实现(契约 C2,Group 团队服务到位后启用,
* 经 HUB_GROUP_SERVICE_URL 配置)。
*
* 语义红线:
* - 失败 → FileLibError(503, group_unavailable)。依赖故障不是"无权限",
* 绝不伪装成 404/403(计划 D13)。
* - 我方绝不自己推祖先:返回什么用什么,不在本地补逻辑。
*/
import { FileLibError } from "./model.js";
import type { GroupResolver } from "./groupResolver.js";
export interface HttpGroupResolverConfig {
readonly baseUrl: string;
readonly timeoutMs?: number;
/** 测试可注入假 fetch;生产用全局 fetch。 */
readonly fetchFn?: typeof fetch;
}
export function createHttpGroupResolver(config: HttpGroupResolverConfig): GroupResolver {
const timeoutMs = config.timeoutMs ?? 2_000;
const fetchFn = config.fetchFn ?? fetch;
return {
async resolveMemberGroupIds(userId) {
const url = `${config.baseUrl.replace(/\/$/, "")}/groups/resolve-member-groups?userId=${encodeURIComponent(userId)}`;
let response: Response;
try {
response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) });
} catch (error) {
throw new FileLibError(503, "group_unavailable", `group service unreachable: ${String(error)}`);
}
if (!response.ok) {
throw new FileLibError(503, "group_unavailable", `group service returned ${response.status}`);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new FileLibError(503, "group_unavailable", "group service returned malformed JSON");
}
const groupIds = (body as { groupIds?: unknown }).groupIds;
if (!Array.isArray(groupIds) || groupIds.some((id) => typeof id !== "string")) {
throw new FileLibError(503, "group_unavailable", "group service returned malformed payload");
}
return groupIds as readonly string[];
},
};
}
+47
View File
@@ -0,0 +1,47 @@
/**
* 文件库 HTTP 门禁(契约 C4 的入驻适配)。
*
* 身份链:hub session(飞书 OAuth / dev bypass)→ silo org membership。
* 网站管理员 = org 的 OWNER/ADMIN(D19:仅 root 创建与 force_adjust 特权,
* 不给内容读旁路);普通成员 = 任何活跃 membership;非成员 = 403。
*/
import type { FastifyReply, FastifyRequest } from "fastify";
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
import { requireSession, sendError } from "../../admin/auth/guards.js";
import type { FileLibActor } from "./treeService.js";
export interface FileLibGuardDeps {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
/** 文件库归属的 silo org(ADR-0020/0025)。 */
readonly organizationId: string;
}
const WEBSITE_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
/** 每个 /database/api/* 端点第一行调它;返回 null 时响应已发出,fail closed。 */
export async function requireFileLibActor(
request: FastifyRequest,
reply: FastifyReply,
deps: FileLibGuardDeps,
): Promise<FileLibActor | null> {
const auth = await requireSession(request, reply, {
prisma: deps.prisma,
sessionSecret: deps.sessionSecret,
});
if (auth === null) return null;
const membership = await deps.prisma.organizationMembership.findFirst({
where: { organizationId: deps.organizationId, userId: auth.user.id, revokedAt: null },
select: { role: true },
});
if (membership === null) {
await sendError(reply, 403, "forbidden", "not a member of this organization");
return null;
}
return {
userId: auth.user.id,
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
};
}
+59
View File
@@ -0,0 +1,59 @@
/**
* 文件库领域基础:角色秩、命名规则(D14)、错误类型。
*
* 语义锚定:仓库根《文件库-接口契约.md》(2.2 权限等级 / D8 可见性 / D14 命名)
* 与 .omo/文件库-开工计划.md。本模块是独立文件库,不复用 hub 的
* Folder/Project/PermissionGrant 体系。
*/
export const FILE_LIB_ROLES = ["VIEW", "EDIT", "MANAGE"] as const;
export type FileLibRole = (typeof FILE_LIB_ROLES)[number];
/** 契约 2.2:MANAGE > EDIT > VIEW,严格全序。 */
export const ROLE_RANK: Record<FileLibRole, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
export function roleAtLeast(role: FileLibRole, min: FileLibRole): boolean {
return ROLE_RANK[role] >= ROLE_RANK[min];
}
/** 业务错误。statusCode 由路由层映射为 HTTP 响应(D8 语义在此层只表达为 code)。 */
export class FileLibError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string,
/** 结构化附加信息(如 409 时的 currentVersion),路由层并入错误响应。 */
readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "FileLibError";
}
}
export const NODE_NAME_MAX_LENGTH = 128;
/** D14:禁 `/`;反斜杠同样禁止(它会变成存储路径的分隔符,且易用于伪装)。控制字符禁。 */
const FORBIDDEN_NAME_CHARS = /[/\\\p{C}]/u;
/**
* D14:NFC 归一化 + trim,然后校验长度与字符集。
* 违规抛 FileLibError(400, "invalid_name"),路由层原样透传。
*/
export function normalizeNodeName(raw: string): string {
const name = raw.normalize("NFC").trim();
if (name.length === 0) {
throw new FileLibError(400, "invalid_name", "name must not be empty");
}
if (name.length > NODE_NAME_MAX_LENGTH) {
throw new FileLibError(400, "invalid_name", `name exceeds ${NODE_NAME_MAX_LENGTH} characters`);
}
if (FORBIDDEN_NAME_CHARS.test(name)) {
throw new FileLibError(400, "invalid_name", "name must not contain '/', '\\' or control characters");
}
return name;
}
/** D14:活跃兄弟节点大小写不敏感唯一的比较键(DB 层另有部分唯一索引兜底)。 */
export function nameKey(normalizedName: string): string {
return normalizedName.toLowerCase();
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 纯权限 reducer(契约 P6 / D11 / D8)。
*
* 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链
* 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离
* 存储做密集单测与随机化不变量测试。
*/
import type { FileLibRole } from "./model.js";
import { ROLE_RANK } from "./model.js";
export interface FileLibGrantFact {
readonly nodeId: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: FileLibRole;
readonly isCreatorGrant: boolean;
}
export interface EffectiveRoleInput {
/** 目标节点(self)。 */
readonly nodeId: string;
readonly nodeKind: "FOLDER" | "PROJECT";
/** 目标的全部祖先 id(不含 self,顺序无关)。 */
readonly ancestorIds: readonly string[];
/** 项目独立权限开关(D11/P5);文件夹忽略此值。 */
readonly independentPermissionsEnabled: boolean;
readonly userId: string;
/** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */
readonly groupIds: readonly string[];
/** self ancestors 上的全部活跃 grant(revokedAt 已由获取层过滤)。 */
readonly grants: readonly FileLibGrantFact[];
}
/**
* 契约 P6:effective(user, R) = max { grant.role | s ∈ {user} groups*(user),
* r ∈ {R} ancestors(R) };无匹配 → null(无任何权限)。
* "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。
*
* D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant
* 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的
* grant 不受开关影响。
*/
export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null {
const onChain = new Set<string>([input.nodeId, ...input.ancestorIds]);
const groups = new Set(input.groupIds);
const freezeProjectGrants =
input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled;
let best: FileLibRole | null = null;
for (const grant of input.grants) {
if (!onChain.has(grant.nodeId)) continue;
if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue;
if (grant.principalType === "USER" && grant.principalId !== input.userId) continue;
if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue;
if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role;
}
return best;
}
/**
* D8 可见性语义:
* - 完全无权限(effective === null)→ "not_found"(路由层映射 404,不泄露存在性);
* - 有权限但不足 → "forbidden"(路由层映射 403)。
*/
export type AccessVerdict =
| { readonly allowed: true; readonly role: FileLibRole }
| { readonly allowed: false; readonly reason: "not_found" | "forbidden" };
export function checkAccess(effective: FileLibRole | null, min: FileLibRole): AccessVerdict {
if (effective === null) return { allowed: false, reason: "not_found" };
if (ROLE_RANK[effective] < ROLE_RANK[min]) return { allowed: false, reason: "forbidden" };
return { allowed: true, role: effective };
}
+89
View File
@@ -0,0 +1,89 @@
/**
* /database/api/* 路由共享件:依赖装配、actor 门禁、统一错误映射。
* 约定(与 admin 面一致):绝对路径;guard 前置 fail closed;查询 scope 到 silo org。
*/
import type { FastifyReply, FastifyRequest } from "fastify";
import { Prisma } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
import { FileLibError } from "./model.js";
import { requireFileLibActor } from "./guards.js";
import type { FileLibActor, TreeServiceDeps } from "./treeService.js";
import type { GroupResolver } from "./groupResolver.js";
import type { VersionStore } from "./versionStore.js";
import type { ExportAdapter } from "./exportService.js";
export interface FileLibRouteDeps {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly organizationId: string;
readonly storageRoot: string;
readonly groupResolver: GroupResolver;
readonly versionStore: VersionStore;
readonly exportAdapters: readonly ExportAdapter[];
}
/** 组装 treeService 依赖(路由处理内直接使用)。 */
export function treeDeps(deps: FileLibRouteDeps): TreeServiceDeps {
return {
prisma: deps.prisma,
groupResolver: deps.groupResolver,
versionStore: deps.versionStore,
organizationId: deps.organizationId,
storageRoot: deps.storageRoot,
};
}
/** 端点第一行调用;null = 响应已发(401/403),fail closed。 */
export async function actorOrNull(
request: FastifyRequest,
reply: FastifyReply,
deps: FileLibRouteDeps,
): Promise<FileLibActor | null> {
return requireFileLibActor(request, reply, {
prisma: deps.prisma,
sessionSecret: deps.sessionSecret,
organizationId: deps.organizationId,
});
}
/** 统一错误出口:FileLibError → 语义码;P2002 → 409;其余 → 500(不泄露内部)。 */
export async function sendRouteError(reply: FastifyReply, error: unknown): Promise<void> {
if (error instanceof FileLibError) {
await reply.status(error.statusCode).send({
error: { code: error.code, message: error.message, ...(error.details ?? {}) },
});
return;
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
await reply.status(409).send({ error: { code: "conflict", message: "uniqueness conflict" } });
return;
}
reply.log.error({ err: error }, "filelib route: unexpected error");
await reply.status(500).send({ error: { code: "internal", message: "internal error" } });
}
/** 请求体轻量校验(抛 FileLibError 400)。 */
export function bodyObject(body: unknown): Record<string, unknown> {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new FileLibError(400, "invalid_request", "request body must be a JSON object");
}
return body as Record<string, unknown>;
}
export function requireString(obj: Record<string, unknown>, key: string): string {
const value = obj[key];
if (typeof value !== "string" || value === "") {
throw new FileLibError(400, "invalid_request", `missing or invalid field: ${key}`);
}
return value;
}
export function optionalString(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key];
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") {
throw new FileLibError(400, "invalid_request", `invalid field: ${key}`);
}
return value;
}
+598
View File
@@ -0,0 +1,598 @@
/**
* 文件库树服务(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 仓库的磁盘根目录;项目仓 = <storageRoot>/<nodeId>。 */
readonly storageRoot: string;
}
/** 权限判定实际需要的最小依赖(grantService 等兄弟模块复用)。 */
export type AccessDeps = Pick<TreeServiceDeps, "organizationId" | "groupResolver">;
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<Chain> {
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<FileLibRole | null> {
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<Chain & { readonly role: FileLibRole }> {
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<Chain & { readonly role: FileLibRole }> {
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<string>();
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<FileLibNode> {
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<FileLibNode> {
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<FileLibNode> {
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<void> {
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<FileLibRole | null> {
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<readonly BreadcrumbEntry[]> {
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<readonly ChildNodeDto[]> {
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<FileLibNode> {
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 },
});
});
}
+280
View File
@@ -0,0 +1,280 @@
/**
* VersionStore port(契约 C1)+ 开发用内存实现。
*
* 版本团队交付 npm 工具包后,用同一接口替换 createInMemoryVersionStore。
* 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、
* init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。
*
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期
* demo 稳定,不改变任何语义;生产由真包替换,此文件不参与。
*/
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import { FileLibError } from "./model.js";
export type VersionId = string;
export interface CommitRequest {
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
readonly baseVersion: VersionId | null;
readonly content: string | Buffer;
readonly message?: string | undefined;
readonly author?: string | undefined;
}
export type CommitResult =
| { readonly status: "ok"; readonly version: VersionId }
| { readonly status: "conflict"; readonly currentVersion: VersionId };
export interface VersionInfo {
readonly version: VersionId;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
}
export interface FileEntry {
readonly path: string;
readonly size: number;
}
export interface VersionStore {
init(projectDir: string): Promise<void>;
list(projectDir: string, prefix?: string): Promise<FileEntry[]>;
head(projectDir: string, filePath: string): Promise<VersionId>;
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>;
commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise<CommitResult>;
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
}
interface StoredVersion {
readonly version: VersionId;
readonly content: Buffer;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
readonly deleted: boolean;
}
interface Repo {
/** 每文件一条版本链(D16:版本是文件级的,互不干扰)。 */
readonly files: Map<string, StoredVersion[]>;
counter: number;
}
/** S4:同 projectDir 的写操作经 per-repo promise 链串行化。 */
function createKeySerializer(): <T>(key: string, fn: () => Promise<T>) => Promise<T> {
const tails = new Map<string, Promise<unknown>>();
return <T>(key: string, fn: () => Promise<T>): Promise<T> => {
const prev = tails.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
tails.set(key, next.catch(() => undefined));
return next;
};
}
function toBuffer(content: string | Buffer): Buffer {
return typeof content === "string" ? Buffer.from(content, "utf8") : content;
}
/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */
function naiveDiff(fromText: string, toText: string): string {
const a = fromText.split("\n");
const b = toText.split("\n");
const out: string[] = ["--- a", "+++ b"];
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i += 1) {
const al = a[i];
const bl = b[i];
if (al === bl) {
if (al !== undefined) out.push(` ${al}`);
} else {
if (al !== undefined) out.push(`-${al}`);
if (bl !== undefined) out.push(`+${bl}`);
}
}
return out.join("\n");
}
export function createInMemoryVersionStore(persistPath?: string): VersionStore {
const repos = new Map<string, Repo>();
const serialize = createKeySerializer();
/* ---------------- 开发期快照持久化(语义不变,仅防重启丢失) ---------------- */
interface PersistedVersion extends Omit<StoredVersion, "content"> {
content: string; // base64
}
function loadPersisted(): void {
if (persistPath === undefined) return;
try {
const raw = JSON.parse(readFileSync(persistPath, "utf8")) as {
repos: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }>;
};
for (const [dir, repo] of Object.entries(raw.repos)) {
const files = new Map<string, StoredVersion[]>();
for (const [filePath, versions] of Object.entries(repo.files)) {
files.set(filePath, versions.map((v) => ({ ...v, content: Buffer.from(v.content, "base64") })));
}
repos.set(dir, { files, counter: repo.counter });
}
} catch { /* 无快照或损坏 → 空库起步(开发语义) */ }
}
function savePersisted(): void {
if (persistPath === undefined) return;
const out: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }> = {};
for (const [dir, repo] of repos) {
const files: Record<string, PersistedVersion[]> = {};
for (const [filePath, versions] of repo.files) {
files[filePath] = versions.map((v) => ({ ...v, content: v.content.toString("base64") }));
}
out[dir] = { counter: repo.counter, files };
}
try {
mkdirSync(path.dirname(persistPath), { recursive: true });
const tmp = `${persistPath}.tmp`;
writeFileSync(tmp, JSON.stringify({ repos: out }), "utf8");
renameSync(tmp, persistPath);
} catch { /* 快照失败不影响开发使用 */ }
}
loadPersisted();
function requireRepo(projectDir: string): Repo {
const repo = repos.get(projectDir);
if (repo === undefined) {
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
}
return repo;
}
function liveVersion(repo: Repo, filePath: string): StoredVersion {
const chain = repo.files.get(filePath);
const latest = chain?.[chain.length - 1];
if (chain === undefined || latest === undefined || latest.deleted) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
return latest;
}
function findVersion(repo: Repo, filePath: string, version: VersionId): StoredVersion {
const found = repo.files.get(filePath)?.find((v) => v.version === version);
if (found === undefined) {
throw new FileLibError(404, "version_not_found", `version not found: ${filePath}@${version}`);
}
return found;
}
return {
async init(projectDir) {
await serialize(projectDir, async () => {
// S7:幂等,重复调用不报错、不重建。
const created = repos.get(projectDir) === undefined;
repos.set(projectDir, repos.get(projectDir) ?? { files: new Map(), counter: 0 });
if (created) savePersisted();
});
},
async list(projectDir, prefix) {
const repo = requireRepo(projectDir);
const out: FileEntry[] = [];
for (const [path, chain] of repo.files) {
const latest = chain[chain.length - 1];
if (latest === undefined || latest.deleted) continue;
if (prefix !== undefined && !path.startsWith(prefix)) continue;
out.push({ path, size: latest.content.byteLength });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
},
async head(projectDir, filePath) {
return liveVersion(requireRepo(projectDir), filePath).version;
},
async read(projectDir, filePath, at) {
const repo = requireRepo(projectDir);
if (at !== undefined) return findVersion(repo, filePath, at).content;
return liveVersion(repo, filePath).content;
},
async commit(projectDir, filePath, req) {
return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const latest = chain[chain.length - 1];
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
// S2:新建(baseVersion null)要求文件当前不存在;否则要求 baseVersion 精确等于当前版本(S1)。
if (req.baseVersion === null) {
if (currentVersion !== null) return { status: "conflict", currentVersion };
} else if (req.baseVersion !== currentVersion) {
return {
status: "conflict",
currentVersion: currentVersion ?? req.baseVersion,
};
}
repo.counter += 1;
const version = `v${repo.counter}`;
chain.push({
version,
content: toBuffer(req.content),
message: req.message ?? `commit ${version}`,
author: req.author,
committedAt: new Date().toISOString(),
deleted: false,
});
repo.files.set(filePath, chain);
savePersisted();
return { status: "ok", version };
});
},
async remove(projectDir, filePath, baseVersion) {
return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const latest = chain[chain.length - 1];
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
if (currentVersion === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
if (baseVersion !== currentVersion) return { status: "conflict", currentVersion };
repo.counter += 1;
const version = `v${repo.counter}`;
chain.push({
version,
content: Buffer.alloc(0),
message: `remove ${filePath}`,
author: undefined,
committedAt: new Date().toISOString(),
deleted: true,
});
repo.files.set(filePath, chain);
savePersisted();
return { status: "ok", version };
});
},
async diff(projectDir, filePath, from, to) {
const repo = requireRepo(projectDir);
const a = findVersion(repo, filePath, from);
const b = findVersion(repo, filePath, to);
return naiveDiff(a.content.toString("utf8"), b.content.toString("utf8"));
},
async history(projectDir, filePath, limit) {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const infos: VersionInfo[] = chain.map((v) => ({
version: v.version,
message: v.message,
author: v.author,
committedAt: v.committedAt,
}));
const ordered = infos.reverse();
return limit !== undefined ? ordered.slice(0, limit) : ordered;
},
};
}