fix: enforce active organization boundary

This commit is contained in:
2026-07-10 18:42:12 +08:00
parent f07f280b8f
commit d730e51c3d
24 changed files with 1686 additions and 460 deletions
+197
View File
@@ -0,0 +1,197 @@
import { randomUUID } from "node:crypto";
import { chmod, lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
import { isAbsolute, join, resolve } from "node:path";
import {
linkWorkspaceFileNoFollow,
removeWorkspaceFileIfUnchangedNoFollow,
type WorkspaceFileWriteResult,
} from "../security/workspaceFiles.js";
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
export interface MessageResourceStageRequest {
readonly fileKey: string;
readonly resourceType: "image" | "file";
readonly workspaceRelativePath: string;
}
export interface StagedMessageResource {
readonly resourceType: "image" | "file";
readonly workspaceRelativePath: string;
readonly stagedPath: string;
}
export interface StagedMessageResourceBatch {
readonly stagingRoot: string;
readonly resources: readonly StagedMessageResource[];
readonly publishedResources: PublishedMessageResource[];
}
export interface PublishedMessageResource extends WorkspaceFileWriteResult {
readonly resourceType: "image" | "file";
readonly workspaceRelativePath: string;
}
/** Download Feishu resources into a private temporary workspace, never the tenant workspace. */
export async function stageMessageResources(
rt: FeishuRuntime,
messageId: string,
requests: readonly MessageResourceStageRequest[],
workspaceRoot: string,
): Promise<StagedMessageResourceBatch | null> {
if (requests.length === 0) return null;
const stagingBase = await requirePrivateStagingBase(workspaceRoot);
const stagingRoot = join(stagingBase, randomUUID());
const resources: StagedMessageResource[] = [];
try {
await mkdir(stagingRoot, { mode: 0o700 });
for (const [index, request] of requests.entries()) {
const stagedPath = await downloadMessageFile(
rt,
messageId,
request.fileKey,
workspaceRoot,
stagingRoot,
`resource-${index}`,
request.resourceType,
);
resources.push({
resourceType: request.resourceType,
workspaceRelativePath: request.workspaceRelativePath,
stagedPath,
});
}
return { stagingRoot, resources, publishedResources: [] };
} catch (error) {
try {
await rm(stagingRoot, { recursive: true, force: true });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
`Feishu resource staging and cleanup both failed: ${stagingRoot}`,
{ cause: error },
);
}
throw error;
}
}
/**
* Publish with O(1) same-filesystem links only after ACTIVE run/project-lock
* admission has committed durably; that commit is the lifecycle linearization point.
*/
export async function publishStagedMessageResources(
batch: StagedMessageResourceBatch,
workspaceRoot: string,
workspaceDir: string,
): Promise<readonly PublishedMessageResource[]> {
for (const resource of batch.resources) {
const result = await linkWorkspaceFileNoFollow(
workspaceRoot,
workspaceDir,
resource.workspaceRelativePath,
resource.stagedPath,
);
batch.publishedResources.push({
...result,
resourceType: resource.resourceType,
workspaceRelativePath: resource.workspaceRelativePath,
});
}
return batch.publishedResources;
}
export async function removeStagedMessageResources(batch: StagedMessageResourceBatch): Promise<void> {
await rm(batch.stagingRoot, { recursive: true, force: true });
}
/** No trigger can be live during startup, so every prior staging batch is abandoned. */
export async function removeAbandonedMessageResourceStages(workspaceRoot: string): Promise<number> {
const canonicalRoot = await canonicalWorkspaceRoot(workspaceRoot, true);
if (canonicalRoot === null) return 0;
const stagingBase = join(canonicalRoot, ".cph-staging");
let metadata;
try {
metadata = await lstat(stagingBase);
} catch (error) {
if (isErrno(error, "ENOENT")) return 0;
throw error;
}
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`Feishu staging base is not a real directory: ${stagingBase}`);
}
const entries = await readdir(stagingBase);
const removed = await Promise.allSettled(entries.map((entry) =>
rm(join(stagingBase, entry), { recursive: true, force: true })
));
const failures = removed.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (failures.length > 0) {
throw new AggregateError(failures, `failed to remove abandoned Feishu staging batches: ${stagingBase}`);
}
return entries.length;
}
export async function compensatePublishedMessageResources(
resources: readonly PublishedMessageResource[],
workspaceRoot: string,
workspaceDir: string,
): Promise<void> {
const results = await Promise.allSettled(resources.map((resource) =>
removeWorkspaceFileIfUnchangedNoFollow(
workspaceRoot,
workspaceDir,
resource.workspaceRelativePath,
resource.device,
resource.inode,
)
));
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (failures.length > 0) {
throw new AggregateError(failures, "failed to compensate one or more published Feishu resources");
}
}
async function requirePrivateStagingBase(workspaceRoot: string): Promise<string> {
const canonicalRoot = await canonicalWorkspaceRoot(workspaceRoot, false);
if (canonicalRoot === null) throw new Error(`workspace root not found: ${workspaceRoot}`);
const stagingBase = join(canonicalRoot, ".cph-staging");
try {
await mkdir(stagingBase, { mode: 0o700 });
} catch (error) {
if (!isErrno(error, "EEXIST")) throw error;
}
const metadata = await lstat(stagingBase);
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`Feishu staging base is not a real directory: ${stagingBase}`);
}
const canonicalStagingBase = await realpath(stagingBase);
const expectedStagingBase = join(await realpath(canonicalRoot), ".cph-staging");
if (canonicalStagingBase !== expectedStagingBase) {
throw new Error(`Feishu staging base escapes workspace root: ${stagingBase}`);
}
await chmod(stagingBase, 0o700);
return stagingBase;
}
async function canonicalWorkspaceRoot(
workspaceRoot: string,
allowMissing: boolean,
): Promise<string | null> {
if (!isAbsolute(workspaceRoot)) throw new Error(`workspace root must be absolute: ${workspaceRoot}`);
const configuredRoot = resolve(workspaceRoot);
let metadata;
try {
metadata = await lstat(configuredRoot);
} catch (error) {
if (allowMissing && isErrno(error, "ENOENT")) return null;
throw error;
}
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`workspace root is not a real directory: ${workspaceRoot}`);
}
await realpath(configuredRoot);
return configuredRoot;
}
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+27 -15
View File
@@ -3,6 +3,7 @@ import type { FastifyBaseLogger } from "fastify";
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
import type { ModelRegistry, RoleEntry } from "../agent/models.js";
import type { RuntimeSettings } from "../settings/runtime.js";
import { lockActiveProjectOrganization } from "../org/status.js";
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import type { TriggerQueue } from "./triggerQueue.js";
@@ -87,9 +88,12 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"不会清空当前项目的等待队列。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
await deps.prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, projectId);
await tx.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
});
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions);
},
@@ -104,19 +108,24 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"没有可恢复会话时只回复提示,不会创建 agent run。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
const latest = await deps.prisma.agentSession.findFirst({
where: { projectId, archivedAt: { not: null } },
orderBy: { archivedAt: "desc" },
select: { id: true },
const resumed = await deps.prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, projectId);
const latest = await tx.agentSession.findFirst({
where: { projectId, archivedAt: { not: null } },
orderBy: { archivedAt: "desc" },
select: { id: true },
});
if (latest === null) return false;
await tx.agentSession.update({
where: { id: latest.id },
data: { archivedAt: null },
});
return true;
});
if (latest === null) {
if (!resumed) {
await sendText(rt, chatId, "没有可恢复的会话。", sendOptions);
return;
}
await deps.prisma.agentSession.update({
where: { id: latest.id },
data: { archivedAt: null },
});
await sendText(rt, chatId, "已恢复上一个会话。", sendOptions);
},
});
@@ -175,9 +184,12 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"清空当前项目已经排队、尚未开始的触发请求。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
await deps.prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, projectId);
await tx.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
});
const cleared = deps.triggerQueue.clear(projectId);
if (cleared > 0) {
+212 -138
View File
@@ -9,6 +9,8 @@
* ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
*/
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import type { FastifyBaseLogger } from "fastify";
@@ -20,7 +22,6 @@ import {
reactToMessage,
addReaction,
removeReaction,
downloadMessageFile,
parsePostMessage,
resolveCard,
resolveSenderName,
@@ -31,17 +32,26 @@ import {
} from "./client.js";
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
import type { RuntimeSettings } from "../settings/runtime.js";
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
import { currentLockRunId, releaseLock } from "../lock.js";
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
import { writeAudit } from "../audit.js";
import { formatRunCostLine } from "../agent/cost.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
import { readFeishuContext } from "./read.js";
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
import { ApprovalManager } from "./approval.js";
import { SenderNameCache } from "./senderCache.js";
import {
compensatePublishedMessageResources,
publishStagedMessageResources,
removeStagedMessageResources,
stageMessageResources,
type MessageResourceStageRequest,
type StagedMessageResourceBatch,
} from "./resourceStaging.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
@@ -169,7 +179,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// slashes are left as literal text (extractRole returns null). Falls back
// to "draft" when no role is named — the registry degrades gracefully.
const models = await deps.settings.modelRegistry({ projectId });
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, models);
const { roleId: parsedRole, prompt: parsedAgentPrompt } = extractRole(cleanPrompt, models);
const roleId = parsedRole ?? "draft";
// ADR-0019: role trigger is the second gate after project agent.trigger.
// Unconfigured roles remain open for back-compat; configured roles require
@@ -213,80 +223,148 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
rt,
senderOpenId,
});
const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
const stagedResources = await stageTriggerMessageResources(rt, msg, projectWorkspaceRoot);
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir);
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
// ADR-0017: provider+role+model-bound session. Reuse if one exists for
// this (project, provider, role, model) tuple; otherwise create. Role is
// part of the key because role prompts/tool surfaces can differ even when
// the underlying model is the same.
const existingSession = await deps.prisma.agentSession.findFirst({
where: { projectId, provider: providerId, roleId, model, archivedAt: null },
select: { id: true, metadata: true },
});
const session =
existingSession !== null
? await deps.prisma.agentSession.update({
where: { id: existingSession.id },
data: { provider: providerId, roleId, model, updatedAt: new Date() },
select: { id: true, metadata: true },
})
: await deps.prisma.agentSession.create({
data: {
projectId,
provider: providerId,
roleId,
model,
title: agentPrompt.slice(0, 40) || null,
metadata: {},
},
select: { id: true, metadata: true },
});
const sessionMetadata = readSessionMetadata(session.metadata);
// Linearization point for org lifecycle + session/run/lock admission.
// FOR SHARE on the Organization row conflicts with a concurrent status
// UPDATE, so either the run is admitted while ACTIVE or suspension wins
// and no session/run/lock/audit row is committed.
let admission: {
readonly session: { readonly id: string; readonly metadata: Prisma.JsonValue };
readonly run: { readonly id: string };
};
try {
admission = await deps.prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, roleDecision.organizationId);
const run = await deps.prisma.agentRun.create({
data: {
projectId,
sessionId: session.id,
requestedByUserId: roleDecision.actorUserId ?? null,
entrypoint: "FEISHU",
status: "ACTIVE",
prompt: promptForAgent,
model,
provider: providerId,
metadata: agentRunMetadata(roleId, agentPrompt, feishuTriggerContext),
},
select: { id: true },
});
// ADR-0017: provider+role+model-bound session. Reuse if one exists for
// this tuple; otherwise create. Role remains part of the key.
const existingSession = await tx.agentSession.findFirst({
where: { projectId, provider: providerId, roleId, model, archivedAt: null },
select: { id: true, metadata: true },
});
const session = existingSession !== null
? await tx.agentSession.update({
where: { id: existingSession.id },
data: { provider: providerId, roleId, model, updatedAt: new Date() },
select: { id: true, metadata: true },
})
: await tx.agentSession.create({
data: {
projectId,
provider: providerId,
roleId,
model,
title: agentPrompt.slice(0, 40) || null,
metadata: {},
},
select: { id: true, metadata: true },
});
const run = await tx.agentRun.create({
data: {
projectId,
sessionId: session.id,
requestedByUserId: roleDecision.actorUserId ?? null,
entrypoint: "FEISHU",
status: "ACTIVE",
prompt: promptForAgent,
model,
provider: providerId,
metadata: agentRunMetadata(roleId, agentPrompt, feishuTriggerContext),
},
select: { id: true },
});
await tx.projectAgentLock.create({
data: { projectId, runId: run.id, holderUserId: null },
});
return { session, run };
});
} catch (error) {
if (stagedResources !== null) {
await compensateFailedResourceAdmission(
error,
stagedResources,
projectWorkspaceRoot,
project.workspaceDir,
);
}
if (error instanceof InactiveOrganizationError) {
deps.logger.info(
{ projectId, organizationId: error.organizationId, status: error.status },
"feishu trigger: organization became inactive before run admission",
);
await sendText(rt, chatId, "组织当前不可用,拒绝触发。", sendOptions);
return "skipped";
}
if (!isPrismaUniqueConstraintError(error)) throw error;
const current = await currentLockRunId(deps.prisma, projectId);
if (current === null) throw error;
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
}
const { session, run } = admission;
if (stagedResources !== null) {
try {
await publishStagedMessageResources(
stagedResources,
projectWorkspaceRoot,
project.workspaceDir,
);
await removeStagedMessageResources(stagedResources);
} catch (error) {
let publicationFailure: unknown = error;
try {
await compensateFailedResourceAdmission(
error,
stagedResources,
projectWorkspaceRoot,
project.workspaceDir,
);
} catch (cleanupError) {
publicationFailure = cleanupError;
}
try {
await deps.prisma.$transaction(async (tx) => {
await tx.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: "attachment publication failed", finishedAt: new Date() },
});
await tx.projectAgentLock.deleteMany({ where: { runId: run.id } });
});
} catch (finalizationError) {
publicationFailure = new AggregateError(
[publicationFailure, finalizationError],
`attachment publication and run finalization both failed: ${run.id}`,
{ cause: publicationFailure },
);
}
await writeAudit(deps.prisma, {
runId: run.id,
projectId,
action: "run.attachment_publish_failed",
metadata: { error: String(publicationFailure) },
});
await sendText(rt, chatId, "附件处理失败,未启动运行。", sendOptions);
throw publicationFailure;
}
}
await writeAudit(deps.prisma, {
runId: run.id,
projectId,
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
action: "run.created",
metadata: {
roleId,
model,
prompt: agentPrompt.slice(0, 200),
sender: await senderAuditMetadata(rt, senderOpenId),
sender: senderMetadata,
feishuTriggerContext,
},
});
// ADR-0002: acquire the project lock for this run.
try {
await acquireLock(deps.prisma, projectId, run.id, null);
} catch {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: "lock race", finishedAt: new Date() },
});
await writeAudit(deps.prisma, {
runId: run.id,
projectId,
action: "run.lock_race",
metadata: { sender: await senderAuditMetadata(rt, senderOpenId) },
});
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
}
const sessionMetadata = readSessionMetadata(session.metadata);
const projectCtx: ProjectContext = {
projectId,
@@ -771,30 +849,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} else if (msg.message_type === "post") {
const parsed = parsePostMessage(msg.content);
cleanPrompt = extractPostPrompt(msg, parsed.text);
if (cleanPrompt === null) return;
if (parsed.imageKeys.length > 0 || parsed.fileKeys.length > 0) {
const project = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { workspaceDir: true },
});
if (project === null) return;
const downloadedResources = await downloadPostMessageFiles({
rt,
msg,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
imageKeys: parsed.imageKeys,
fileKeys: parsed.fileKeys,
});
cleanPrompt = appendDownloadedResourcePaths(cleanPrompt, downloadedResources);
}
} else if (msg.message_type === "file" || msg.message_type === "image") {
// Download the file/image to the workspace, build a prompt around it.
const project = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { workspaceDir: true },
});
if (project === null) return;
let content: z.infer<typeof FileMessageContentSchema>;
try {
content = FileMessageContentSchema.parse(JSON.parse(msg.content));
@@ -807,17 +862,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
const key = content.file_key ?? content.image_key ?? "";
if (key !== "") {
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
const savePath = await downloadMessageFile(
rt,
msg.message_id,
key,
projectWorkspaceRoot,
project.workspaceDir,
`.cph/inbox/${fileName}`,
msg.message_type,
);
cleanPrompt = `用户发来一个文件: ${savePath}`;
cleanPrompt = "用户发来一个文件";
}
}
if (cleanPrompt === null) return;
@@ -839,7 +884,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (invocation !== null) {
const slashCommand = slashCommands.get(invocation.name);
if (slashCommand !== undefined) {
await slashCommand.run({ invocation, projectId, chatId, rt, sendOptions: sendOptionsForTriggerMessage(msg) });
try {
await slashCommand.run({ invocation, projectId, chatId, rt, sendOptions: sendOptionsForTriggerMessage(msg) });
} catch (error) {
if (!(error instanceof InactiveOrganizationError)) throw error;
deps.logger.info(
{ projectId, organizationId: error.organizationId, status: error.status, command: invocation.name },
"feishu slash command: organization became inactive before mutation",
);
await sendText(rt, chatId, "组织当前不可用,拒绝操作。", sendOptionsForTriggerMessage(msg));
}
return;
}
}
@@ -1308,60 +1362,80 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
interface DownloadedMessageResource {
readonly resourceType: "image" | "file";
readonly path: string;
function isPrismaUniqueConstraintError(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "P2002";
}
async function downloadPostMessageFiles(args: {
readonly rt: FeishuRuntime;
readonly msg: MessageReceiveEvent["message"];
readonly workspaceRoot: string;
readonly workspaceDir: string;
readonly imageKeys: readonly string[];
readonly fileKeys: readonly string[];
}): Promise<readonly DownloadedMessageResource[]> {
const timestamp = Date.now();
const downloadedResources: DownloadedMessageResource[] = [];
for (const [index, imageKey] of args.imageKeys.entries()) {
const savePath = await downloadMessageFile(
args.rt,
args.msg.message_id,
imageKey,
args.workspaceRoot,
args.workspaceDir,
`.cph/inbox/post-image-${timestamp}-${index}.png`,
"image",
);
downloadedResources.push({ resourceType: "image", path: savePath });
async function stageTriggerMessageResources(
rt: FeishuRuntime,
msg: MessageReceiveEvent["message"],
workspaceRoot: string,
): Promise<StagedMessageResourceBatch | null> {
const requests: MessageResourceStageRequest[] = [];
if (msg.message_type === "post") {
const parsed = parsePostMessage(msg.content);
for (const imageKey of parsed.imageKeys) {
requests.push({
fileKey: imageKey,
resourceType: "image",
workspaceRelativePath: `.cph/inbox/post-image-${randomUUID()}.png`,
});
}
for (const fileKey of parsed.fileKeys) {
requests.push({
fileKey,
resourceType: "file",
workspaceRelativePath: `.cph/inbox/post-file-${randomUUID()}.bin`,
});
}
} else if (msg.message_type === "file" || msg.message_type === "image") {
const content = FileMessageContentSchema.parse(JSON.parse(msg.content));
const fileKey = content.file_key ?? content.image_key;
if (fileKey !== undefined && fileKey !== "") {
requests.push({
fileKey,
resourceType: msg.message_type,
workspaceRelativePath: `.cph/inbox/${msg.message_type}-${randomUUID()}.${msg.message_type === "image" ? "png" : "bin"}`,
});
}
}
for (const [index, fileKey] of args.fileKeys.entries()) {
const savePath = await downloadMessageFile(
args.rt,
args.msg.message_id,
fileKey,
args.workspaceRoot,
args.workspaceDir,
`.cph/inbox/post-file-${timestamp}-${index}.bin`,
"file",
);
downloadedResources.push({ resourceType: "file", path: savePath });
}
return downloadedResources;
return stageMessageResources(rt, msg.message_id, requests, workspaceRoot);
}
function appendDownloadedResourcePaths(
function appendStagedResourcePaths(
prompt: string,
resources: readonly DownloadedMessageResource[],
batch: StagedMessageResourceBatch | null,
workspaceDir: string,
): string {
const resources = batch?.resources ?? [];
if (resources.length === 0) return prompt;
const paths = resources.map((resource) => {
const label = resource.resourceType === "image" ? "图片" : "文件";
return `- ${label}: ${resource.path}`;
return `- ${label}: ${join(workspaceDir, resource.workspaceRelativePath)}`;
});
return `${prompt}\n\n消息附件已下载到项目工作区,可直接读取以下本地文件:\n${paths.join("\n")}`;
}
async function compensateFailedResourceAdmission(
primaryError: unknown,
batch: StagedMessageResourceBatch,
workspaceRoot: string,
workspaceDir: string,
): Promise<void> {
const cleanup = await Promise.allSettled([
removeStagedMessageResources(batch),
compensatePublishedMessageResources(batch.publishedResources, workspaceRoot, workspaceDir),
]);
const failures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (failures.length > 0) {
throw new AggregateError(
[primaryError, ...failures],
"Agent admission failed and Feishu resource compensation was incomplete",
{ cause: primaryError },
);
}
}
/**
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
* Returns the role id and the remaining prompt with the command stripped.
+6 -3
View File
@@ -3,6 +3,7 @@ import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { makeTriggerHandler } from "./feishu/trigger.js";
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
import { triggerQueue } from "./feishu/triggerQueue.js";
import { createEnvRuntimeSettings } from "./settings/runtime.js";
import { readServerBinding } from "./settings/server.js";
@@ -24,6 +25,11 @@ function booleanEnv(name: string, fallback: boolean): boolean {
/** Start the Hub after every import and runtime dependency has loaded. */
export async function startHub(): Promise<void> {
requireEnv("DATABASE_URL");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const app = Fastify({ logger: true });
const abandonedStages = await removeAbandonedMessageResourceStages(projectWorkspaceRoot);
app.log.info({ removed: abandonedStages }, "startup: removed abandoned Feishu resource stages");
const runtimeSettings = createEnvRuntimeSettings();
await runtimeSettings.provider("openrouter");
@@ -31,13 +37,10 @@ export async function startHub(): Promise<void> {
const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const bind = readServerBinding();
const app = Fastify({ logger: true });
// Startup reset: clear stale locks + mark dead runs as FAILED.
await prisma.projectAgentLock.deleteMany({});
await prisma.agentRun.updateMany({
+12 -5
View File
@@ -11,6 +11,7 @@ import {
createProjectFromOrgAdmin,
moveProjectToFolder,
} from "../projectOnboarding.js";
import { lockActiveOrganization } from "./status.js";
export interface ExplorerFolderNode {
readonly id: string;
@@ -120,6 +121,7 @@ export async function renameFolder(
},
) {
return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === folder.id) {
@@ -148,6 +150,7 @@ export async function archiveFolder(
input: { readonly organizationId: string; readonly folderId: string },
): Promise<{ readonly archived: true; readonly folderId: string }> {
return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
const childFolders = await tx.folder.count({
where: { parentId: folder.id, archivedAt: null },
@@ -195,11 +198,14 @@ export async function renameProject(
},
) {
const name = requireNonEmpty(input.name, "project name");
const project = await requireActiveProject(prisma, input.projectId, input.organizationId);
return prisma.project.update({
where: { id: project.id },
data: { name },
select: { id: true, name: true, folderId: true },
return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const project = await requireActiveProject(tx, input.projectId, input.organizationId);
return tx.project.update({
where: { id: project.id },
data: { name },
select: { id: true, name: true, folderId: true },
});
});
}
@@ -223,6 +229,7 @@ export async function archiveProject(
input: { readonly organizationId: string; readonly projectId: string },
): Promise<{ readonly archived: true; readonly projectId: string }> {
return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const project = await requireActiveProject(tx, input.projectId, input.organizationId);
const activeBinding = await tx.projectGroupBinding.findFirst({
where: { projectId: project.id, archivedAt: null },
+89 -87
View File
@@ -7,7 +7,8 @@
* 3. Cannot revoke or demote the last remaining OWNER.
* 4. ADMIN cannot modify OWNER memberships.
*/
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
import type { OrganizationMemberRole, Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "./status.js";
export interface OrgMemberRow {
readonly userId: string;
@@ -56,32 +57,32 @@ export async function addOrgMember(
const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId");
assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false);
const user = await prisma.user.upsert({
where: { feishuOpenId: openId },
create: {
feishuOpenId: openId,
displayName: input.displayName?.trim() || openId,
},
update: {
...(input.displayName !== undefined && input.displayName.trim() !== ""
? { displayName: input.displayName.trim() }
: {}),
},
});
const existing = await prisma.organizationMembership.findFirst({
where: { organizationId: input.organizationId, userId: user.id, revokedAt: null },
});
if (existing !== null) {
throw new Error(`user ${user.id} is already an active member`);
}
const membership = await prisma.organizationMembership.create({
data: {
organizationId: input.organizationId,
userId: user.id,
role: input.role,
},
const { user, membership } = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const user = await tx.user.upsert({
where: { feishuOpenId: openId },
create: {
feishuOpenId: openId,
displayName: input.displayName?.trim() || openId,
},
update: {
...(input.displayName !== undefined && input.displayName.trim() !== ""
? { displayName: input.displayName.trim() }
: {}),
},
});
const existing = await tx.organizationMembership.findFirst({
where: { organizationId: input.organizationId, userId: user.id, revokedAt: null },
});
if (existing !== null) throw new Error(`user ${user.id} is already an active member`);
const membership = await tx.organizationMembership.create({
data: {
organizationId: input.organizationId,
userId: user.id,
role: input.role,
},
});
return { user, membership };
});
return {
@@ -104,46 +105,47 @@ export async function setOrgMemberRole(
readonly role: OrganizationMemberRole;
},
): Promise<OrgMemberRow> {
const membership = await prisma.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.targetUserId,
revokedAt: null,
},
select: {
id: true,
role: true,
createdAt: true,
user: {
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true },
return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const membership = await tx.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.targetUserId,
revokedAt: null,
},
},
select: {
id: true,
role: true,
createdAt: true,
user: {
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true },
},
},
});
if (membership === null) throw new Error(`member not found: ${input.targetUserId}`);
assertCanAssignRole(input.actorRole, input.role, membership.role === "OWNER");
if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
throw new Error("cannot modify OWNER membership as ADMIN");
}
if (membership.role === "OWNER" && input.role !== "OWNER") {
await assertNotLastOwner(tx, input.organizationId, input.targetUserId);
}
const updated = await tx.organizationMembership.update({
where: { id: membership.id },
data: { role: input.role },
});
return {
userId: membership.user.id,
feishuOpenId: membership.user.feishuOpenId,
displayName: membership.user.displayName,
avatarUrl: membership.user.avatarUrl,
role: updated.role,
createdAt: membership.createdAt.toISOString(),
};
});
if (membership === null) {
throw new Error(`member not found: ${input.targetUserId}`);
}
assertCanAssignRole(input.actorRole, input.role, membership.role === "OWNER");
if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
throw new Error("cannot modify OWNER membership as ADMIN");
}
if (membership.role === "OWNER" && input.role !== "OWNER") {
await assertNotLastOwner(prisma, input.organizationId, input.targetUserId);
}
const updated = await prisma.organizationMembership.update({
where: { id: membership.id },
data: { role: input.role },
});
return {
userId: membership.user.id,
feishuOpenId: membership.user.feishuOpenId,
displayName: membership.user.displayName,
avatarUrl: membership.user.avatarUrl,
role: updated.role,
createdAt: membership.createdAt.toISOString(),
};
}
export async function revokeOrgMember(
@@ -155,27 +157,27 @@ export async function revokeOrgMember(
readonly targetUserId: string;
},
): Promise<{ readonly revoked: true; readonly userId: string }> {
const membership = await prisma.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.targetUserId,
revokedAt: null,
},
select: { id: true, role: true },
});
if (membership === null) {
throw new Error(`member not found: ${input.targetUserId}`);
}
if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
throw new Error("cannot revoke OWNER membership as ADMIN");
}
if (membership.role === "OWNER") {
await assertNotLastOwner(prisma, input.organizationId, input.targetUserId);
}
await prisma.organizationMembership.update({
where: { id: membership.id },
data: { revokedAt: new Date() },
await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const membership = await tx.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.targetUserId,
revokedAt: null,
},
select: { id: true, role: true },
});
if (membership === null) throw new Error(`member not found: ${input.targetUserId}`);
if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
throw new Error("cannot revoke OWNER membership as ADMIN");
}
if (membership.role === "OWNER") {
await assertNotLastOwner(tx, input.organizationId, input.targetUserId);
}
await tx.organizationMembership.update({
where: { id: membership.id },
data: { revokedAt: new Date() },
});
});
return { revoked: true as const, userId: input.targetUserId };
}
@@ -194,7 +196,7 @@ function assertCanAssignRole(
}
async function assertNotLastOwner(
prisma: PrismaClient,
prisma: PrismaClient | Prisma.TransactionClient,
organizationId: string,
targetUserId: string,
): Promise<void> {
+62
View File
@@ -0,0 +1,62 @@
import type { OrganizationStatus, Prisma } from "@prisma/client";
export class InactiveOrganizationError extends Error {
readonly organizationId: string;
readonly status: Exclude<OrganizationStatus, "ACTIVE">;
constructor(organizationId: string, status: Exclude<OrganizationStatus, "ACTIVE">) {
super(`organization ${organizationId} is ${status}; customer operations require ACTIVE`);
this.name = "InactiveOrganizationError";
this.organizationId = organizationId;
this.status = status;
}
}
export function inactiveOrganizationReason(
organizationId: string,
status: OrganizationStatus,
): string | undefined {
if (status === "ACTIVE") return undefined;
return `organization ${organizationId} is ${status}; customer operations require ACTIVE`;
}
export function requireActiveOrganizationStatus(
organizationId: string,
status: OrganizationStatus,
): void {
if (status !== "ACTIVE") throw new InactiveOrganizationError(organizationId, status);
}
/**
* Lock the Organization row for a customer operation and verify ACTIVE.
* PostgreSQL UPDATE takes a conflicting row lock, so lifecycle transitions
* serialize with the entire transaction that called this function.
*/
export async function lockActiveOrganization(
prisma: Prisma.TransactionClient,
organizationId: string,
): Promise<void> {
const rows = await prisma.$queryRaw<Array<{ readonly id: string; readonly status: OrganizationStatus }>>`
SELECT "id", "status"::text AS "status"
FROM "Organization"
WHERE "id" = ${organizationId}
FOR SHARE
`;
const organization = rows[0];
if (organization === undefined) throw new Error(`organization not found: ${organizationId}`);
requireActiveOrganizationStatus(organization.id, organization.status);
}
/** Resolve a Project's tenant inside the transaction, then take the lifecycle lock. */
export async function lockActiveProjectOrganization(
prisma: Prisma.TransactionClient,
projectId: string,
): Promise<string> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { organizationId: true },
});
if (project === null) throw new Error(`project not found: ${projectId}`);
await lockActiveOrganization(prisma, project.organizationId);
return project.organizationId;
}
+72 -67
View File
@@ -5,6 +5,7 @@
* grants that use this team as principal (product pin).
*/
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "./status.js";
export interface TeamRow {
readonly id: string;
@@ -59,20 +60,21 @@ export async function createTeam(
): Promise<TeamRow> {
const slug = sanitizeSlug(input.slug);
const name = requireNonEmpty(input.name, "name");
const existing = await prisma.team.findFirst({
where: { organizationId: input.organizationId, slug, archivedAt: null },
select: { id: true },
});
if (existing !== null) {
throw new Error(`team slug already exists: ${slug}`);
}
const team = await prisma.team.create({
data: {
organizationId: input.organizationId,
slug,
name,
...(input.description !== undefined ? { description: input.description } : {}),
},
const team = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const existing = await tx.team.findFirst({
where: { organizationId: input.organizationId, slug, archivedAt: null },
select: { id: true },
});
if (existing !== null) throw new Error(`team slug already exists: ${slug}`);
return tx.team.create({
data: {
organizationId: input.organizationId,
slug,
name,
...(input.description !== undefined ? { description: input.description } : {}),
},
});
});
return {
id: team.id,
@@ -93,21 +95,24 @@ export async function updateTeam(
readonly description?: string | null | undefined;
},
): Promise<TeamRow> {
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId);
const updated = await prisma.team.update({
where: { id: team.id },
data: {
...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
},
select: {
id: true,
slug: true,
name: true,
description: true,
createdAt: true,
_count: { select: { memberships: { where: { revokedAt: null } } } },
},
const updated = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
return tx.team.update({
where: { id: team.id },
data: {
...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
},
select: {
id: true,
slug: true,
name: true,
description: true,
createdAt: true,
_count: { select: { memberships: { where: { revokedAt: null } } } },
},
});
});
return {
id: updated.id,
@@ -127,6 +132,7 @@ export async function archiveTeam(
input: { readonly organizationId: string; readonly teamId: string },
): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> {
return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
const now = new Date();
await tx.team.update({
@@ -183,34 +189,32 @@ export async function addTeamMember(
readonly feishuOpenId?: string | undefined;
},
): Promise<TeamMemberRow> {
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId);
const user = await resolveUser(prisma, input);
// User should be an org member to join a team (product pin for pilot).
const membership = await prisma.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: user.id,
revokedAt: null,
},
select: { id: true },
});
if (membership === null) {
throw new Error(`user ${user.id} is not an active member of the organization`);
}
const existing = await prisma.teamMembership.findFirst({
where: { teamId: team.id, userId: user.id, revokedAt: null },
});
if (existing !== null) {
throw new Error(`user ${user.id} is already on team ${team.id}`);
}
const row = await prisma.teamMembership.create({
data: { teamId: team.id, userId: user.id },
const result = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
const user = await resolveUser(tx, input);
// User should be an org member to join a team (product pin for pilot).
const membership = await tx.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: user.id,
revokedAt: null,
},
select: { id: true },
});
if (membership === null) throw new Error(`user ${user.id} is not an active member of the organization`);
const existing = await tx.teamMembership.findFirst({
where: { teamId: team.id, userId: user.id, revokedAt: null },
});
if (existing !== null) throw new Error(`user ${user.id} is already on team ${team.id}`);
const row = await tx.teamMembership.create({ data: { teamId: team.id, userId: user.id } });
return { user, row };
});
return {
userId: user.id,
feishuOpenId: user.feishuOpenId,
displayName: user.displayName,
createdAt: row.createdAt.toISOString(),
userId: result.user.id,
feishuOpenId: result.user.feishuOpenId,
displayName: result.user.displayName,
createdAt: result.row.createdAt.toISOString(),
};
}
@@ -222,23 +226,24 @@ export async function revokeTeamMember(
readonly userId: string;
},
): Promise<{ readonly revoked: true; readonly userId: string }> {
await requireActiveTeam(prisma, input.teamId, input.organizationId);
const membership = await prisma.teamMembership.findFirst({
where: { teamId: input.teamId, userId: input.userId, revokedAt: null },
select: { id: true },
});
if (membership === null) {
throw new Error(`team member not found: ${input.userId}`);
}
await prisma.teamMembership.update({
where: { id: membership.id },
data: { revokedAt: new Date() },
await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
await requireActiveTeam(tx, input.teamId, input.organizationId);
const membership = await tx.teamMembership.findFirst({
where: { teamId: input.teamId, userId: input.userId, revokedAt: null },
select: { id: true },
});
if (membership === null) throw new Error(`team member not found: ${input.userId}`);
await tx.teamMembership.update({
where: { id: membership.id },
data: { revokedAt: new Date() },
});
});
return { revoked: true as const, userId: input.userId };
}
async function resolveUser(
prisma: PrismaClient,
prisma: PrismaClient | Prisma.TransactionClient,
input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined },
): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> {
if (input.userId !== undefined && input.userId !== "") {
+8
View File
@@ -8,6 +8,7 @@
*/
import type { PrismaClient } from "@prisma/client";
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
import { inactiveOrganizationReason } from "./org/status.js";
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
@@ -73,6 +74,13 @@ export async function canTriggerRole(
principal: string,
): Promise<PermissionResult> {
try {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { organization: { select: { id: true, status: true } } },
});
if (project === null) return { allowed: false, reason: `project not found: ${projectId}` };
const inactiveReason = inactiveOrganizationReason(project.organization.id, project.organization.status);
if (inactiveReason !== undefined) return { allowed: false, reason: inactiveReason };
const anyGrant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId },
select: { id: true },
+32 -8
View File
@@ -1,5 +1,6 @@
import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
import type { OrganizationStatus, PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
import { inactiveOrganizationReason } from "../org/status.js";
export type AuthorizationAction =
| "project.read"
@@ -61,8 +62,16 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
throw new Error("role.trigger authorization requires roleId");
}
const organizationId = await this.organizationForResource(req.resource);
const resolution = await this.resolver.resolveActor(req.actor, { organizationId });
const organization = await this.organizationForResource(req.resource);
const inactiveReason = inactiveOrganizationReason(organization.id, organization.status);
if (inactiveReason !== undefined) {
return this.decision(req, inactivePrincipalResolution(req.actor, organization.id), {
allowed: false,
reason: inactiveReason,
requiredRole: defaultRequiredRole(req.action),
});
}
const resolution = await this.resolver.resolveActor(req.actor, { organizationId: organization.id });
const threshold = await this.requiredRole(req);
if (threshold === "DISABLED") {
return this.decision(req, resolution, {
@@ -221,27 +230,29 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
};
}
private async organizationForResource(resource: AuthorizationResource): Promise<string> {
private async organizationForResource(
resource: AuthorizationResource,
): Promise<{ readonly id: string; readonly status: OrganizationStatus }> {
switch (resource.type) {
case "PROJECT": {
const project = await this.prisma.project.findUnique({
where: { id: resource.id },
select: { organizationId: true },
select: { organization: { select: { id: true, status: true } } },
});
if (project === null) {
throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
}
return project.organizationId;
return project.organization;
}
case "PROJECT_GROUP": {
const binding = await this.prisma.projectGroupBinding.findFirst({
where: { chatId: resource.id, archivedAt: null },
select: { project: { select: { organizationId: true } } },
select: { project: { select: { organization: { select: { id: true, status: true } } } } },
});
if (binding === null) {
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
}
return binding.project.organizationId;
return binding.project.organization;
}
case "ARTIFACT":
throw new Error("authorization for ARTIFACT resources requires an organization resolver");
@@ -293,3 +304,16 @@ function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalT
principalId: principal.id,
}));
}
function inactivePrincipalResolution(actor: ActorInput, organizationId: string): PrincipalResolution {
const principals: PrincipalRef[] = [{ type: "USER", id: actor.feishuOpenId }];
if (actor.chatId !== undefined && actor.chatId !== "") {
principals.push({ type: "FEISHU_CHAT", id: actor.chatId });
}
return {
actor,
organizationId,
principals,
resolved: [],
};
}
+87 -72
View File
@@ -1,4 +1,5 @@
import type { PrismaClient, PrincipalType } from "@prisma/client";
import type { Prisma, PrismaClient, PrincipalType } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
export type ExternalPrincipalType = Extract<
PrincipalType,
@@ -33,98 +34,112 @@ export async function syncExternalPrincipalMemberships(
const syncedAt = new Date();
const incomingKeys = new Set<string>();
let synced = 0;
const connection = await prisma.externalDirectoryConnection.upsert({
where: {
organizationId_provider_source: {
const connection = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
return tx.externalDirectoryConnection.upsert({
where: {
organizationId_provider_source: {
organizationId: input.organizationId,
provider: input.provider ?? "FEISHU",
source: input.source,
},
},
update: {
revokedAt: null,
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
},
create: {
organizationId: input.organizationId,
provider: input.provider ?? "FEISHU",
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
source: input.source,
},
},
update: {
revokedAt: null,
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
},
create: {
organizationId: input.organizationId,
provider: input.provider ?? "FEISHU",
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
source: input.source,
},
select: { id: true },
});
for (const item of input.memberships) {
assertExternalPrincipalType(item.principalType);
const user = await prisma.user.upsert({
where: { feishuOpenId: item.feishuOpenId },
update: { displayName: item.displayName },
create: {
feishuOpenId: item.feishuOpenId,
displayName: item.displayName,
platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
},
});
await ensureOrganizationMembership(prisma, input.organizationId, user.id);
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId, connection.id));
const existing = await prisma.externalPrincipalMembership.findFirst({
where: {
userId: user.id,
principalType: item.principalType,
principalId: item.principalId,
connectionId: connection.id,
revokedAt: null,
},
select: { id: true },
});
if (existing === null) {
await prisma.externalPrincipalMembership.create({
data: {
userId: user.id,
connectionId: connection.id,
principalType: item.principalType,
principalId: item.principalId,
syncedAt,
});
// Each membership is a short lifecycle-serialized unit. A large directory
// must not hold the Organization row lock (or one interactive transaction)
// for the duration of the entire external sync.
for (const item of input.memberships) {
assertExternalPrincipalType(item.principalType);
const userId = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const user = await tx.user.upsert({
where: { feishuOpenId: item.feishuOpenId },
update: { displayName: item.displayName },
create: {
feishuOpenId: item.feishuOpenId,
displayName: item.displayName,
platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
},
});
} else {
await prisma.externalPrincipalMembership.update({
where: { id: existing.id },
data: { syncedAt },
await ensureOrganizationMembership(tx, input.organizationId, user.id);
const existing = await tx.externalPrincipalMembership.findFirst({
where: {
userId: user.id,
principalType: item.principalType,
principalId: item.principalId,
connectionId: connection.id,
revokedAt: null,
},
select: { id: true },
});
}
if (existing === null) {
await tx.externalPrincipalMembership.create({
data: {
userId: user.id,
connectionId: connection.id,
principalType: item.principalType,
principalId: item.principalId,
syncedAt,
},
});
} else {
await tx.externalPrincipalMembership.update({
where: { id: existing.id },
data: { syncedAt },
});
}
return user.id;
});
incomingKeys.add(externalMembershipKey(userId, item.principalType, item.principalId, connection.id));
synced++;
}
let revoked = 0;
if (input.replaceSource === true) {
const active = await prisma.externalPrincipalMembership.findMany({
where: { connectionId: connection.id, revokedAt: null },
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
});
const revokeIds = active
.filter((membership) => !incomingKeys.has(externalMembershipKey(
membership.userId,
membership.principalType,
membership.principalId,
membership.connectionId,
)))
.map((membership) => membership.id);
if (revokeIds.length > 0) {
const result = await prisma.externalPrincipalMembership.updateMany({
where: { id: { in: revokeIds } },
data: { revokedAt: syncedAt },
revoked = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const active = await tx.externalPrincipalMembership.findMany({
where: { connectionId: connection.id, revokedAt: null },
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
});
revoked = result.count;
}
const revokeIds = active
.filter((membership) => !incomingKeys.has(externalMembershipKey(
membership.userId,
membership.principalType,
membership.principalId,
membership.connectionId,
)))
.map((membership) => membership.id);
if (revokeIds.length > 0) {
const result = await tx.externalPrincipalMembership.updateMany({
where: { id: { in: revokeIds } },
data: { revokedAt: syncedAt },
});
return result.count;
}
return 0;
});
}
return { synced, revoked };
}
async function ensureOrganizationMembership(
prisma: PrismaClient,
prisma: PrismaClient | Prisma.TransactionClient,
organizationId: string,
userId: string,
): Promise<void> {
+3
View File
@@ -1,4 +1,5 @@
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
export interface GrantTeamProjectAccessInput {
readonly projectId: string;
@@ -30,6 +31,7 @@ export async function grantTeamProjectAccess(
): Promise<ProjectTeamAccessEntry> {
return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input);
await lockActiveOrganization(tx, project.organizationId);
const now = new Date();
const existing = await tx.permissionGrant.findFirst({
where: {
@@ -87,6 +89,7 @@ export async function revokeTeamProjectAccess(
): Promise<number> {
return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input);
await lockActiveOrganization(tx, project.organizationId);
const result = await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
+82 -54
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { relative, resolve } from "node:path";
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";
export interface OrganizationProjectPolicy {
readonly organizationId: string;
@@ -72,23 +73,24 @@ export async function setMembersCanCreateProjects(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly enabled: boolean },
): Promise<OrganizationProjectPolicy> {
await ensureOrganizationExists(prisma, input.organizationId);
const settings = await prisma.organizationProjectSettings.upsert({
where: { organizationId: input.organizationId },
update: { membersCanCreateProjects: input.enabled },
create: {
organizationId: input.organizationId,
membersCanCreateProjects: input.enabled,
},
select: { organizationId: true, membersCanCreateProjects: true },
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 },
});
});
return settings;
}
export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> {
const name = requireNonEmpty(input.name, "folder name");
return prisma.$transaction(async (tx) => {
await ensureOrganizationExistsTx(tx, input.organizationId);
await requireActiveOrganizationTx(tx, input.organizationId);
if (input.parentId !== undefined) {
await assertFolderInOrganization(tx, input.parentId, input.organizationId);
}
@@ -113,6 +115,7 @@ export async function moveProjectToFolder(
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);
}
@@ -188,6 +191,7 @@ export async function bindFeishuChatToProject(
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 activeProjectBinding = await tx.projectGroupBinding.findFirst({
where: { projectId: project.id, archivedAt: null },
select: { chatId: true },
@@ -248,6 +252,7 @@ export async function archiveFeishuChatBinding(
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() },
@@ -287,9 +292,10 @@ async function createManagedProject(
): Promise<ProjectOnboardingResult> {
const organization = await prisma.organization.findUnique({
where: { id: input.organizationId },
select: { id: true, slug: true },
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 = createProjectId();
const workspaceDir = projectWorkspaceDir({
@@ -297,48 +303,66 @@ async function createManagedProject(
organizationSlug: organization.slug,
projectId,
});
await mkdir(workspaceDir, { recursive: true });
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);
const 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.actorFeishuOpenId,
role: "MANAGE",
createdByUserId: input.actorUserId,
});
if (input.chatId !== undefined) {
await tx.projectGroupBinding.create({
data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId },
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: "FEISHU_CHAT",
principalId: input.chatId,
role: "EDIT",
principalType: "USER",
principalId: input.actorFeishuOpenId,
role: "MANAGE",
createdByUserId: input.actorUserId,
});
if (input.chatId !== undefined) {
await tx.projectGroupBinding.create({
data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId },
});
await replaceProjectGrant(tx, {
projectId: created.id,
principalType: "FEISHU_CHAT",
principalId: 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}`,
);
}
}
return created;
});
throw error;
}
await writeAudit(prisma, {
projectId: project.id,
@@ -382,6 +406,7 @@ async function requireActiveOrgMember(
organizationId: string,
feishuOpenId: string,
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> {
await requireActiveOrganization(prisma, organizationId);
const user = await prisma.user.findUnique({
where: { feishuOpenId },
select: {
@@ -407,7 +432,7 @@ async function ensureOrganizationProjectSettingsTx(
prisma: Prisma.TransactionClient,
organizationId: string,
): Promise<OrganizationProjectPolicy> {
await ensureOrganizationExistsTx(prisma, organizationId);
await requireActiveOrganizationTx(prisma, organizationId);
return prisma.organizationProjectSettings.upsert({
where: { organizationId },
update: {},
@@ -445,14 +470,17 @@ async function assertFolderInOrganization(
}
}
async function ensureOrganizationExists(prisma: PrismaClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise<void> {
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 ensureOrganizationExistsTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
async function requireActiveOrganizationTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
await lockActiveOrganization(prisma, organizationId);
}
async function replaceProjectGrant(
+125 -3
View File
@@ -1,5 +1,5 @@
import { constants } from "node:fs";
import { lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises";
import { link, lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import type { Readable } from "node:stream";
@@ -21,6 +21,12 @@ export interface WorkspaceFileSnapshot {
readonly data: Buffer;
}
export interface WorkspaceFileWriteResult {
readonly path: string;
readonly device: number;
readonly inode: number;
}
interface OpenDirectory {
readonly handle: FileHandle;
readonly displayPath: string;
@@ -72,6 +78,21 @@ export async function writeNewWorkspaceFileNoFollow(
requestedPath: string,
source: Readable,
): Promise<string> {
return (await writeNewWorkspaceFileNoFollowTracked(
workspaceRoot,
workspaceDir,
requestedPath,
source,
)).path;
}
/** Create one inbound file and return its identity for race-safe compensation. */
export async function writeNewWorkspaceFileNoFollowTracked(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
source: Readable,
): Promise<WorkspaceFileWriteResult> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!;
@@ -81,7 +102,7 @@ export async function writeNewWorkspaceFileNoFollow(
let file: FileHandle | undefined;
let device: number | undefined;
let inode: number | undefined;
let result: string | undefined;
let result: WorkspaceFileWriteResult | undefined;
let failure: unknown;
const cleanupFailures: unknown[] = [];
try {
@@ -104,7 +125,11 @@ export async function writeNewWorkspaceFileNoFollow(
}
await file.sync();
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
result = join(workspace, ...components);
result = {
path: join(workspace, ...components),
device: metadata.dev,
inode: metadata.ino,
};
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely");
if (device !== undefined && inode !== undefined) {
@@ -124,6 +149,103 @@ export async function writeNewWorkspaceFileNoFollow(
return result!;
}
/** Remove only the exact file created by a tracked no-follow write. */
export async function removeWorkspaceFileIfUnchangedNoFollow(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
expectedDevice: number,
expectedInode: number,
): Promise<void> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!;
const parent = await openDirectoryTree(workspace, components.slice(0, -1), false, requestedPath);
let failure: unknown;
try {
await unlinkIfSameFile(parent, name, expectedDevice, expectedInode);
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot compensate inbound workspace file safely");
}
await finishWithCleanup(
failure,
[parent.handle],
`inbound workspace compensation cleanup failed: ${requestedPath}`,
);
}
/**
* Publish a protected same-filesystem staging file with one exclusive hard-link
* operation. The target path is traversed without following workspace symlinks.
*/
export async function linkWorkspaceFileNoFollow(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
stagedPath: string,
): Promise<WorkspaceFileWriteResult> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!;
const parent = await openDirectoryTree(workspace, components.slice(0, -1), true, requestedPath);
let source: FileHandle | undefined;
let result: WorkspaceFileWriteResult | undefined;
let failure: unknown;
const cleanupFailures: unknown[] = [];
try {
source = await open(stagedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
const metadata = await source.stat();
if (!metadata.isFile()) {
throw new WorkspaceFileBoundaryError(`staged resource is not a regular file: ${stagedPath}`, stagedPath);
}
const targetPath = childPath(parent, name);
await link(stagedPath, targetPath);
try {
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
} catch (error) {
await unlinkIfSameFile(parent, name, metadata.dev, metadata.ino).catch((cleanupError) => {
cleanupFailures.push(cleanupError);
});
throw error;
}
result = {
path: join(workspace, ...components),
device: metadata.dev,
inode: metadata.ino,
};
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot publish staged workspace file safely");
}
try {
await finishWithCleanup(
failure,
[source, parent.handle],
`staged workspace publication cleanup failed: ${requestedPath}`,
cleanupFailures,
);
} catch (error) {
if (result !== undefined) {
try {
await removeWorkspaceFileIfUnchangedNoFollow(
workspaceRoot,
workspaceDir,
requestedPath,
result.device,
result.inode,
);
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
`staged workspace publication and compensation both failed: ${requestedPath}`,
{ cause: error },
);
}
}
throw error;
}
return result!;
}
async function canonicalWorkspace(workspaceRoot: string, workspaceDir: string): Promise<string> {
if (process.platform !== "linux") {
throw new WorkspaceFileBoundaryError(