forked from EduCraft/curriculum-project-hub
fix: enforce active organization boundary
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Enforce Organization status at the shared authorization seam
|
# Enforce Organization status at the shared authorization seam
|
||||||
|
|
||||||
Type: task
|
Type: task
|
||||||
Status: open
|
Status: resolved
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
|
|
||||||
@@ -9,3 +9,42 @@ Make non-ACTIVE Organizations fail closed for project authorization,
|
|||||||
onboarding, queued execution, and Feishu triggers through one shared seam, with
|
onboarding, queued execution, and Feishu triggers through one shared seam, with
|
||||||
negative tests proving a SUSPENDED or ARCHIVED tenant cannot create, bind,
|
negative tests proving a SUSPENDED or ARCHIVED tenant cannot create, bind,
|
||||||
trigger, resume, or mutate work while operator recovery remains explicit.
|
trigger, resume, or mutate work while operator recovery remains explicit.
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Organization lifecycle is now enforced at both authorization and mutation
|
||||||
|
linearization points:
|
||||||
|
|
||||||
|
- Project and project-group authorization resolves the owning Organization and
|
||||||
|
denies every customer action before principal resolution when status is not
|
||||||
|
`ACTIVE`. The legacy role helper follows the same rule.
|
||||||
|
- Every customer mutation service (project/folder/binding/policy, explorer,
|
||||||
|
members, teams, team grants, external-directory sync and slash-session
|
||||||
|
mutation) verifies `ACTIVE` while holding a PostgreSQL `FOR SHARE` lock on
|
||||||
|
the Organization row. A concurrent status `UPDATE` therefore orders before
|
||||||
|
the operation (which fails closed) or after its transaction commits.
|
||||||
|
- Agent session/run/project-lock admission is one transaction behind that same
|
||||||
|
lifecycle lock. Queued triggers reauthorize when drained. Slash-command races
|
||||||
|
return an explicit user-facing rejection instead of becoming deduplicated,
|
||||||
|
silent failures.
|
||||||
|
- Feishu attachments download into a private
|
||||||
|
`HUB_PROJECT_WORKSPACE_ROOT/.cph-staging` batch outside every project. A
|
||||||
|
durably committed ACTIVE run/project-lock admission is the linearization
|
||||||
|
point; publication then uses exclusive same-filesystem hard links rather than
|
||||||
|
copying bytes under a database lock. Failure compensates inode-tracked links,
|
||||||
|
fails the admitted run and releases its lock. Startup removes abandoned
|
||||||
|
staging batches and refuses staging symlinks.
|
||||||
|
- Project creation holds the lifecycle lock across workspace allocation and DB
|
||||||
|
creation. A later transaction failure removes the new leaf workspace; cleanup
|
||||||
|
failure is surfaced together with the original error as `AggregateError`.
|
||||||
|
- External-directory sync uses short lifecycle-serialized transactions per
|
||||||
|
unit rather than holding the Organization row lock across an unbounded sync.
|
||||||
|
|
||||||
|
No customer API can reactivate or otherwise change Organization lifecycle
|
||||||
|
state. Operator recovery remains a separate platform-control-plane task.
|
||||||
|
|
||||||
|
Evidence includes deterministic status/update lock ordering, direct-service
|
||||||
|
negative tests for both `SUSPENDED` and `ARCHIVED`, queued-trigger and
|
||||||
|
post-authorization races, DB-triggered workspace compensation (including
|
||||||
|
cleanup failure), full local Hub tests, and the real Linux attachment trigger
|
||||||
|
path.
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ rollback and recovery, and no known critical security or data-integrity gaps.
|
|||||||
- [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit.
|
- [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit.
|
||||||
- [Confine the agent runtime and protect service credentials](issues/16-confine-agent-and-protect-credentials.md) — replace the Agent environment, deny host reads/writes from root, keep every writable SDK path inside the canonical project, require bubblewrap plus socat, emit bounded correlated diagnostics, and prove the boundary with the real Linux SDK under a capability-free non-root identity.
|
- [Confine the agent runtime and protect service credentials](issues/16-confine-agent-and-protect-credentials.md) — replace the Agent environment, deny host reads/writes from root, keep every writable SDK path inside the canonical project, require bubblewrap plus socat, emit bounded correlated diagnostics, and prove the boundary with the real Linux SDK under a capability-free non-root identity.
|
||||||
- [Close MCP context and file-delivery escape paths](issues/17-close-mcp-data-egress-escapes.md) — use no-follow project-rooted file operations and delivery snapshots, remove Git-root delivery, and bind every Feishu MCP read to the run's project and chat.
|
- [Close MCP context and file-delivery escape paths](issues/17-close-mcp-data-egress-escapes.md) — use no-follow project-rooted file operations and delivery snapshots, remove Git-root delivery, and bind every Feishu MCP read to the run's project and chat.
|
||||||
|
- [Enforce Organization status at the shared authorization seam](issues/22-enforce-organization-status.md) — deny non-ACTIVE tenants before principal resolution, serialize every customer mutation and run admission against Organization lifecycle updates, reauthorize queued work, and stage/publish attachments with crash recovery and explicit filesystem compensation.
|
||||||
|
|
||||||
## Fog
|
## Fog
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { FastifyBaseLogger } from "fastify";
|
|||||||
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
|
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
|
||||||
import type { ModelRegistry, RoleEntry } from "../agent/models.js";
|
import type { ModelRegistry, RoleEntry } from "../agent/models.js";
|
||||||
import type { RuntimeSettings } from "../settings/runtime.js";
|
import type { RuntimeSettings } from "../settings/runtime.js";
|
||||||
|
import { lockActiveProjectOrganization } from "../org/status.js";
|
||||||
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
||||||
import type { TriggerQueue } from "./triggerQueue.js";
|
import type { TriggerQueue } from "./triggerQueue.js";
|
||||||
|
|
||||||
@@ -87,9 +88,12 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
|
|||||||
"不会清空当前项目的等待队列。",
|
"不会清空当前项目的等待队列。",
|
||||||
],
|
],
|
||||||
run: async ({ projectId, chatId, rt, sendOptions }) => {
|
run: async ({ projectId, chatId, rt, sendOptions }) => {
|
||||||
await deps.prisma.agentSession.updateMany({
|
await deps.prisma.$transaction(async (tx) => {
|
||||||
where: { projectId, archivedAt: null },
|
await lockActiveProjectOrganization(tx, projectId);
|
||||||
data: { archivedAt: new Date() },
|
await tx.agentSession.updateMany({
|
||||||
|
where: { projectId, archivedAt: null },
|
||||||
|
data: { archivedAt: new Date() },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions);
|
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions);
|
||||||
},
|
},
|
||||||
@@ -104,19 +108,24 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
|
|||||||
"没有可恢复会话时只回复提示,不会创建 agent run。",
|
"没有可恢复会话时只回复提示,不会创建 agent run。",
|
||||||
],
|
],
|
||||||
run: async ({ projectId, chatId, rt, sendOptions }) => {
|
run: async ({ projectId, chatId, rt, sendOptions }) => {
|
||||||
const latest = await deps.prisma.agentSession.findFirst({
|
const resumed = await deps.prisma.$transaction(async (tx) => {
|
||||||
where: { projectId, archivedAt: { not: null } },
|
await lockActiveProjectOrganization(tx, projectId);
|
||||||
orderBy: { archivedAt: "desc" },
|
const latest = await tx.agentSession.findFirst({
|
||||||
select: { id: true },
|
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);
|
await sendText(rt, chatId, "没有可恢复的会话。", sendOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deps.prisma.agentSession.update({
|
|
||||||
where: { id: latest.id },
|
|
||||||
data: { archivedAt: null },
|
|
||||||
});
|
|
||||||
await sendText(rt, chatId, "已恢复上一个会话。", sendOptions);
|
await sendText(rt, chatId, "已恢复上一个会话。", sendOptions);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -175,9 +184,12 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
|
|||||||
"清空当前项目已经排队、尚未开始的触发请求。",
|
"清空当前项目已经排队、尚未开始的触发请求。",
|
||||||
],
|
],
|
||||||
run: async ({ projectId, chatId, rt, sendOptions }) => {
|
run: async ({ projectId, chatId, rt, sendOptions }) => {
|
||||||
await deps.prisma.agentSession.updateMany({
|
await deps.prisma.$transaction(async (tx) => {
|
||||||
where: { projectId, archivedAt: null },
|
await lockActiveProjectOrganization(tx, projectId);
|
||||||
data: { archivedAt: new Date() },
|
await tx.agentSession.updateMany({
|
||||||
|
where: { projectId, archivedAt: null },
|
||||||
|
data: { archivedAt: new Date() },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
const cleared = deps.triggerQueue.clear(projectId);
|
const cleared = deps.triggerQueue.clear(projectId);
|
||||||
if (cleared > 0) {
|
if (cleared > 0) {
|
||||||
|
|||||||
+212
-138
@@ -9,6 +9,8 @@
|
|||||||
* ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
|
* ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
|
||||||
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
|
* 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 type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
@@ -20,7 +22,6 @@ import {
|
|||||||
reactToMessage,
|
reactToMessage,
|
||||||
addReaction,
|
addReaction,
|
||||||
removeReaction,
|
removeReaction,
|
||||||
downloadMessageFile,
|
|
||||||
parsePostMessage,
|
parsePostMessage,
|
||||||
resolveCard,
|
resolveCard,
|
||||||
resolveSenderName,
|
resolveSenderName,
|
||||||
@@ -31,17 +32,26 @@ import {
|
|||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
|
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
|
||||||
import type { RuntimeSettings } from "../settings/runtime.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 { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
|
||||||
import { writeAudit } from "../audit.js";
|
import { writeAudit } from "../audit.js";
|
||||||
import { formatRunCostLine } from "../agent/cost.js";
|
import { formatRunCostLine } from "../agent/cost.js";
|
||||||
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
|
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
|
||||||
|
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
|
||||||
import { StreamingAgentCard } from "./card/streaming-card.js";
|
import { StreamingAgentCard } from "./card/streaming-card.js";
|
||||||
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||||
import { readFeishuContext } from "./read.js";
|
import { readFeishuContext } from "./read.js";
|
||||||
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
|
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
|
||||||
import { ApprovalManager } from "./approval.js";
|
import { ApprovalManager } from "./approval.js";
|
||||||
import { SenderNameCache } from "./senderCache.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 { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
|
||||||
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
|
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
|
||||||
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.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
|
// slashes are left as literal text (extractRole returns null). Falls back
|
||||||
// to "draft" when no role is named — the registry degrades gracefully.
|
// to "draft" when no role is named — the registry degrades gracefully.
|
||||||
const models = await deps.settings.modelRegistry({ projectId });
|
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";
|
const roleId = parsedRole ?? "draft";
|
||||||
// ADR-0019: role trigger is the second gate after project agent.trigger.
|
// ADR-0019: role trigger is the second gate after project agent.trigger.
|
||||||
// Unconfigured roles remain open for back-compat; configured roles require
|
// Unconfigured roles remain open for back-compat; configured roles require
|
||||||
@@ -213,80 +223,148 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
rt,
|
rt,
|
||||||
senderOpenId,
|
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);
|
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
|
||||||
|
|
||||||
// ADR-0017: provider+role+model-bound session. Reuse if one exists for
|
// Linearization point for org lifecycle + session/run/lock admission.
|
||||||
// this (project, provider, role, model) tuple; otherwise create. Role is
|
// FOR SHARE on the Organization row conflicts with a concurrent status
|
||||||
// part of the key because role prompts/tool surfaces can differ even when
|
// UPDATE, so either the run is admitted while ACTIVE or suspension wins
|
||||||
// the underlying model is the same.
|
// and no session/run/lock/audit row is committed.
|
||||||
const existingSession = await deps.prisma.agentSession.findFirst({
|
let admission: {
|
||||||
where: { projectId, provider: providerId, roleId, model, archivedAt: null },
|
readonly session: { readonly id: string; readonly metadata: Prisma.JsonValue };
|
||||||
select: { id: true, metadata: true },
|
readonly run: { readonly id: string };
|
||||||
});
|
};
|
||||||
const session =
|
try {
|
||||||
existingSession !== null
|
admission = await deps.prisma.$transaction(async (tx) => {
|
||||||
? await deps.prisma.agentSession.update({
|
await lockActiveOrganization(tx, roleDecision.organizationId);
|
||||||
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);
|
|
||||||
|
|
||||||
const run = await deps.prisma.agentRun.create({
|
// ADR-0017: provider+role+model-bound session. Reuse if one exists for
|
||||||
data: {
|
// this tuple; otherwise create. Role remains part of the key.
|
||||||
projectId,
|
const existingSession = await tx.agentSession.findFirst({
|
||||||
sessionId: session.id,
|
where: { projectId, provider: providerId, roleId, model, archivedAt: null },
|
||||||
requestedByUserId: roleDecision.actorUserId ?? null,
|
select: { id: true, metadata: true },
|
||||||
entrypoint: "FEISHU",
|
});
|
||||||
status: "ACTIVE",
|
const session = existingSession !== null
|
||||||
prompt: promptForAgent,
|
? await tx.agentSession.update({
|
||||||
model,
|
where: { id: existingSession.id },
|
||||||
provider: providerId,
|
data: { provider: providerId, roleId, model, updatedAt: new Date() },
|
||||||
metadata: agentRunMetadata(roleId, agentPrompt, feishuTriggerContext),
|
select: { id: true, metadata: true },
|
||||||
},
|
})
|
||||||
select: { id: 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, {
|
await writeAudit(deps.prisma, {
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
projectId,
|
projectId,
|
||||||
|
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
|
||||||
action: "run.created",
|
action: "run.created",
|
||||||
metadata: {
|
metadata: {
|
||||||
roleId,
|
roleId,
|
||||||
model,
|
model,
|
||||||
prompt: agentPrompt.slice(0, 200),
|
prompt: agentPrompt.slice(0, 200),
|
||||||
sender: await senderAuditMetadata(rt, senderOpenId),
|
sender: senderMetadata,
|
||||||
feishuTriggerContext,
|
feishuTriggerContext,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const sessionMetadata = readSessionMetadata(session.metadata);
|
||||||
// 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 projectCtx: ProjectContext = {
|
const projectCtx: ProjectContext = {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -771,30 +849,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
} else if (msg.message_type === "post") {
|
} else if (msg.message_type === "post") {
|
||||||
const parsed = parsePostMessage(msg.content);
|
const parsed = parsePostMessage(msg.content);
|
||||||
cleanPrompt = extractPostPrompt(msg, parsed.text);
|
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") {
|
} 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>;
|
let content: z.infer<typeof FileMessageContentSchema>;
|
||||||
try {
|
try {
|
||||||
content = FileMessageContentSchema.parse(JSON.parse(msg.content));
|
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 ?? "";
|
const key = content.file_key ?? content.image_key ?? "";
|
||||||
if (key !== "") {
|
if (key !== "") {
|
||||||
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
|
cleanPrompt = "用户发来一个文件";
|
||||||
const savePath = await downloadMessageFile(
|
|
||||||
rt,
|
|
||||||
msg.message_id,
|
|
||||||
key,
|
|
||||||
projectWorkspaceRoot,
|
|
||||||
project.workspaceDir,
|
|
||||||
`.cph/inbox/${fileName}`,
|
|
||||||
msg.message_type,
|
|
||||||
);
|
|
||||||
cleanPrompt = `用户发来一个文件: ${savePath}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cleanPrompt === null) return;
|
if (cleanPrompt === null) return;
|
||||||
@@ -839,7 +884,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
if (invocation !== null) {
|
if (invocation !== null) {
|
||||||
const slashCommand = slashCommands.get(invocation.name);
|
const slashCommand = slashCommands.get(invocation.name);
|
||||||
if (slashCommand !== undefined) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1308,60 +1362,80 @@ function escapeRegExp(value: string): string {
|
|||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadedMessageResource {
|
function isPrismaUniqueConstraintError(error: unknown): boolean {
|
||||||
readonly resourceType: "image" | "file";
|
return typeof error === "object" && error !== null && "code" in error && error.code === "P2002";
|
||||||
readonly path: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadPostMessageFiles(args: {
|
async function stageTriggerMessageResources(
|
||||||
readonly rt: FeishuRuntime;
|
rt: FeishuRuntime,
|
||||||
readonly msg: MessageReceiveEvent["message"];
|
msg: MessageReceiveEvent["message"],
|
||||||
readonly workspaceRoot: string;
|
workspaceRoot: string,
|
||||||
readonly workspaceDir: string;
|
): Promise<StagedMessageResourceBatch | null> {
|
||||||
readonly imageKeys: readonly string[];
|
const requests: MessageResourceStageRequest[] = [];
|
||||||
readonly fileKeys: readonly string[];
|
if (msg.message_type === "post") {
|
||||||
}): Promise<readonly DownloadedMessageResource[]> {
|
const parsed = parsePostMessage(msg.content);
|
||||||
const timestamp = Date.now();
|
for (const imageKey of parsed.imageKeys) {
|
||||||
const downloadedResources: DownloadedMessageResource[] = [];
|
requests.push({
|
||||||
for (const [index, imageKey] of args.imageKeys.entries()) {
|
fileKey: imageKey,
|
||||||
const savePath = await downloadMessageFile(
|
resourceType: "image",
|
||||||
args.rt,
|
workspaceRelativePath: `.cph/inbox/post-image-${randomUUID()}.png`,
|
||||||
args.msg.message_id,
|
});
|
||||||
imageKey,
|
}
|
||||||
args.workspaceRoot,
|
for (const fileKey of parsed.fileKeys) {
|
||||||
args.workspaceDir,
|
requests.push({
|
||||||
`.cph/inbox/post-image-${timestamp}-${index}.png`,
|
fileKey,
|
||||||
"image",
|
resourceType: "file",
|
||||||
);
|
workspaceRelativePath: `.cph/inbox/post-file-${randomUUID()}.bin`,
|
||||||
downloadedResources.push({ resourceType: "image", path: savePath });
|
});
|
||||||
|
}
|
||||||
|
} 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()) {
|
return stageMessageResources(rt, msg.message_id, requests, workspaceRoot);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendDownloadedResourcePaths(
|
function appendStagedResourcePaths(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
resources: readonly DownloadedMessageResource[],
|
batch: StagedMessageResourceBatch | null,
|
||||||
|
workspaceDir: string,
|
||||||
): string {
|
): string {
|
||||||
|
const resources = batch?.resources ?? [];
|
||||||
if (resources.length === 0) return prompt;
|
if (resources.length === 0) return prompt;
|
||||||
const paths = resources.map((resource) => {
|
const paths = resources.map((resource) => {
|
||||||
const label = resource.resourceType === "image" ? "图片" : "文件";
|
const label = resource.resourceType === "image" ? "图片" : "文件";
|
||||||
return `- ${label}: ${resource.path}`;
|
return `- ${label}: ${join(workspaceDir, resource.workspaceRelativePath)}`;
|
||||||
});
|
});
|
||||||
return `${prompt}\n\n消息附件已下载到项目工作区,可直接读取以下本地文件:\n${paths.join("\n")}`;
|
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 帮我写教案`).
|
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
|
||||||
* Returns the role id and the remaining prompt with the command stripped.
|
* Returns the role id and the remaining prompt with the command stripped.
|
||||||
|
|||||||
+6
-3
@@ -3,6 +3,7 @@ import { registerAdminPlugin } from "./admin/plugin.js";
|
|||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||||
|
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
|
||||||
import { triggerQueue } from "./feishu/triggerQueue.js";
|
import { triggerQueue } from "./feishu/triggerQueue.js";
|
||||||
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||||
import { readServerBinding } from "./settings/server.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. */
|
/** Start the Hub after every import and runtime dependency has loaded. */
|
||||||
export async function startHub(): Promise<void> {
|
export async function startHub(): Promise<void> {
|
||||||
requireEnv("DATABASE_URL");
|
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();
|
const runtimeSettings = createEnvRuntimeSettings();
|
||||||
await runtimeSettings.provider("openrouter");
|
await runtimeSettings.provider("openrouter");
|
||||||
@@ -31,13 +37,10 @@ export async function startHub(): Promise<void> {
|
|||||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||||
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
|
||||||
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
|
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
|
||||||
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
|
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
|
||||||
const bind = readServerBinding();
|
const bind = readServerBinding();
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
|
||||||
|
|
||||||
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
||||||
await prisma.projectAgentLock.deleteMany({});
|
await prisma.projectAgentLock.deleteMany({});
|
||||||
await prisma.agentRun.updateMany({
|
await prisma.agentRun.updateMany({
|
||||||
|
|||||||
+12
-5
@@ -11,6 +11,7 @@ import {
|
|||||||
createProjectFromOrgAdmin,
|
createProjectFromOrgAdmin,
|
||||||
moveProjectToFolder,
|
moveProjectToFolder,
|
||||||
} from "../projectOnboarding.js";
|
} from "../projectOnboarding.js";
|
||||||
|
import { lockActiveOrganization } from "./status.js";
|
||||||
|
|
||||||
export interface ExplorerFolderNode {
|
export interface ExplorerFolderNode {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -120,6 +121,7 @@ export async function renameFolder(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
|
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
|
||||||
if (input.parentId !== undefined && input.parentId !== null) {
|
if (input.parentId !== undefined && input.parentId !== null) {
|
||||||
if (input.parentId === folder.id) {
|
if (input.parentId === folder.id) {
|
||||||
@@ -148,6 +150,7 @@ export async function archiveFolder(
|
|||||||
input: { readonly organizationId: string; readonly folderId: string },
|
input: { readonly organizationId: string; readonly folderId: string },
|
||||||
): Promise<{ readonly archived: true; readonly folderId: string }> {
|
): Promise<{ readonly archived: true; readonly folderId: string }> {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
|
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
|
||||||
const childFolders = await tx.folder.count({
|
const childFolders = await tx.folder.count({
|
||||||
where: { parentId: folder.id, archivedAt: null },
|
where: { parentId: folder.id, archivedAt: null },
|
||||||
@@ -195,11 +198,14 @@ export async function renameProject(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const name = requireNonEmpty(input.name, "project name");
|
const name = requireNonEmpty(input.name, "project name");
|
||||||
const project = await requireActiveProject(prisma, input.projectId, input.organizationId);
|
return prisma.$transaction(async (tx) => {
|
||||||
return prisma.project.update({
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
where: { id: project.id },
|
const project = await requireActiveProject(tx, input.projectId, input.organizationId);
|
||||||
data: { name },
|
return tx.project.update({
|
||||||
select: { id: true, name: true, folderId: true },
|
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 },
|
input: { readonly organizationId: string; readonly projectId: string },
|
||||||
): Promise<{ readonly archived: true; readonly projectId: string }> {
|
): Promise<{ readonly archived: true; readonly projectId: string }> {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
const project = await requireActiveProject(tx, input.projectId, input.organizationId);
|
const project = await requireActiveProject(tx, input.projectId, input.organizationId);
|
||||||
const activeBinding = await tx.projectGroupBinding.findFirst({
|
const activeBinding = await tx.projectGroupBinding.findFirst({
|
||||||
where: { projectId: project.id, archivedAt: null },
|
where: { projectId: project.id, archivedAt: null },
|
||||||
|
|||||||
+89
-87
@@ -7,7 +7,8 @@
|
|||||||
* 3. Cannot revoke or demote the last remaining OWNER.
|
* 3. Cannot revoke or demote the last remaining OWNER.
|
||||||
* 4. ADMIN cannot modify OWNER memberships.
|
* 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 {
|
export interface OrgMemberRow {
|
||||||
readonly userId: string;
|
readonly userId: string;
|
||||||
@@ -56,32 +57,32 @@ export async function addOrgMember(
|
|||||||
const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId");
|
const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId");
|
||||||
assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false);
|
assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false);
|
||||||
|
|
||||||
const user = await prisma.user.upsert({
|
const { user, membership } = await prisma.$transaction(async (tx) => {
|
||||||
where: { feishuOpenId: openId },
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
create: {
|
const user = await tx.user.upsert({
|
||||||
feishuOpenId: openId,
|
where: { feishuOpenId: openId },
|
||||||
displayName: input.displayName?.trim() || openId,
|
create: {
|
||||||
},
|
feishuOpenId: openId,
|
||||||
update: {
|
displayName: input.displayName?.trim() || openId,
|
||||||
...(input.displayName !== undefined && input.displayName.trim() !== ""
|
},
|
||||||
? { displayName: input.displayName.trim() }
|
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 },
|
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`);
|
if (existing !== null) throw new Error(`user ${user.id} is already an active member`);
|
||||||
}
|
const membership = await tx.organizationMembership.create({
|
||||||
|
data: {
|
||||||
const membership = await prisma.organizationMembership.create({
|
organizationId: input.organizationId,
|
||||||
data: {
|
userId: user.id,
|
||||||
organizationId: input.organizationId,
|
role: input.role,
|
||||||
userId: user.id,
|
},
|
||||||
role: input.role,
|
});
|
||||||
},
|
return { user, membership };
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -104,46 +105,47 @@ export async function setOrgMemberRole(
|
|||||||
readonly role: OrganizationMemberRole;
|
readonly role: OrganizationMemberRole;
|
||||||
},
|
},
|
||||||
): Promise<OrgMemberRow> {
|
): Promise<OrgMemberRow> {
|
||||||
const membership = await prisma.organizationMembership.findFirst({
|
return prisma.$transaction(async (tx) => {
|
||||||
where: {
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
organizationId: input.organizationId,
|
const membership = await tx.organizationMembership.findFirst({
|
||||||
userId: input.targetUserId,
|
where: {
|
||||||
revokedAt: null,
|
organizationId: input.organizationId,
|
||||||
},
|
userId: input.targetUserId,
|
||||||
select: {
|
revokedAt: null,
|
||||||
id: true,
|
|
||||||
role: true,
|
|
||||||
createdAt: true,
|
|
||||||
user: {
|
|
||||||
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true },
|
|
||||||
},
|
},
|
||||||
},
|
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(
|
export async function revokeOrgMember(
|
||||||
@@ -155,27 +157,27 @@ export async function revokeOrgMember(
|
|||||||
readonly targetUserId: string;
|
readonly targetUserId: string;
|
||||||
},
|
},
|
||||||
): Promise<{ readonly revoked: true; readonly userId: string }> {
|
): Promise<{ readonly revoked: true; readonly userId: string }> {
|
||||||
const membership = await prisma.organizationMembership.findFirst({
|
await prisma.$transaction(async (tx) => {
|
||||||
where: {
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
organizationId: input.organizationId,
|
const membership = await tx.organizationMembership.findFirst({
|
||||||
userId: input.targetUserId,
|
where: {
|
||||||
revokedAt: null,
|
organizationId: input.organizationId,
|
||||||
},
|
userId: input.targetUserId,
|
||||||
select: { id: true, role: true },
|
revokedAt: null,
|
||||||
});
|
},
|
||||||
if (membership === null) {
|
select: { id: true, role: true },
|
||||||
throw new Error(`member not found: ${input.targetUserId}`);
|
});
|
||||||
}
|
if (membership === null) throw new Error(`member not found: ${input.targetUserId}`);
|
||||||
if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
|
if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
|
||||||
throw new Error("cannot revoke OWNER membership as ADMIN");
|
throw new Error("cannot revoke OWNER membership as ADMIN");
|
||||||
}
|
}
|
||||||
if (membership.role === "OWNER") {
|
if (membership.role === "OWNER") {
|
||||||
await assertNotLastOwner(prisma, input.organizationId, input.targetUserId);
|
await assertNotLastOwner(tx, input.organizationId, input.targetUserId);
|
||||||
}
|
}
|
||||||
|
await tx.organizationMembership.update({
|
||||||
await prisma.organizationMembership.update({
|
where: { id: membership.id },
|
||||||
where: { id: membership.id },
|
data: { revokedAt: new Date() },
|
||||||
data: { revokedAt: new Date() },
|
});
|
||||||
});
|
});
|
||||||
return { revoked: true as const, userId: input.targetUserId };
|
return { revoked: true as const, userId: input.targetUserId };
|
||||||
}
|
}
|
||||||
@@ -194,7 +196,7 @@ function assertCanAssignRole(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function assertNotLastOwner(
|
async function assertNotLastOwner(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient | Prisma.TransactionClient,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -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
@@ -5,6 +5,7 @@
|
|||||||
* grants that use this team as principal (product pin).
|
* grants that use this team as principal (product pin).
|
||||||
*/
|
*/
|
||||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
import { lockActiveOrganization } from "./status.js";
|
||||||
|
|
||||||
export interface TeamRow {
|
export interface TeamRow {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -59,20 +60,21 @@ export async function createTeam(
|
|||||||
): Promise<TeamRow> {
|
): Promise<TeamRow> {
|
||||||
const slug = sanitizeSlug(input.slug);
|
const slug = sanitizeSlug(input.slug);
|
||||||
const name = requireNonEmpty(input.name, "name");
|
const name = requireNonEmpty(input.name, "name");
|
||||||
const existing = await prisma.team.findFirst({
|
const team = await prisma.$transaction(async (tx) => {
|
||||||
where: { organizationId: input.organizationId, slug, archivedAt: null },
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
select: { id: true },
|
const existing = await tx.team.findFirst({
|
||||||
});
|
where: { organizationId: input.organizationId, slug, archivedAt: null },
|
||||||
if (existing !== null) {
|
select: { id: true },
|
||||||
throw new Error(`team slug already exists: ${slug}`);
|
});
|
||||||
}
|
if (existing !== null) throw new Error(`team slug already exists: ${slug}`);
|
||||||
const team = await prisma.team.create({
|
return tx.team.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
slug,
|
slug,
|
||||||
name,
|
name,
|
||||||
...(input.description !== undefined ? { description: input.description } : {}),
|
...(input.description !== undefined ? { description: input.description } : {}),
|
||||||
},
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
id: team.id,
|
id: team.id,
|
||||||
@@ -93,21 +95,24 @@ export async function updateTeam(
|
|||||||
readonly description?: string | null | undefined;
|
readonly description?: string | null | undefined;
|
||||||
},
|
},
|
||||||
): Promise<TeamRow> {
|
): Promise<TeamRow> {
|
||||||
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId);
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
const updated = await prisma.team.update({
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
where: { id: team.id },
|
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
|
||||||
data: {
|
return tx.team.update({
|
||||||
...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}),
|
where: { id: team.id },
|
||||||
...(input.description !== undefined ? { description: input.description } : {}),
|
data: {
|
||||||
},
|
...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}),
|
||||||
select: {
|
...(input.description !== undefined ? { description: input.description } : {}),
|
||||||
id: true,
|
},
|
||||||
slug: true,
|
select: {
|
||||||
name: true,
|
id: true,
|
||||||
description: true,
|
slug: true,
|
||||||
createdAt: true,
|
name: true,
|
||||||
_count: { select: { memberships: { where: { revokedAt: null } } } },
|
description: true,
|
||||||
},
|
createdAt: true,
|
||||||
|
_count: { select: { memberships: { where: { revokedAt: null } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
@@ -127,6 +132,7 @@ export async function archiveTeam(
|
|||||||
input: { readonly organizationId: string; readonly teamId: string },
|
input: { readonly organizationId: string; readonly teamId: string },
|
||||||
): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> {
|
): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
|
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await tx.team.update({
|
await tx.team.update({
|
||||||
@@ -183,34 +189,32 @@ export async function addTeamMember(
|
|||||||
readonly feishuOpenId?: string | undefined;
|
readonly feishuOpenId?: string | undefined;
|
||||||
},
|
},
|
||||||
): Promise<TeamMemberRow> {
|
): Promise<TeamMemberRow> {
|
||||||
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId);
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
const user = await resolveUser(prisma, input);
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
// User should be an org member to join a team (product pin for pilot).
|
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
|
||||||
const membership = await prisma.organizationMembership.findFirst({
|
const user = await resolveUser(tx, input);
|
||||||
where: {
|
// User should be an org member to join a team (product pin for pilot).
|
||||||
organizationId: input.organizationId,
|
const membership = await tx.organizationMembership.findFirst({
|
||||||
userId: user.id,
|
where: {
|
||||||
revokedAt: null,
|
organizationId: input.organizationId,
|
||||||
},
|
userId: user.id,
|
||||||
select: { id: true },
|
revokedAt: null,
|
||||||
});
|
},
|
||||||
if (membership === null) {
|
select: { id: true },
|
||||||
throw new Error(`user ${user.id} is not an active member of the organization`);
|
});
|
||||||
}
|
if (membership === null) throw new Error(`user ${user.id} is not an active member of the organization`);
|
||||||
const existing = await prisma.teamMembership.findFirst({
|
const existing = await tx.teamMembership.findFirst({
|
||||||
where: { teamId: team.id, userId: user.id, revokedAt: null },
|
where: { teamId: team.id, userId: user.id, revokedAt: null },
|
||||||
});
|
});
|
||||||
if (existing !== null) {
|
if (existing !== null) throw new Error(`user ${user.id} is already on team ${team.id}`);
|
||||||
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 };
|
||||||
const row = await prisma.teamMembership.create({
|
|
||||||
data: { teamId: team.id, userId: user.id },
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: result.user.id,
|
||||||
feishuOpenId: user.feishuOpenId,
|
feishuOpenId: result.user.feishuOpenId,
|
||||||
displayName: user.displayName,
|
displayName: result.user.displayName,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: result.row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,23 +226,24 @@ export async function revokeTeamMember(
|
|||||||
readonly userId: string;
|
readonly userId: string;
|
||||||
},
|
},
|
||||||
): Promise<{ readonly revoked: true; readonly userId: string }> {
|
): Promise<{ readonly revoked: true; readonly userId: string }> {
|
||||||
await requireActiveTeam(prisma, input.teamId, input.organizationId);
|
await prisma.$transaction(async (tx) => {
|
||||||
const membership = await prisma.teamMembership.findFirst({
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
where: { teamId: input.teamId, userId: input.userId, revokedAt: null },
|
await requireActiveTeam(tx, input.teamId, input.organizationId);
|
||||||
select: { id: true },
|
const membership = await tx.teamMembership.findFirst({
|
||||||
});
|
where: { teamId: input.teamId, userId: input.userId, revokedAt: null },
|
||||||
if (membership === null) {
|
select: { id: true },
|
||||||
throw new Error(`team member not found: ${input.userId}`);
|
});
|
||||||
}
|
if (membership === null) throw new Error(`team member not found: ${input.userId}`);
|
||||||
await prisma.teamMembership.update({
|
await tx.teamMembership.update({
|
||||||
where: { id: membership.id },
|
where: { id: membership.id },
|
||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return { revoked: true as const, userId: input.userId };
|
return { revoked: true as const, userId: input.userId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveUser(
|
async function resolveUser(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient | Prisma.TransactionClient,
|
||||||
input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined },
|
input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined },
|
||||||
): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> {
|
): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> {
|
||||||
if (input.userId !== undefined && input.userId !== "") {
|
if (input.userId !== undefined && input.userId !== "") {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
|
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
|
||||||
|
import { inactiveOrganizationReason } from "./org/status.js";
|
||||||
|
|
||||||
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
|
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
|
||||||
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
|
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
|
||||||
@@ -73,6 +74,13 @@ export async function canTriggerRole(
|
|||||||
principal: string,
|
principal: string,
|
||||||
): Promise<PermissionResult> {
|
): Promise<PermissionResult> {
|
||||||
try {
|
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({
|
const anyGrant = await prisma.roleTriggerGrant.findFirst({
|
||||||
where: { projectId, roleId },
|
where: { projectId, roleId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|||||||
@@ -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 { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
|
||||||
|
import { inactiveOrganizationReason } from "../org/status.js";
|
||||||
|
|
||||||
export type AuthorizationAction =
|
export type AuthorizationAction =
|
||||||
| "project.read"
|
| "project.read"
|
||||||
@@ -61,8 +62,16 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
|||||||
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
|
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
|
||||||
throw new Error("role.trigger authorization requires roleId");
|
throw new Error("role.trigger authorization requires roleId");
|
||||||
}
|
}
|
||||||
const organizationId = await this.organizationForResource(req.resource);
|
const organization = await this.organizationForResource(req.resource);
|
||||||
const resolution = await this.resolver.resolveActor(req.actor, { organizationId });
|
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);
|
const threshold = await this.requiredRole(req);
|
||||||
if (threshold === "DISABLED") {
|
if (threshold === "DISABLED") {
|
||||||
return this.decision(req, resolution, {
|
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) {
|
switch (resource.type) {
|
||||||
case "PROJECT": {
|
case "PROJECT": {
|
||||||
const project = await this.prisma.project.findUnique({
|
const project = await this.prisma.project.findUnique({
|
||||||
where: { id: resource.id },
|
where: { id: resource.id },
|
||||||
select: { organizationId: true },
|
select: { organization: { select: { id: true, status: true } } },
|
||||||
});
|
});
|
||||||
if (project === null) {
|
if (project === null) {
|
||||||
throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
|
throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
|
||||||
}
|
}
|
||||||
return project.organizationId;
|
return project.organization;
|
||||||
}
|
}
|
||||||
case "PROJECT_GROUP": {
|
case "PROJECT_GROUP": {
|
||||||
const binding = await this.prisma.projectGroupBinding.findFirst({
|
const binding = await this.prisma.projectGroupBinding.findFirst({
|
||||||
where: { chatId: resource.id, archivedAt: null },
|
where: { chatId: resource.id, archivedAt: null },
|
||||||
select: { project: { select: { organizationId: true } } },
|
select: { project: { select: { organization: { select: { id: true, status: true } } } } },
|
||||||
});
|
});
|
||||||
if (binding === null) {
|
if (binding === null) {
|
||||||
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
|
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
|
||||||
}
|
}
|
||||||
return binding.project.organizationId;
|
return binding.project.organization;
|
||||||
}
|
}
|
||||||
case "ARTIFACT":
|
case "ARTIFACT":
|
||||||
throw new Error("authorization for ARTIFACT resources requires an organization resolver");
|
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,
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<
|
export type ExternalPrincipalType = Extract<
|
||||||
PrincipalType,
|
PrincipalType,
|
||||||
@@ -33,98 +34,112 @@ export async function syncExternalPrincipalMemberships(
|
|||||||
const syncedAt = new Date();
|
const syncedAt = new Date();
|
||||||
const incomingKeys = new Set<string>();
|
const incomingKeys = new Set<string>();
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
const connection = await prisma.externalDirectoryConnection.upsert({
|
const connection = await prisma.$transaction(async (tx) => {
|
||||||
where: {
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
organizationId_provider_source: {
|
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,
|
organizationId: input.organizationId,
|
||||||
provider: input.provider ?? "FEISHU",
|
provider: input.provider ?? "FEISHU",
|
||||||
|
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
|
||||||
source: input.source,
|
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 },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (existing === null) {
|
});
|
||||||
await prisma.externalPrincipalMembership.create({
|
|
||||||
data: {
|
// Each membership is a short lifecycle-serialized unit. A large directory
|
||||||
userId: user.id,
|
// must not hold the Organization row lock (or one interactive transaction)
|
||||||
connectionId: connection.id,
|
// for the duration of the entire external sync.
|
||||||
principalType: item.principalType,
|
for (const item of input.memberships) {
|
||||||
principalId: item.principalId,
|
assertExternalPrincipalType(item.principalType);
|
||||||
syncedAt,
|
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 ensureOrganizationMembership(tx, input.organizationId, user.id);
|
||||||
await prisma.externalPrincipalMembership.update({
|
const existing = await tx.externalPrincipalMembership.findFirst({
|
||||||
where: { id: existing.id },
|
where: {
|
||||||
data: { syncedAt },
|
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++;
|
synced++;
|
||||||
}
|
}
|
||||||
|
|
||||||
let revoked = 0;
|
let revoked = 0;
|
||||||
if (input.replaceSource === true) {
|
if (input.replaceSource === true) {
|
||||||
const active = await prisma.externalPrincipalMembership.findMany({
|
revoked = await prisma.$transaction(async (tx) => {
|
||||||
where: { connectionId: connection.id, revokedAt: null },
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
|
const active = await tx.externalPrincipalMembership.findMany({
|
||||||
});
|
where: { connectionId: connection.id, revokedAt: null },
|
||||||
const revokeIds = active
|
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
|
||||||
.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 = 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 };
|
return { synced, revoked };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureOrganizationMembership(
|
async function ensureOrganizationMembership(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient | Prisma.TransactionClient,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
import { lockActiveOrganization } from "../org/status.js";
|
||||||
|
|
||||||
export interface GrantTeamProjectAccessInput {
|
export interface GrantTeamProjectAccessInput {
|
||||||
readonly projectId: string;
|
readonly projectId: string;
|
||||||
@@ -30,6 +31,7 @@ export async function grantTeamProjectAccess(
|
|||||||
): Promise<ProjectTeamAccessEntry> {
|
): Promise<ProjectTeamAccessEntry> {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const { project, team } = await resolveProjectAndTeam(tx, input);
|
const { project, team } = await resolveProjectAndTeam(tx, input);
|
||||||
|
await lockActiveOrganization(tx, project.organizationId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const existing = await tx.permissionGrant.findFirst({
|
const existing = await tx.permissionGrant.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -87,6 +89,7 @@ export async function revokeTeamProjectAccess(
|
|||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const { project, team } = await resolveProjectAndTeam(tx, input);
|
const { project, team } = await resolveProjectAndTeam(tx, input);
|
||||||
|
await lockActiveOrganization(tx, project.organizationId);
|
||||||
const result = await tx.permissionGrant.updateMany({
|
const result = await tx.permissionGrant.updateMany({
|
||||||
where: {
|
where: {
|
||||||
resourceType: "PROJECT",
|
resourceType: "PROJECT",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
import { relative, resolve } from "node:path";
|
import { dirname, relative, resolve } from "node:path";
|
||||||
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
||||||
import { createPermissionAuthorizer } from "./permission.js";
|
import { createPermissionAuthorizer } from "./permission.js";
|
||||||
import { writeAudit } from "./audit.js";
|
import { writeAudit } from "./audit.js";
|
||||||
|
import { lockActiveOrganization, requireActiveOrganizationStatus } from "./org/status.js";
|
||||||
|
|
||||||
export interface OrganizationProjectPolicy {
|
export interface OrganizationProjectPolicy {
|
||||||
readonly organizationId: string;
|
readonly organizationId: string;
|
||||||
@@ -72,23 +73,24 @@ export async function setMembersCanCreateProjects(
|
|||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
input: { readonly organizationId: string; readonly enabled: boolean },
|
input: { readonly organizationId: string; readonly enabled: boolean },
|
||||||
): Promise<OrganizationProjectPolicy> {
|
): Promise<OrganizationProjectPolicy> {
|
||||||
await ensureOrganizationExists(prisma, input.organizationId);
|
return prisma.$transaction(async (tx) => {
|
||||||
const settings = await prisma.organizationProjectSettings.upsert({
|
await requireActiveOrganizationTx(tx, input.organizationId);
|
||||||
where: { organizationId: input.organizationId },
|
return tx.organizationProjectSettings.upsert({
|
||||||
update: { membersCanCreateProjects: input.enabled },
|
where: { organizationId: input.organizationId },
|
||||||
create: {
|
update: { membersCanCreateProjects: input.enabled },
|
||||||
organizationId: input.organizationId,
|
create: {
|
||||||
membersCanCreateProjects: input.enabled,
|
organizationId: input.organizationId,
|
||||||
},
|
membersCanCreateProjects: input.enabled,
|
||||||
select: { organizationId: true, membersCanCreateProjects: true },
|
},
|
||||||
|
select: { organizationId: true, membersCanCreateProjects: true },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return settings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> {
|
export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> {
|
||||||
const name = requireNonEmpty(input.name, "folder name");
|
const name = requireNonEmpty(input.name, "folder name");
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
await ensureOrganizationExistsTx(tx, input.organizationId);
|
await requireActiveOrganizationTx(tx, input.organizationId);
|
||||||
if (input.parentId !== undefined) {
|
if (input.parentId !== undefined) {
|
||||||
await assertFolderInOrganization(tx, input.parentId, input.organizationId);
|
await assertFolderInOrganization(tx, input.parentId, input.organizationId);
|
||||||
}
|
}
|
||||||
@@ -113,6 +115,7 @@ export async function moveProjectToFolder(
|
|||||||
select: { id: true, organizationId: true },
|
select: { id: true, organizationId: true },
|
||||||
});
|
});
|
||||||
if (project === null) throw new Error(`project not found: ${input.projectId}`);
|
if (project === null) throw new Error(`project not found: ${input.projectId}`);
|
||||||
|
await requireActiveOrganizationTx(tx, project.organizationId);
|
||||||
if (input.folderId !== null) {
|
if (input.folderId !== null) {
|
||||||
await assertFolderInOrganization(tx, input.folderId, project.organizationId);
|
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}`);
|
if (project === null) throw new Error(`project not found: ${input.projectId}`);
|
||||||
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
|
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await requireActiveOrganizationTx(tx, project.organizationId);
|
||||||
const activeProjectBinding = await tx.projectGroupBinding.findFirst({
|
const activeProjectBinding = await tx.projectGroupBinding.findFirst({
|
||||||
where: { projectId: project.id, archivedAt: null },
|
where: { projectId: project.id, archivedAt: null },
|
||||||
select: { chatId: true },
|
select: { chatId: true },
|
||||||
@@ -248,6 +252,7 @@ export async function archiveFeishuChatBinding(
|
|||||||
if (binding === null) return { archived: false };
|
if (binding === null) return { archived: false };
|
||||||
const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId);
|
const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId);
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await requireActiveOrganizationTx(tx, binding.project.organizationId);
|
||||||
await tx.projectGroupBinding.update({
|
await tx.projectGroupBinding.update({
|
||||||
where: { id: binding.id },
|
where: { id: binding.id },
|
||||||
data: { archivedAt: new Date() },
|
data: { archivedAt: new Date() },
|
||||||
@@ -287,9 +292,10 @@ async function createManagedProject(
|
|||||||
): Promise<ProjectOnboardingResult> {
|
): Promise<ProjectOnboardingResult> {
|
||||||
const organization = await prisma.organization.findUnique({
|
const organization = await prisma.organization.findUnique({
|
||||||
where: { id: input.organizationId },
|
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}`);
|
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
|
||||||
|
requireActiveOrganizationStatus(organization.id, organization.status);
|
||||||
|
|
||||||
const projectId = createProjectId();
|
const projectId = createProjectId();
|
||||||
const workspaceDir = projectWorkspaceDir({
|
const workspaceDir = projectWorkspaceDir({
|
||||||
@@ -297,48 +303,66 @@ async function createManagedProject(
|
|||||||
organizationSlug: organization.slug,
|
organizationSlug: organization.slug,
|
||||||
projectId,
|
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 mkdir(dirname(workspaceDir), { recursive: true });
|
||||||
await ensureOrganizationProjectSettingsTx(tx, organization.id);
|
await mkdir(workspaceDir);
|
||||||
const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id;
|
workspaceCreated = true;
|
||||||
await assertFolderInOrganization(tx, folderId, organization.id);
|
|
||||||
|
|
||||||
const created = await tx.project.create({
|
const created = await tx.project.create({
|
||||||
data: {
|
data: {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
folderId,
|
folderId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
createdByUserId: input.actorUserId,
|
createdByUserId: input.actorUserId,
|
||||||
},
|
},
|
||||||
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
|
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
|
||||||
});
|
});
|
||||||
await tx.permissionSettings.create({
|
await tx.permissionSettings.create({
|
||||||
data: defaultProjectPermissionSettings(created.id),
|
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 },
|
|
||||||
});
|
});
|
||||||
await replaceProjectGrant(tx, {
|
await replaceProjectGrant(tx, {
|
||||||
projectId: created.id,
|
projectId: created.id,
|
||||||
principalType: "FEISHU_CHAT",
|
principalType: "USER",
|
||||||
principalId: input.chatId,
|
principalId: input.actorFeishuOpenId,
|
||||||
role: "EDIT",
|
role: "MANAGE",
|
||||||
createdByUserId: input.actorUserId,
|
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, {
|
await writeAudit(prisma, {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
@@ -382,6 +406,7 @@ async function requireActiveOrgMember(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
feishuOpenId: string,
|
feishuOpenId: string,
|
||||||
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> {
|
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> {
|
||||||
|
await requireActiveOrganization(prisma, organizationId);
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { feishuOpenId },
|
where: { feishuOpenId },
|
||||||
select: {
|
select: {
|
||||||
@@ -407,7 +432,7 @@ async function ensureOrganizationProjectSettingsTx(
|
|||||||
prisma: Prisma.TransactionClient,
|
prisma: Prisma.TransactionClient,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
): Promise<OrganizationProjectPolicy> {
|
): Promise<OrganizationProjectPolicy> {
|
||||||
await ensureOrganizationExistsTx(prisma, organizationId);
|
await requireActiveOrganizationTx(prisma, organizationId);
|
||||||
return prisma.organizationProjectSettings.upsert({
|
return prisma.organizationProjectSettings.upsert({
|
||||||
where: { organizationId },
|
where: { organizationId },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -445,14 +470,17 @@ async function assertFolderInOrganization(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureOrganizationExists(prisma: PrismaClient, organizationId: string): Promise<void> {
|
async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise<void> {
|
||||||
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
|
const organization = await prisma.organization.findUnique({
|
||||||
|
where: { id: organizationId },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
|
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> {
|
async function requireActiveOrganizationTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
|
||||||
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
|
await lockActiveOrganization(prisma, organizationId);
|
||||||
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceProjectGrant(
|
async function replaceProjectGrant(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { constants } from "node:fs";
|
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 { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
@@ -21,6 +21,12 @@ export interface WorkspaceFileSnapshot {
|
|||||||
readonly data: Buffer;
|
readonly data: Buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceFileWriteResult {
|
||||||
|
readonly path: string;
|
||||||
|
readonly device: number;
|
||||||
|
readonly inode: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface OpenDirectory {
|
interface OpenDirectory {
|
||||||
readonly handle: FileHandle;
|
readonly handle: FileHandle;
|
||||||
readonly displayPath: string;
|
readonly displayPath: string;
|
||||||
@@ -72,6 +78,21 @@ export async function writeNewWorkspaceFileNoFollow(
|
|||||||
requestedPath: string,
|
requestedPath: string,
|
||||||
source: Readable,
|
source: Readable,
|
||||||
): Promise<string> {
|
): 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 workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
||||||
const components = fileComponents(workspace, requestedPath);
|
const components = fileComponents(workspace, requestedPath);
|
||||||
const name = components.at(-1)!;
|
const name = components.at(-1)!;
|
||||||
@@ -81,7 +102,7 @@ export async function writeNewWorkspaceFileNoFollow(
|
|||||||
let file: FileHandle | undefined;
|
let file: FileHandle | undefined;
|
||||||
let device: number | undefined;
|
let device: number | undefined;
|
||||||
let inode: number | undefined;
|
let inode: number | undefined;
|
||||||
let result: string | undefined;
|
let result: WorkspaceFileWriteResult | undefined;
|
||||||
let failure: unknown;
|
let failure: unknown;
|
||||||
const cleanupFailures: unknown[] = [];
|
const cleanupFailures: unknown[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -104,7 +125,11 @@ export async function writeNewWorkspaceFileNoFollow(
|
|||||||
}
|
}
|
||||||
await file.sync();
|
await file.sync();
|
||||||
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
|
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) {
|
} catch (error) {
|
||||||
failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely");
|
failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely");
|
||||||
if (device !== undefined && inode !== undefined) {
|
if (device !== undefined && inode !== undefined) {
|
||||||
@@ -124,6 +149,103 @@ export async function writeNewWorkspaceFileNoFollow(
|
|||||||
return result!;
|
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> {
|
async function canonicalWorkspace(workspaceRoot: string, workspaceDir: string): Promise<string> {
|
||||||
if (process.platform !== "linux") {
|
if (process.platform !== "linux") {
|
||||||
throw new WorkspaceFileBoundaryError(
|
throw new WorkspaceFileBoundaryError(
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { join } from "node:path";
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { registerAdminPlugin } from "../../src/admin/plugin.js";
|
import { registerAdminPlugin } from "../../src/admin/plugin.js";
|
||||||
|
import {
|
||||||
|
archiveFolder,
|
||||||
|
archiveProject,
|
||||||
|
moveOrgProjectToFolder,
|
||||||
|
renameFolder,
|
||||||
|
renameProject,
|
||||||
|
} from "../../src/org/explorer.js";
|
||||||
import {
|
import {
|
||||||
mintSessionToken,
|
mintSessionToken,
|
||||||
sessionCookieHeader,
|
sessionCookieHeader,
|
||||||
@@ -221,4 +228,59 @@ describe("admin explorer API", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["SUSPENDED", "ARCHIVED"] as const)(
|
||||||
|
"blocks direct explorer mutations when the organization is %s",
|
||||||
|
async (status) => {
|
||||||
|
await Promise.all([
|
||||||
|
prisma.folder.create({ data: { id: `folder-rename-${status}`, organizationId: DEFAULT_ORG_ID, name: "Rename" } }),
|
||||||
|
prisma.folder.create({ data: { id: `folder-archive-${status}`, organizationId: DEFAULT_ORG_ID, name: "Archive" } }),
|
||||||
|
prisma.folder.create({ data: { id: `folder-target-${status}`, organizationId: DEFAULT_ORG_ID, name: "Target" } }),
|
||||||
|
prisma.project.create({
|
||||||
|
data: { id: `project-rename-${status}`, organizationId: DEFAULT_ORG_ID, name: "Rename", workspaceDir: `/tmp/rename-${status}` },
|
||||||
|
}),
|
||||||
|
prisma.project.create({
|
||||||
|
data: { id: `project-archive-${status}`, organizationId: DEFAULT_ORG_ID, name: "Archive", workspaceDir: `/tmp/archive-${status}` },
|
||||||
|
}),
|
||||||
|
prisma.project.create({
|
||||||
|
data: { id: `project-move-${status}`, organizationId: DEFAULT_ORG_ID, name: "Move", workspaceDir: `/tmp/move-${status}` },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
|
||||||
|
|
||||||
|
const expected = `organization ${DEFAULT_ORG_ID} is ${status}`;
|
||||||
|
await expect(renameFolder(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
folderId: `folder-rename-${status}`,
|
||||||
|
name: "Changed",
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
await expect(archiveFolder(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
folderId: `folder-archive-${status}`,
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
await expect(renameProject(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
projectId: `project-rename-${status}`,
|
||||||
|
name: "Changed",
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
await expect(archiveProject(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
projectId: `project-archive-${status}`,
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
await expect(moveOrgProjectToFolder(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
projectId: `project-move-${status}`,
|
||||||
|
folderId: `folder-target-${status}`,
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
|
||||||
|
await expect(prisma.folder.findUniqueOrThrow({ where: { id: `folder-rename-${status}` } })).resolves.toMatchObject({
|
||||||
|
name: "Rename",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await expect(prisma.project.findUniqueOrThrow({ where: { id: `project-rename-${status}` } })).resolves.toMatchObject({
|
||||||
|
name: "Rename",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
sessionCookieHeader,
|
sessionCookieHeader,
|
||||||
} from "../../src/admin/routes/authRoutes.js";
|
} from "../../src/admin/routes/authRoutes.js";
|
||||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||||
|
import { addOrgMember } from "../../src/org/members.js";
|
||||||
|
import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js";
|
||||||
|
|
||||||
const SESSION_SECRET = "integration-test-session-secret";
|
const SESSION_SECRET = "integration-test-session-secret";
|
||||||
|
|
||||||
@@ -158,4 +160,49 @@ describe("admin members + teams API", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks direct member and team mutations for a suspended organization", async () => {
|
||||||
|
await seedOwner();
|
||||||
|
await prisma.user.create({ data: { id: "u-direct-member", feishuOpenId: "ou_direct", displayName: "Direct" } });
|
||||||
|
await prisma.organizationMembership.create({
|
||||||
|
data: { organizationId: DEFAULT_ORG_ID, userId: "u-direct-member", role: "MEMBER" },
|
||||||
|
});
|
||||||
|
const team = await prisma.team.create({
|
||||||
|
data: { organizationId: DEFAULT_ORG_ID, slug: "existing", name: "Existing" },
|
||||||
|
});
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
|
||||||
|
const expected = `organization ${DEFAULT_ORG_ID} is SUSPENDED`;
|
||||||
|
|
||||||
|
await expect(addOrgMember(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
actorRole: "OWNER",
|
||||||
|
feishuOpenId: "ou_blocked",
|
||||||
|
role: "MEMBER",
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
await expect(createTeam(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
slug: "blocked",
|
||||||
|
name: "Blocked",
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
await expect(updateTeam(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
teamId: team.id,
|
||||||
|
name: "Changed",
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
await expect(addTeamMember(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
teamId: team.id,
|
||||||
|
userId: "u-direct-member",
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
await expect(archiveTeam(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
teamId: team.id,
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
|
||||||
|
await expect(prisma.user.findUnique({ where: { feishuOpenId: "ou_blocked" } })).resolves.toBeNull();
|
||||||
|
await expect(prisma.team.findUniqueOrThrow({ where: { id: team.id } })).resolves.toMatchObject({
|
||||||
|
name: "Existing",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PrismaPrincipalResolver,
|
PrismaPrincipalResolver,
|
||||||
syncExternalPrincipalMemberships,
|
syncExternalPrincipalMemberships,
|
||||||
} from "../../src/permission.js";
|
} from "../../src/permission.js";
|
||||||
|
import { lockActiveOrganization } from "../../src/org/status.js";
|
||||||
|
|
||||||
describe("ADR-0019 authorization", () => {
|
describe("ADR-0019 authorization", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -96,6 +97,75 @@ describe("ADR-0019 authorization", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["SUSPENDED", "ARCHIVED"] as const)(
|
||||||
|
"denies every project authorization when the organization is %s",
|
||||||
|
async (status) => {
|
||||||
|
await prisma.project.create({
|
||||||
|
data: { id: `p-org-${status}`, organizationId: DEFAULT_ORG_ID, name: status, workspaceDir: `/tmp/org-${status}` },
|
||||||
|
});
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: `u-org-${status}`,
|
||||||
|
feishuOpenId: `ou_org_${status}`,
|
||||||
|
displayName: status,
|
||||||
|
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.permissionGrant.create({
|
||||||
|
data: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: `p-org-${status}`,
|
||||||
|
principalType: "USER",
|
||||||
|
principalId: `ou_org_${status}`,
|
||||||
|
role: "MANAGE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
|
||||||
|
|
||||||
|
for (const action of ["project.read", "project.edit", "collaborator.manage", "agent.trigger", "agent.cancel"] as const) {
|
||||||
|
const decision = await createPermissionAuthorizer(prisma).can({
|
||||||
|
actor: { feishuOpenId: `ou_org_${status}` },
|
||||||
|
action,
|
||||||
|
resource: { type: "PROJECT", id: `p-org-${status}` },
|
||||||
|
});
|
||||||
|
expect(decision.allowed).toBe(false);
|
||||||
|
expect(decision.reason).toContain(`organization ${DEFAULT_ORG_ID} is ${status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("serializes a lifecycle transition behind an admitted customer transaction", async () => {
|
||||||
|
let releaseOperation!: () => void;
|
||||||
|
const operationHeld = new Promise<void>((resolve) => {
|
||||||
|
releaseOperation = resolve;
|
||||||
|
});
|
||||||
|
let markLocked!: () => void;
|
||||||
|
const rowLocked = new Promise<void>((resolve) => {
|
||||||
|
markLocked = resolve;
|
||||||
|
});
|
||||||
|
const operation = prisma.$transaction(async (tx) => {
|
||||||
|
await lockActiveOrganization(tx, DEFAULT_ORG_ID);
|
||||||
|
markLocked();
|
||||||
|
await operationHeld;
|
||||||
|
});
|
||||||
|
await rowLocked;
|
||||||
|
|
||||||
|
let transitionCommitted = false;
|
||||||
|
const transition = prisma.organization.update({
|
||||||
|
where: { id: DEFAULT_ORG_ID },
|
||||||
|
data: { status: "SUSPENDED" },
|
||||||
|
}).then(() => {
|
||||||
|
transitionCommitted = true;
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
expect(transitionCommitted).toBe(false);
|
||||||
|
|
||||||
|
releaseOperation();
|
||||||
|
await operation;
|
||||||
|
await transition;
|
||||||
|
expect(transitionCommitted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses external Feishu principal memberships as grantable principals", async () => {
|
it("uses external Feishu principal memberships as grantable principals", async () => {
|
||||||
await prisma.project.create({
|
await prisma.project.create({
|
||||||
data: { id: "p-auth-ext", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" },
|
data: { id: "p-auth-ext", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" },
|
||||||
@@ -177,6 +247,36 @@ describe("ADR-0019 authorization", () => {
|
|||||||
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
|
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks directory sync and team grants for an archived organization", async () => {
|
||||||
|
const team = await prisma.team.create({
|
||||||
|
data: { organizationId: DEFAULT_ORG_ID, slug: "archived-team", name: "Archived Team" },
|
||||||
|
});
|
||||||
|
await prisma.project.create({
|
||||||
|
data: { id: "p-archived-grant", organizationId: DEFAULT_ORG_ID, name: "Archived", workspaceDir: "/tmp/archived" },
|
||||||
|
});
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "ARCHIVED" } });
|
||||||
|
const expected = `organization ${DEFAULT_ORG_ID} is ARCHIVED`;
|
||||||
|
|
||||||
|
await expect(syncExternalPrincipalMemberships(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
source: "blocked-sync",
|
||||||
|
memberships: [{
|
||||||
|
feishuOpenId: "ou_blocked_sync",
|
||||||
|
displayName: "Blocked",
|
||||||
|
principalType: "FEISHU_DEPARTMENT",
|
||||||
|
principalId: "dep-blocked",
|
||||||
|
}],
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
await expect(grantTeamProjectAccess(prisma, {
|
||||||
|
projectId: "p-archived-grant",
|
||||||
|
teamId: team.id,
|
||||||
|
role: "EDIT",
|
||||||
|
})).rejects.toThrow(expected);
|
||||||
|
|
||||||
|
await expect(prisma.externalDirectoryConnection.count()).resolves.toBe(0);
|
||||||
|
await expect(prisma.permissionGrant.count({ where: { resourceId: "p-archived-grant" } })).resolves.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
|
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
|
||||||
await prisma.project.create({
|
await prisma.project.create({
|
||||||
data: { id: "p-auth-policy", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" },
|
data: { id: "p-auth-policy", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
import { chmod, mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
|
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
|
||||||
import {
|
import {
|
||||||
archiveFeishuChatBinding,
|
archiveFeishuChatBinding,
|
||||||
@@ -9,17 +9,21 @@ import {
|
|||||||
createFolder,
|
createFolder,
|
||||||
createProjectFromFeishuChat,
|
createProjectFromFeishuChat,
|
||||||
createProjectFromOrgAdmin,
|
createProjectFromOrgAdmin,
|
||||||
|
moveProjectToFolder,
|
||||||
setMembersCanCreateProjects,
|
setMembersCanCreateProjects,
|
||||||
} from "../../src/projectOnboarding.js";
|
} from "../../src/projectOnboarding.js";
|
||||||
|
|
||||||
const workspaceRoots: string[] = [];
|
const workspaceRoots: string[] = [];
|
||||||
|
const itAsNonRoot = typeof process.getuid === "function" && process.getuid() === 0 ? it.skip : it;
|
||||||
|
|
||||||
describe("ADR-0021 project onboarding", () => {
|
describe("ADR-0021 project onboarding", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
await dropPermissionSettingsFailureTrigger();
|
||||||
await resetDb();
|
await resetDb();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
await dropPermissionSettingsFailureTrigger();
|
||||||
while (workspaceRoots.length > 0) {
|
while (workspaceRoots.length > 0) {
|
||||||
const root = workspaceRoots.pop();
|
const root = workspaceRoots.pop();
|
||||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||||
@@ -27,6 +31,7 @@ describe("ADR-0021 project onboarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await dropPermissionSettingsFailureTrigger();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,6 +116,98 @@ describe("ADR-0021 project onboarding", () => {
|
|||||||
})).rejects.toThrow(/members cannot create projects/);
|
})).rejects.toThrow(/members cannot create projects/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["SUSPENDED", "ARCHIVED"] as const)(
|
||||||
|
"blocks project creation and organization mutations when the org is %s",
|
||||||
|
async (status) => {
|
||||||
|
await seedUser(`u-${status}`, `ou_${status}`, "OWNER");
|
||||||
|
const folder = await createFolder(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
name: `Before ${status}`,
|
||||||
|
});
|
||||||
|
const workspaceRoot = await tempWorkspaceRoot();
|
||||||
|
const project = await createProjectFromOrgAdmin(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
actorFeishuOpenId: `ou_${status}`,
|
||||||
|
name: `Existing ${status}`,
|
||||||
|
workspaceRoot,
|
||||||
|
});
|
||||||
|
const organizationWorkspace = dirname(project.workspaceDir);
|
||||||
|
const workspacesBefore = (await readdir(organizationWorkspace)).sort();
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
|
||||||
|
|
||||||
|
await expect(createProjectFromOrgAdmin(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
actorFeishuOpenId: `ou_${status}`,
|
||||||
|
name: "Blocked",
|
||||||
|
workspaceRoot,
|
||||||
|
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
|
||||||
|
await expect(readdir(organizationWorkspace)).resolves.toEqual(workspacesBefore);
|
||||||
|
await expect(bindFeishuChatToProject(prisma, {
|
||||||
|
projectId: project.projectId,
|
||||||
|
actorFeishuOpenId: `ou_${status}`,
|
||||||
|
chatId: `chat-${status}`,
|
||||||
|
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
|
||||||
|
await expect(createFolder(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
name: "Blocked folder",
|
||||||
|
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
|
||||||
|
await expect(setMembersCanCreateProjects(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
enabled: false,
|
||||||
|
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
|
||||||
|
await expect(moveProjectToFolder(prisma, {
|
||||||
|
projectId: project.projectId,
|
||||||
|
folderId: folder.id,
|
||||||
|
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("removes a newly created workspace when the database transaction later fails", async () => {
|
||||||
|
await seedUser("u-compensate", "ou_compensate", "ADMIN");
|
||||||
|
await installPermissionSettingsFailureTrigger(0);
|
||||||
|
const workspaceRoot = await tempWorkspaceRoot();
|
||||||
|
|
||||||
|
await expect(createProjectFromOrgAdmin(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
actorFeishuOpenId: "ou_compensate",
|
||||||
|
name: "Must Roll Back",
|
||||||
|
workspaceRoot,
|
||||||
|
})).rejects.toThrow(/forced permission settings failure/);
|
||||||
|
|
||||||
|
await expect(readdir(join(workspaceRoot, "test-default"))).resolves.toEqual([]);
|
||||||
|
await expect(prisma.project.count()).resolves.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
itAsNonRoot("surfaces both the transaction and workspace cleanup failures", async () => {
|
||||||
|
await seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN");
|
||||||
|
await installPermissionSettingsFailureTrigger(1);
|
||||||
|
const workspaceRoot = await tempWorkspaceRoot();
|
||||||
|
const organizationWorkspace = join(workspaceRoot, "test-default");
|
||||||
|
const pending = createProjectFromOrgAdmin(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
actorFeishuOpenId: "ou_cleanup_failure",
|
||||||
|
name: "Cleanup Must Fail Loudly",
|
||||||
|
workspaceRoot,
|
||||||
|
});
|
||||||
|
const outcome = pending.then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
expect(await readdir(organizationWorkspace)).toHaveLength(1);
|
||||||
|
}, { timeout: 2_000 });
|
||||||
|
await chmod(organizationWorkspace, 0o500);
|
||||||
|
try {
|
||||||
|
const error = await outcome;
|
||||||
|
expect(error).toBeInstanceOf(AggregateError);
|
||||||
|
expect((error as AggregateError).errors).toHaveLength(2);
|
||||||
|
await expect(prisma.project.count()).resolves.toBe(0);
|
||||||
|
} finally {
|
||||||
|
await chmod(organizationWorkspace, 0o700);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("binds an existing project once, archives the binding, then allows a new active binding", async () => {
|
it("binds an existing project once, archives the binding, then allows a new active binding", async () => {
|
||||||
await seedUser("u-owner", "ou_owner", "OWNER");
|
await seedUser("u-owner", "ou_owner", "OWNER");
|
||||||
const workspaceRoot = await tempWorkspaceRoot();
|
const workspaceRoot = await tempWorkspaceRoot();
|
||||||
@@ -176,3 +273,28 @@ async function tempWorkspaceRoot(): Promise<string> {
|
|||||||
workspaceRoots.push(root);
|
workspaceRoots.push(root);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function installPermissionSettingsFailureTrigger(delaySeconds: number): Promise<void> {
|
||||||
|
await dropPermissionSettingsFailureTrigger();
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE FUNCTION cph_test_fail_permission_settings() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_sleep(${delaySeconds});
|
||||||
|
RAISE EXCEPTION 'forced permission settings failure';
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TRIGGER cph_test_fail_permission_settings
|
||||||
|
BEFORE INSERT ON "PermissionSettings"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION cph_test_fail_permission_settings()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropPermissionSettingsFailureTrigger(): Promise<void> {
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
'DROP TRIGGER IF EXISTS cph_test_fail_permission_settings ON "PermissionSettings"',
|
||||||
|
);
|
||||||
|
await prisma.$executeRawUnsafe('DROP FUNCTION IF EXISTS cph_test_fail_permission_settings()');
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,4 +68,16 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
|||||||
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
|
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
|
||||||
expect(onP2.allowed).toBe(false);
|
expect(onP2.allowed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("denies an otherwise open role when the organization is suspended", async () => {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: "p-role-suspended", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
|
||||||
|
|
||||||
|
const result = await canTriggerRole(prisma, project.id, "draft", "ou_a");
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain(`organization ${DEFAULT_ORG_ID} is SUSPENDED`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||||
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
||||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||||
|
import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js";
|
||||||
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||||
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
||||||
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
|
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
|
||||||
@@ -164,7 +165,9 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
itOnLinux("includes downloaded post image paths in the agent prompt", async () => {
|
itOnLinux("includes downloaded post image paths in the agent prompt", async () => {
|
||||||
const workspaceDir = await tempWorkspaceRoot();
|
const workspaceRoot = await tempWorkspaceRoot();
|
||||||
|
const workspaceDir = join(workspaceRoot, "project");
|
||||||
|
await mkdir(workspaceDir);
|
||||||
await seedProject("proj-post-image", "chat-post-image");
|
await seedProject("proj-post-image", "chat-post-image");
|
||||||
await prisma.project.update({
|
await prisma.project.update({
|
||||||
where: { id: "proj-post-image" },
|
where: { id: "proj-post-image" },
|
||||||
@@ -198,7 +201,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
settings,
|
settings,
|
||||||
logger: silentLogger,
|
logger: silentLogger,
|
||||||
runAgent,
|
runAgent,
|
||||||
projectWorkspaceRoot: dirname(workspaceDir),
|
projectWorkspaceRoot: workspaceRoot,
|
||||||
messageBatcherOptions: { maxMessages: 1 },
|
messageBatcherOptions: { maxMessages: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,6 +215,10 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
params: { type: "image" },
|
params: { type: "image" },
|
||||||
path: { message_id: event.message.message_id, file_key: "img-key-1" },
|
path: { message_id: event.message.message_id, file_key: "img-key-1" },
|
||||||
});
|
});
|
||||||
|
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
|
||||||
|
expect(inboxFiles).toHaveLength(1);
|
||||||
|
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
|
||||||
|
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends an onboarding card when an unbound chat mentions the bot", async () => {
|
it("sends an onboarding card when an unbound chat mentions the bot", async () => {
|
||||||
@@ -612,6 +619,115 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
expect(runs).toHaveLength(0);
|
expect(runs).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["SUSPENDED", "ARCHIVED"] as const)(
|
||||||
|
"rejects triggers and resume commands when the organization is %s",
|
||||||
|
async (status) => {
|
||||||
|
await seedProject(`proj-org-${status}`, `chat-org-${status}`);
|
||||||
|
const session = await prisma.agentSession.create({
|
||||||
|
data: {
|
||||||
|
projectId: `proj-org-${status}`,
|
||||||
|
provider: "openrouter",
|
||||||
|
roleId: "draft",
|
||||||
|
model: "mock-model",
|
||||||
|
metadata: {},
|
||||||
|
archivedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
|
||||||
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||||
|
|
||||||
|
await trigger(makeEvent(`chat-org-${status}`, "@_user_1 /resume"), rt);
|
||||||
|
|
||||||
|
expect(rt.sentTexts).toContain("无权限触发。");
|
||||||
|
expect(runAgentCalls).toHaveLength(0);
|
||||||
|
expect(await prisma.agentRun.count()).toBe(0);
|
||||||
|
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
|
||||||
|
archivedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["SUSPENDED", "ARCHIVED"] as const)(
|
||||||
|
"rejects direct session mutation when the organization is %s",
|
||||||
|
async (status) => {
|
||||||
|
await seedProject(`proj-direct-${status}`, `chat-direct-${status}`);
|
||||||
|
const session = await prisma.agentSession.create({
|
||||||
|
data: {
|
||||||
|
projectId: `proj-direct-${status}`,
|
||||||
|
provider: "openrouter",
|
||||||
|
roleId: "draft",
|
||||||
|
model: "mock-model",
|
||||||
|
metadata: {},
|
||||||
|
archivedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
|
||||||
|
const commands = createSlashCommandRegistry({
|
||||||
|
prisma,
|
||||||
|
settings,
|
||||||
|
logger: silentLogger,
|
||||||
|
triggerQueue: new TriggerQueue(),
|
||||||
|
});
|
||||||
|
const resume = commands.get("resume");
|
||||||
|
expect(resume).toBeDefined();
|
||||||
|
|
||||||
|
await expect(resume!.run({
|
||||||
|
invocation: { name: "resume", args: [] },
|
||||||
|
projectId: `proj-direct-${status}`,
|
||||||
|
chatId: `chat-direct-${status}`,
|
||||||
|
rt,
|
||||||
|
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
|
||||||
|
|
||||||
|
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
|
||||||
|
archivedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("reports a lifecycle race that rejects a slash-command mutation", async () => {
|
||||||
|
await seedProject("proj-slash-race", "chat-slash-race");
|
||||||
|
const session = await prisma.agentSession.create({
|
||||||
|
data: {
|
||||||
|
projectId: "proj-slash-race",
|
||||||
|
provider: "openrouter",
|
||||||
|
roleId: "draft",
|
||||||
|
model: "mock-model",
|
||||||
|
metadata: {},
|
||||||
|
archivedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
|
||||||
|
const trigger = makeTriggerHandler({
|
||||||
|
prisma,
|
||||||
|
settings,
|
||||||
|
logger: silentLogger,
|
||||||
|
runAgent,
|
||||||
|
messageBatcherOptions: { maxMessages: 1 },
|
||||||
|
authorizer: {
|
||||||
|
async can(request) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
reason: "authorized before concurrent suspension",
|
||||||
|
action: request.action,
|
||||||
|
resource: request.resource,
|
||||||
|
actor: request.actor,
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
principals: [{ type: "USER", id: "ou_test_user" }],
|
||||||
|
requiredRole: "EDIT",
|
||||||
|
effectiveRole: "EDIT",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await trigger(makeEvent("chat-slash-race", "@_user_1 /resume"), rt);
|
||||||
|
|
||||||
|
expect(rt.sentTexts).toContain("组织当前不可用,拒绝操作。");
|
||||||
|
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
|
||||||
|
archivedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("queues a text trigger when project is already locked (ADR-0002)", async () => {
|
it("queues a text trigger when project is already locked (ADR-0002)", async () => {
|
||||||
await seedProject("proj-3", "chat-3");
|
await seedProject("proj-3", "chat-3");
|
||||||
// Manually create a lock by inserting a run + lock.
|
// Manually create a lock by inserting a run + lock.
|
||||||
@@ -678,6 +794,127 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reauthorizes a queued trigger and drops it after the organization is suspended", async () => {
|
||||||
|
await seedProject("proj-queue-suspended", "chat-queue-suspended");
|
||||||
|
const firstRun = deferred<RunResult>();
|
||||||
|
const queuedRunAgent: TestRunner = async (req) => {
|
||||||
|
runAgentCalls.push(req);
|
||||||
|
return firstRun.promise;
|
||||||
|
};
|
||||||
|
const trigger = makeTriggerHandler({
|
||||||
|
prisma,
|
||||||
|
settings,
|
||||||
|
logger: silentLogger,
|
||||||
|
runAgent: queuedRunAgent,
|
||||||
|
messageBatcherOptions: { maxMessages: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await trigger(makeEvent("chat-queue-suspended", "@_user_1 第一个请求"), rt);
|
||||||
|
await vi.waitFor(() => expect(runAgentCalls).toHaveLength(1));
|
||||||
|
await trigger(makeEvent("chat-queue-suspended", "@_user_1 第二个请求"), rt);
|
||||||
|
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
|
||||||
|
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
|
||||||
|
firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(rt.sentTexts).toContain("无权限使用角色 draft。"));
|
||||||
|
expect(runAgentCalls).toHaveLength(1);
|
||||||
|
await expect(prisma.agentRun.count()).resolves.toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rechecks ACTIVE atomically when suspension lands after the first authorization", async () => {
|
||||||
|
await seedProject("proj-admission-race", "chat-admission-race");
|
||||||
|
const providerEntered = deferred<void>();
|
||||||
|
const releaseProvider = deferred<void>();
|
||||||
|
const baseProvider = settings.provider.bind(settings);
|
||||||
|
const pausedSettings: RuntimeSettings = {
|
||||||
|
...settings,
|
||||||
|
async provider(providerId, context) {
|
||||||
|
providerEntered.resolve();
|
||||||
|
await releaseProvider.promise;
|
||||||
|
return baseProvider(providerId, context);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const trigger = makeTriggerHandler({
|
||||||
|
prisma,
|
||||||
|
settings: pausedSettings,
|
||||||
|
logger: silentLogger,
|
||||||
|
runAgent,
|
||||||
|
messageBatcherOptions: { maxMessages: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingTrigger = trigger(makeEvent("chat-admission-race", "@_user_1 竞态请求"), rt);
|
||||||
|
await providerEntered.promise;
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
|
||||||
|
releaseProvider.resolve();
|
||||||
|
await pendingTrigger;
|
||||||
|
|
||||||
|
expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。");
|
||||||
|
expect(runAgentCalls).toHaveLength(0);
|
||||||
|
await expect(prisma.agentSession.count()).resolves.toBe(0);
|
||||||
|
await expect(prisma.agentRun.count()).resolves.toBe(0);
|
||||||
|
await expect(prisma.projectAgentLock.count()).resolves.toBe(0);
|
||||||
|
await expect(prisma.auditEntry.count({ where: { action: "run.created" } })).resolves.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
itOnLinux("does not publish a staged attachment when suspension wins admission", async () => {
|
||||||
|
const workspaceRoot = await tempWorkspaceRoot();
|
||||||
|
const workspaceDir = join(workspaceRoot, "project");
|
||||||
|
await mkdir(workspaceDir);
|
||||||
|
await seedProject("proj-attachment-race", "chat-attachment-race");
|
||||||
|
await prisma.project.update({
|
||||||
|
where: { id: "proj-attachment-race" },
|
||||||
|
data: { workspaceDir },
|
||||||
|
});
|
||||||
|
const resourceEntered = deferred<void>();
|
||||||
|
const releaseResource = deferred<void>();
|
||||||
|
const messageResourceGet = vi.fn(async () => {
|
||||||
|
resourceEntered.resolve();
|
||||||
|
await releaseResource.promise;
|
||||||
|
return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
|
||||||
|
});
|
||||||
|
const imV1 = (rt.client as unknown as {
|
||||||
|
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
|
||||||
|
}).im.v1;
|
||||||
|
imV1.messageResource = { get: messageResourceGet };
|
||||||
|
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
|
||||||
|
const event: MessageReceiveEvent = {
|
||||||
|
...baseEvent,
|
||||||
|
message: {
|
||||||
|
...baseEvent.message,
|
||||||
|
message_type: "post",
|
||||||
|
content: JSON.stringify({
|
||||||
|
zh_cn: {
|
||||||
|
content: [[
|
||||||
|
{ tag: "text", text: "@_user_1 附件竞态" },
|
||||||
|
{ tag: "img", image_key: "img-race" },
|
||||||
|
]],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const trigger = makeTriggerHandler({
|
||||||
|
prisma,
|
||||||
|
settings,
|
||||||
|
logger: silentLogger,
|
||||||
|
runAgent,
|
||||||
|
projectWorkspaceRoot: workspaceRoot,
|
||||||
|
messageBatcherOptions: { maxMessages: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingTrigger = trigger(event, rt);
|
||||||
|
await resourceEntered.promise;
|
||||||
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
|
||||||
|
releaseResource.resolve();
|
||||||
|
await pendingTrigger;
|
||||||
|
|
||||||
|
expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。");
|
||||||
|
expect(runAgentCalls).toHaveLength(0);
|
||||||
|
await expect(prisma.agentRun.count()).resolves.toBe(0);
|
||||||
|
await expect(readdir(join(workspaceDir, ".cph", "inbox"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not create a run for unbound chats and asks unknown users to log in", async () => {
|
it("does not create a run for unbound chats and asks unknown users to log in", async () => {
|
||||||
await seedProject("proj-4", "chat-4");
|
await seedProject("proj-4", "chat-4");
|
||||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ function mockPrisma(): PrismaClient {
|
|||||||
let lock: { projectId: string; runId: string } | null = null;
|
let lock: { projectId: string; runId: string } | null = null;
|
||||||
const session = { id: "session-1", metadata: {} };
|
const session = { id: "session-1", metadata: {} };
|
||||||
|
|
||||||
return {
|
const client = {
|
||||||
feishuEventReceipt: {
|
feishuEventReceipt: {
|
||||||
findUnique: vi.fn(async () => null),
|
findUnique: vi.fn(async () => null),
|
||||||
create: vi.fn(async () => ({ id: "receipt-1" })),
|
create: vi.fn(async () => ({ id: "receipt-1" })),
|
||||||
@@ -286,6 +286,11 @@ function mockPrisma(): PrismaClient {
|
|||||||
create: vi.fn(async () => ({ id: "audit-1" })),
|
create: vi.fn(async () => ({ id: "audit-1" })),
|
||||||
},
|
},
|
||||||
} as unknown as PrismaClient;
|
} as unknown as PrismaClient;
|
||||||
|
Object.assign(client, {
|
||||||
|
$transaction: vi.fn(async (callback: (tx: PrismaClient) => Promise<unknown>) => callback(client)),
|
||||||
|
$queryRaw: vi.fn(async () => [{ id: "org_test_default", status: "ACTIVE" }]),
|
||||||
|
});
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
function silentLogger(): FeishuRuntime["logger"] {
|
function silentLogger(): FeishuRuntime["logger"] {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { removeAbandonedMessageResourceStages } from "../../src/feishu/resourceStaging.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
describe("Feishu resource staging recovery", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes every abandoned batch during startup recovery", async () => {
|
||||||
|
const root = await tempRoot();
|
||||||
|
const batch = join(root, ".cph-staging", "abandoned-batch");
|
||||||
|
await mkdir(batch, { recursive: true });
|
||||||
|
await writeFile(join(batch, "resource-0"), "sensitive attachment");
|
||||||
|
|
||||||
|
await expect(removeAbandonedMessageResourceStages(root)).resolves.toBe(1);
|
||||||
|
await expect(readdir(join(root, ".cph-staging"))).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to traverse a staging-base symlink", async () => {
|
||||||
|
const root = await tempRoot();
|
||||||
|
const outside = await tempRoot();
|
||||||
|
await symlink(outside, join(root, ".cph-staging"));
|
||||||
|
|
||||||
|
await expect(removeAbandonedMessageResourceStages(root)).rejects.toThrow(
|
||||||
|
/staging base is not a real directory/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function tempRoot(): Promise<string> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "cph-resource-staging-test-"));
|
||||||
|
roots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user