forked from bai/curriculum-project-hub
feat: add org admin project onboarding foundation
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
|
||||
`Spec.System.ProjectWorkspace`)。
|
||||
|
||||
## 纪律
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
|
||||
`Spec.System.ProjectWorkspace`)。
|
||||
|
||||
## 纪律
|
||||
|
||||
|
||||
@@ -26,4 +26,6 @@ The group can stay open while no Claude processing is active. A teacher leaving
|
||||
- Multi-open is natural: a teacher participates in multiple project groups.
|
||||
- Normal group discussion does not occupy the project.
|
||||
- "Exit project" should not be the normal action in the group workflow.
|
||||
- Project rebinding or group archival needs explicit product rules, separate from run cancellation.
|
||||
- ADR-0021 adds those explicit product rules: active project↔Feishu chat binding
|
||||
is strict 1:1, while archived historical bindings are retained so org admins
|
||||
can correct pilot setup mistakes without losing audit/session history.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR 0021: Org Admin Project Onboarding
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0020 introduced `Organization` as the SaaS tenant root. The next product
|
||||
surface is not only the Feishu bot trigger path, but also the control planes
|
||||
around it:
|
||||
|
||||
- platform staff need a private platform admin area to create and operate orgs;
|
||||
- customer org owners/admins need an org admin area for teams, roles, model
|
||||
provider configuration, projects, folders, sessions, and usage accounting;
|
||||
- ordinary teachers should be able to start work from the natural Feishu group
|
||||
flow without entering the web backend.
|
||||
|
||||
Without a crisp project onboarding model, project creation, Feishu chat binding,
|
||||
permissions, session history, and usage accounting would drift into separate
|
||||
ad-hoc rules.
|
||||
|
||||
## Decision
|
||||
|
||||
Use one web app with two separate admin areas:
|
||||
|
||||
```text
|
||||
/admin/platform internal platform admins only
|
||||
/admin/org/:orgSlug customer org OWNER/ADMIN only
|
||||
```
|
||||
|
||||
The guards are intentionally separate:
|
||||
|
||||
- `requirePlatformAdmin` for internal operators;
|
||||
- `requireOrgRole` for org owner/admin backend access;
|
||||
- `requireProjectPermission` for project-level actions.
|
||||
|
||||
Platform admin is not an `OrganizationMembership` and is not represented by
|
||||
project `PermissionGrant`. Platform admin audit remains separate from customer
|
||||
project audit.
|
||||
|
||||
Customer orgs are manually created by platform staff for the pilot. Platform
|
||||
admins invite/allowlist other platform admins. Customer users authenticate with
|
||||
the customer's Feishu app. The same customer-owned Feishu app may be used for
|
||||
OAuth login, bot messages, and directory sync. App secrets are org-scoped
|
||||
secrets and must be encrypted at rest; business code should access them through
|
||||
a secret/connection resolver, not raw plaintext columns.
|
||||
|
||||
Project management has two creation paths:
|
||||
|
||||
- org owner/admin creates projects in the org web backend;
|
||||
- ordinary org members may create a project from an unbound Feishu group when
|
||||
`membersCanCreateProjects` is enabled for the org.
|
||||
|
||||
In both paths the creator gets project `MANAGE`. A Feishu-created project is
|
||||
immediately bound to the source chat, and that chat receives project `EDIT`.
|
||||
|
||||
Feishu chat binding is strict 1:1:
|
||||
|
||||
- one Feishu chat binds to at most one active project;
|
||||
- one project binds to at most one Feishu chat;
|
||||
- binding an existing unbound project from Feishu requires the clicking user to
|
||||
have `MANAGE` on that project;
|
||||
- binding creates an active `FEISHU_CHAT -> PROJECT EDIT` grant;
|
||||
- binding mistakes are corrected by org owner/admin unbinding or archiving the
|
||||
binding in the backend; historical sessions stay on their original project.
|
||||
|
||||
Folders are transparent organization nodes for project navigation and usage
|
||||
aggregation:
|
||||
|
||||
- folders belong to one organization;
|
||||
- projects may sit in folders;
|
||||
- folders can be nested;
|
||||
- folders are not permission resources;
|
||||
- folder visibility, team policies, and inherited grants are deferred.
|
||||
|
||||
Project permissions remain on `PROJECT` resources. Moving a project between
|
||||
folders does not change grants.
|
||||
|
||||
Usage accounting is org-wise and project/folder aggregatable. It is not payment
|
||||
collection in the pilot because customers supply their own model provider API
|
||||
keys and base URLs.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `OrganizationProjectSettings.membersCanCreateProjects` gates Feishu group
|
||||
project creation for ordinary members.
|
||||
- `Folder` and `Project.folderId` support the file-manager-like project
|
||||
explorer without creating a second ACL system.
|
||||
- Service code should expose project creation and chat binding as reusable
|
||||
backend operations so Feishu cards and future web APIs call the same rules.
|
||||
- Org role/model/provider/billing panels can be added on top of the org tenant
|
||||
root without changing project authorization.
|
||||
|
||||
## Open Questions / Deferred
|
||||
|
||||
- True one-click Feishu app provisioning is deferred; pilot uses guided setup
|
||||
and readiness checks.
|
||||
- Folder-level permissions are deferred until there is a concrete customer need.
|
||||
- Self-serve org signup and payment collection are deferred beyond pilot.
|
||||
- The exact platform admin identity store and audit schema are separate from
|
||||
this ADR and should be modeled before exposing the platform admin panel.
|
||||
@@ -0,0 +1,92 @@
|
||||
-- ADR-0021: org admin project onboarding.
|
||||
-- Folder is a transparent project explorer node; project remains the permission boundary.
|
||||
|
||||
CREATE TABLE "OrganizationProjectSettings" (
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"membersCanCreateProjects" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OrganizationProjectSettings_pkey" PRIMARY KEY ("organizationId")
|
||||
);
|
||||
|
||||
ALTER TABLE "OrganizationProjectSettings"
|
||||
ADD CONSTRAINT "OrganizationProjectSettings_organizationId_fkey"
|
||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "OrganizationProjectSettings" ("organizationId", "membersCanCreateProjects", "createdAt", "updatedAt")
|
||||
SELECT "id", true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM "Organization"
|
||||
ON CONFLICT ("organizationId") DO NOTHING;
|
||||
|
||||
CREATE TABLE "Folder" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"sortKey" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "Folder"
|
||||
ADD CONSTRAINT "Folder_organizationId_fkey"
|
||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Folder"
|
||||
ADD CONSTRAINT "Folder_parentId_fkey"
|
||||
FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "Folder_organizationId_parentId_archivedAt_idx"
|
||||
ON "Folder"("organizationId", "parentId", "archivedAt");
|
||||
|
||||
CREATE INDEX "Folder_organizationId_parentId_sortKey_idx"
|
||||
ON "Folder"("organizationId", "parentId", "sortKey");
|
||||
|
||||
CREATE UNIQUE INDEX "Folder_active_sibling_name_key"
|
||||
ON "Folder"("organizationId", COALESCE("parentId", ''), lower("name"))
|
||||
WHERE "archivedAt" IS NULL;
|
||||
|
||||
INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "sortKey", "createdAt", "updatedAt")
|
||||
SELECT 'folder_inbox_' || md5("id"), "id", NULL, 'Inbox', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM "Organization";
|
||||
|
||||
ALTER TABLE "Project" ADD COLUMN "folderId" TEXT;
|
||||
|
||||
UPDATE "Project" AS p
|
||||
SET "folderId" = f."id"
|
||||
FROM "Folder" AS f
|
||||
WHERE f."organizationId" = p."organizationId"
|
||||
AND f."parentId" IS NULL
|
||||
AND f."name" = 'Inbox'
|
||||
AND p."folderId" IS NULL;
|
||||
|
||||
ALTER TABLE "Project"
|
||||
ADD CONSTRAINT "Project_folderId_fkey"
|
||||
FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "Project_folderId_archivedAt_idx"
|
||||
ON "Project"("folderId", "archivedAt");
|
||||
|
||||
ALTER TABLE "ProjectGroupBinding" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||
|
||||
DROP INDEX IF EXISTS "ProjectGroupBinding_projectId_key";
|
||||
DROP INDEX IF EXISTS "ProjectGroupBinding_chatId_key";
|
||||
DROP INDEX IF EXISTS "ProjectGroupBinding_chatId_idx";
|
||||
|
||||
CREATE INDEX "ProjectGroupBinding_projectId_archivedAt_idx"
|
||||
ON "ProjectGroupBinding"("projectId", "archivedAt");
|
||||
|
||||
CREATE INDEX "ProjectGroupBinding_chatId_archivedAt_idx"
|
||||
ON "ProjectGroupBinding"("chatId", "archivedAt");
|
||||
|
||||
CREATE UNIQUE INDEX "ProjectGroupBinding_active_project_key"
|
||||
ON "ProjectGroupBinding"("projectId")
|
||||
WHERE "archivedAt" IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "ProjectGroupBinding_active_chat_key"
|
||||
ON "ProjectGroupBinding"("chatId")
|
||||
WHERE "archivedAt" IS NULL;
|
||||
+55
-17
@@ -36,6 +36,8 @@ model Organization {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships OrganizationMembership[]
|
||||
projectSettings OrganizationProjectSettings?
|
||||
folders Folder[]
|
||||
projects Project[]
|
||||
teams Team[]
|
||||
externalDirectoryConnections ExternalDirectoryConnection[]
|
||||
@@ -73,6 +75,17 @@ enum OrganizationMemberRole {
|
||||
MEMBER
|
||||
}
|
||||
|
||||
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
|
||||
/// create projects from unbound chats only when membersCanCreateProjects=true.
|
||||
model OrganizationProjectSettings {
|
||||
organizationId String @id
|
||||
membersCanCreateProjects Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
@@ -222,9 +235,32 @@ model ExternalPrincipalMembership {
|
||||
|
||||
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
||||
|
||||
/// ADR-0021: transparent project explorer folder. Folders are org-scoped
|
||||
/// navigation/aggregation nodes, not permission resources; project grants stay
|
||||
/// attached to PROJECT resources.
|
||||
model Folder {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
parentId String?
|
||||
name String
|
||||
sortKey String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
parent Folder? @relation("folderTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children Folder[] @relation("folderTree")
|
||||
projects Project[]
|
||||
|
||||
@@index([organizationId, parentId, archivedAt])
|
||||
@@index([organizationId, parentId, sortKey])
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
folderId String?
|
||||
name String
|
||||
workspaceDir String
|
||||
createdByUserId String?
|
||||
@@ -232,37 +268,39 @@ model Project {
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
groupBinding ProjectGroupBinding?
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
groupBindings ProjectGroupBinding[]
|
||||
agentSessions AgentSession[]
|
||||
agentRuns AgentRun[]
|
||||
agentLock ProjectAgentLock?
|
||||
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||
auditEntries AuditEntry[] @relation("projectAudit")
|
||||
fileChanges AgentFileChange[] @relation("projectFileChanges")
|
||||
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||
auditEntries AuditEntry[] @relation("projectAudit")
|
||||
fileChanges AgentFileChange[] @relation("projectFileChanges")
|
||||
|
||||
@@index([organizationId, archivedAt])
|
||||
@@index([folderId, archivedAt])
|
||||
@@index([archivedAt])
|
||||
}
|
||||
|
||||
/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒
|
||||
/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat).
|
||||
/// The "at most one binding per project" half is enforced by the 1:1 relation.
|
||||
/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled
|
||||
/// here; archival is a future policy, not a current column.
|
||||
/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
|
||||
/// (1:1). Historical archived bindings are retained for audit; partial unique
|
||||
/// indexes in migrations enforce one active binding per project and per chat.
|
||||
model ProjectGroupBinding {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique
|
||||
chatId String @unique
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
chatId String
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([chatId])
|
||||
@@index([projectId, archivedAt])
|
||||
@@index([chatId, archivedAt])
|
||||
}
|
||||
|
||||
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
|
||||
|
||||
@@ -535,8 +535,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
deps.logger.warn({ runId }, "feishu interrupt: card action missing chat id");
|
||||
return;
|
||||
}
|
||||
const binding = await deps.prisma.projectGroupBinding.findUnique({
|
||||
where: { chatId },
|
||||
const binding = await deps.prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId, archivedAt: null },
|
||||
select: { projectId: true },
|
||||
});
|
||||
if (binding === null) {
|
||||
@@ -618,8 +618,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
if (chatId === "") return;
|
||||
|
||||
// ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
|
||||
const binding = await deps.prisma.projectGroupBinding.findUnique({
|
||||
where: { chatId },
|
||||
const binding = await deps.prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId, archivedAt: null },
|
||||
select: { projectId: true },
|
||||
});
|
||||
if (binding === null) {
|
||||
|
||||
@@ -234,8 +234,8 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
||||
return project.organizationId;
|
||||
}
|
||||
case "PROJECT_GROUP": {
|
||||
const binding = await this.prisma.projectGroupBinding.findUnique({
|
||||
where: { chatId: resource.id },
|
||||
const binding = await this.prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId: resource.id, archivedAt: null },
|
||||
select: { project: { select: { organizationId: true } } },
|
||||
});
|
||||
if (binding === null) {
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { relative, resolve } from "node:path";
|
||||
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
||||
import { createPermissionAuthorizer } from "./permission.js";
|
||||
import { writeAudit } from "./audit.js";
|
||||
|
||||
export interface OrganizationProjectPolicy {
|
||||
readonly organizationId: string;
|
||||
readonly membersCanCreateProjects: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectOnboardingResult {
|
||||
readonly projectId: string;
|
||||
readonly organizationId: string;
|
||||
readonly folderId: string | null;
|
||||
readonly workspaceDir: string;
|
||||
readonly chatId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface CreateOrgAdminProjectInput {
|
||||
readonly organizationId: string;
|
||||
readonly actorFeishuOpenId: string;
|
||||
readonly name: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly folderId?: string | undefined;
|
||||
readonly sortKey?: string | undefined;
|
||||
}
|
||||
|
||||
export interface CreateFeishuChatProjectInput {
|
||||
readonly organizationId: string;
|
||||
readonly actorFeishuOpenId: string;
|
||||
readonly chatId: string;
|
||||
readonly name: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly folderId?: string | undefined;
|
||||
readonly sortKey?: string | undefined;
|
||||
}
|
||||
|
||||
export interface BindFeishuChatToProjectInput {
|
||||
readonly projectId: string;
|
||||
readonly actorFeishuOpenId: string;
|
||||
readonly chatId: string;
|
||||
}
|
||||
|
||||
export interface ArchiveFeishuChatBindingInput {
|
||||
readonly projectId?: string | undefined;
|
||||
readonly chatId?: string | undefined;
|
||||
readonly actorFeishuOpenId: string;
|
||||
}
|
||||
|
||||
export interface CreateFolderInput {
|
||||
readonly organizationId: string;
|
||||
readonly name: string;
|
||||
readonly parentId?: string | undefined;
|
||||
readonly sortKey?: string | undefined;
|
||||
}
|
||||
|
||||
export interface MoveProjectToFolderInput {
|
||||
readonly projectId: string;
|
||||
readonly folderId: string | null;
|
||||
}
|
||||
|
||||
export async function ensureOrganizationProjectSettings(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): Promise<OrganizationProjectPolicy> {
|
||||
return prisma.$transaction(async (tx) => ensureOrganizationProjectSettingsTx(tx, organizationId));
|
||||
}
|
||||
|
||||
export async function setMembersCanCreateProjects(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly enabled: boolean },
|
||||
): Promise<OrganizationProjectPolicy> {
|
||||
await ensureOrganizationExists(prisma, input.organizationId);
|
||||
const settings = await prisma.organizationProjectSettings.upsert({
|
||||
where: { organizationId: input.organizationId },
|
||||
update: { membersCanCreateProjects: input.enabled },
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
membersCanCreateProjects: input.enabled,
|
||||
},
|
||||
select: { organizationId: true, membersCanCreateProjects: true },
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> {
|
||||
const name = requireNonEmpty(input.name, "folder name");
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await ensureOrganizationExistsTx(tx, input.organizationId);
|
||||
if (input.parentId !== undefined) {
|
||||
await assertFolderInOrganization(tx, input.parentId, input.organizationId);
|
||||
}
|
||||
return tx.folder.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
name,
|
||||
sortKey: input.sortKey ?? "",
|
||||
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveProjectToFolder(
|
||||
prisma: PrismaClient,
|
||||
input: MoveProjectToFolderInput,
|
||||
): Promise<{ readonly projectId: string; readonly folderId: string | null }> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const project = await tx.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (project === null) throw new Error(`project not found: ${input.projectId}`);
|
||||
if (input.folderId !== null) {
|
||||
await assertFolderInOrganization(tx, input.folderId, project.organizationId);
|
||||
}
|
||||
const updated = await tx.project.update({
|
||||
where: { id: input.projectId },
|
||||
data: { folderId: input.folderId },
|
||||
select: { id: true, folderId: true },
|
||||
});
|
||||
return { projectId: updated.id, folderId: updated.folderId };
|
||||
});
|
||||
}
|
||||
|
||||
export async function createProjectFromOrgAdmin(
|
||||
prisma: PrismaClient,
|
||||
input: CreateOrgAdminProjectInput,
|
||||
): Promise<ProjectOnboardingResult> {
|
||||
const name = requireNonEmpty(input.name, "project name");
|
||||
const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId);
|
||||
if (!isOrgAdminRole(actor.role)) {
|
||||
throw new Error(`org admin project creation requires OWNER or ADMIN in organization ${input.organizationId}`);
|
||||
}
|
||||
return createManagedProject(prisma, {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: actor.userId,
|
||||
actorFeishuOpenId: input.actorFeishuOpenId,
|
||||
name,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
folderId: input.folderId,
|
||||
sortKey: input.sortKey,
|
||||
chatId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createProjectFromFeishuChat(
|
||||
prisma: PrismaClient,
|
||||
input: CreateFeishuChatProjectInput,
|
||||
): Promise<ProjectOnboardingResult> {
|
||||
const chatId = requireNonEmpty(input.chatId, "chat id");
|
||||
const name = requireNonEmpty(input.name, "project name");
|
||||
const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId);
|
||||
const settings = await ensureOrganizationProjectSettings(prisma, input.organizationId);
|
||||
if (!settings.membersCanCreateProjects && !isOrgAdminRole(actor.role)) {
|
||||
throw new Error(`members cannot create projects in organization ${input.organizationId}`);
|
||||
}
|
||||
const activeBinding = await prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId, archivedAt: null },
|
||||
select: { projectId: true },
|
||||
});
|
||||
if (activeBinding !== null) {
|
||||
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeBinding.projectId}`);
|
||||
}
|
||||
return createManagedProject(prisma, {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: actor.userId,
|
||||
actorFeishuOpenId: input.actorFeishuOpenId,
|
||||
name,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
folderId: input.folderId,
|
||||
sortKey: input.sortKey,
|
||||
chatId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function bindFeishuChatToProject(
|
||||
prisma: PrismaClient,
|
||||
input: BindFeishuChatToProjectInput,
|
||||
): Promise<ProjectOnboardingResult> {
|
||||
const chatId = requireNonEmpty(input.chatId, "chat id");
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
|
||||
});
|
||||
if (project === null) throw new Error(`project not found: ${input.projectId}`);
|
||||
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const activeProjectBinding = await tx.projectGroupBinding.findFirst({
|
||||
where: { projectId: project.id, archivedAt: null },
|
||||
select: { chatId: true },
|
||||
});
|
||||
if (activeProjectBinding !== null) {
|
||||
throw new Error(`project ${project.id} is already bound to Feishu chat ${activeProjectBinding.chatId}`);
|
||||
}
|
||||
const activeChatBinding = await tx.projectGroupBinding.findFirst({
|
||||
where: { chatId, archivedAt: null },
|
||||
select: { projectId: true },
|
||||
});
|
||||
if (activeChatBinding !== null) {
|
||||
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`);
|
||||
}
|
||||
await tx.projectGroupBinding.create({
|
||||
data: { projectId: project.id, chatId, createdByUserId: actor.userId },
|
||||
});
|
||||
await replaceProjectGrant(tx, {
|
||||
projectId: project.id,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: chatId,
|
||||
role: "EDIT",
|
||||
createdByUserId: actor.userId,
|
||||
});
|
||||
});
|
||||
await writeAudit(prisma, {
|
||||
projectId: project.id,
|
||||
actorUserId: actor.userId,
|
||||
action: "project.chat_bound",
|
||||
metadata: { chatId, actorVia: actor.via },
|
||||
});
|
||||
return {
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
folderId: project.folderId,
|
||||
workspaceDir: project.workspaceDir,
|
||||
chatId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveFeishuChatBinding(
|
||||
prisma: PrismaClient,
|
||||
input: ArchiveFeishuChatBindingInput,
|
||||
): Promise<{ readonly archived: boolean; readonly projectId?: string | undefined; readonly chatId?: string | undefined }> {
|
||||
if ((input.projectId === undefined || input.projectId === "") && (input.chatId === undefined || input.chatId === "")) {
|
||||
throw new Error("archiveFeishuChatBinding requires projectId or chatId");
|
||||
}
|
||||
if (input.projectId !== undefined && input.projectId !== "" && input.chatId !== undefined && input.chatId !== "") {
|
||||
throw new Error("archiveFeishuChatBinding accepts only one of projectId or chatId");
|
||||
}
|
||||
const binding = await prisma.projectGroupBinding.findFirst({
|
||||
where: {
|
||||
archivedAt: null,
|
||||
...(input.projectId !== undefined && input.projectId !== "" ? { projectId: input.projectId } : { chatId: input.chatId! }),
|
||||
},
|
||||
select: { id: true, projectId: true, chatId: true, project: { select: { organizationId: true } } },
|
||||
});
|
||||
if (binding === null) return { archived: false };
|
||||
const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.projectGroupBinding.update({
|
||||
where: { id: binding.id },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
await tx.permissionGrant.updateMany({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: binding.projectId,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: binding.chatId,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
});
|
||||
await writeAudit(prisma, {
|
||||
projectId: binding.projectId,
|
||||
actorUserId: actor.userId,
|
||||
action: "project.chat_binding_archived",
|
||||
metadata: { chatId: binding.chatId, actorVia: actor.via },
|
||||
});
|
||||
return { archived: true, projectId: binding.projectId, chatId: binding.chatId };
|
||||
}
|
||||
|
||||
async function createManagedProject(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId: string;
|
||||
readonly actorFeishuOpenId: string;
|
||||
readonly name: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly folderId: string | undefined;
|
||||
readonly sortKey?: string | undefined;
|
||||
readonly chatId: string | undefined;
|
||||
},
|
||||
): Promise<ProjectOnboardingResult> {
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { id: input.organizationId },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
|
||||
|
||||
const projectId = createProjectId();
|
||||
const workspaceDir = projectWorkspaceDir({
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
organizationSlug: organization.slug,
|
||||
projectId,
|
||||
});
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
const project = await prisma.$transaction(async (tx) => {
|
||||
await ensureOrganizationProjectSettingsTx(tx, organization.id);
|
||||
const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id;
|
||||
await assertFolderInOrganization(tx, folderId, organization.id);
|
||||
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
id: projectId,
|
||||
organizationId: organization.id,
|
||||
folderId,
|
||||
name: input.name,
|
||||
workspaceDir,
|
||||
createdByUserId: input.actorUserId,
|
||||
},
|
||||
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
|
||||
});
|
||||
await tx.permissionSettings.create({
|
||||
data: defaultProjectPermissionSettings(created.id),
|
||||
});
|
||||
await replaceProjectGrant(tx, {
|
||||
projectId: created.id,
|
||||
principalType: "USER",
|
||||
principalId: input.actorFeishuOpenId,
|
||||
role: "MANAGE",
|
||||
createdByUserId: input.actorUserId,
|
||||
});
|
||||
if (input.chatId !== undefined) {
|
||||
await tx.projectGroupBinding.create({
|
||||
data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId },
|
||||
});
|
||||
await replaceProjectGrant(tx, {
|
||||
projectId: created.id,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: input.chatId,
|
||||
role: "EDIT",
|
||||
createdByUserId: input.actorUserId,
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
await writeAudit(prisma, {
|
||||
projectId: project.id,
|
||||
actorUserId: input.actorUserId,
|
||||
action: input.chatId === undefined ? "project.created" : "project.created_from_feishu_chat",
|
||||
metadata: { folderId: project.folderId, workspaceDir, chatId: input.chatId ?? null },
|
||||
});
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
folderId: project.folderId,
|
||||
workspaceDir: project.workspaceDir,
|
||||
...(input.chatId !== undefined ? { chatId: input.chatId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function requireProjectManager(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
organizationId: string,
|
||||
actorFeishuOpenId: string,
|
||||
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole; readonly via: "org-admin" | "project-manage" }> {
|
||||
const actor = await requireActiveOrgMember(prisma, organizationId, actorFeishuOpenId);
|
||||
if (isOrgAdminRole(actor.role)) {
|
||||
return { ...actor, via: "org-admin" };
|
||||
}
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: actorFeishuOpenId },
|
||||
action: "collaborator.manage",
|
||||
resource: { type: "PROJECT", id: projectId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
throw new Error(`project ${projectId} requires MANAGE for ${actorFeishuOpenId}: ${decision.reason}`);
|
||||
}
|
||||
return { ...actor, via: "project-manage" };
|
||||
}
|
||||
|
||||
async function requireActiveOrgMember(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
feishuOpenId: string,
|
||||
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { feishuOpenId },
|
||||
select: {
|
||||
id: true,
|
||||
organizationMemberships: {
|
||||
where: { organizationId, revokedAt: null },
|
||||
select: { role: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (user === null) {
|
||||
throw new Error(`Feishu user ${feishuOpenId} must log in before project onboarding`);
|
||||
}
|
||||
const membership = user.organizationMemberships[0];
|
||||
if (membership === undefined) {
|
||||
throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`);
|
||||
}
|
||||
return { userId: user.id, role: membership.role };
|
||||
}
|
||||
|
||||
async function ensureOrganizationProjectSettingsTx(
|
||||
prisma: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
): Promise<OrganizationProjectPolicy> {
|
||||
await ensureOrganizationExistsTx(prisma, organizationId);
|
||||
return prisma.organizationProjectSettings.upsert({
|
||||
where: { organizationId },
|
||||
update: {},
|
||||
create: { organizationId, membersCanCreateProjects: true },
|
||||
select: { organizationId: true, membersCanCreateProjects: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> {
|
||||
const existing = await prisma.folder.findFirst({
|
||||
where: { organizationId, parentId: null, name: "Inbox", archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) return existing;
|
||||
return prisma.folder.create({
|
||||
data: { organizationId, name: "Inbox", sortKey: "000000" },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function assertFolderInOrganization(
|
||||
prisma: Prisma.TransactionClient,
|
||||
folderId: string,
|
||||
organizationId: string,
|
||||
): Promise<void> {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: { id: folderId },
|
||||
select: { organizationId: true, archivedAt: true },
|
||||
});
|
||||
if (folder === null || folder.archivedAt !== null) {
|
||||
throw new Error(`active folder not found: ${folderId}`);
|
||||
}
|
||||
if (folder.organizationId !== organizationId) {
|
||||
throw new Error(`folder ${folderId} is in ${folder.organizationId}, not organization ${organizationId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOrganizationExists(prisma: PrismaClient, organizationId: string): Promise<void> {
|
||||
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
|
||||
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
|
||||
}
|
||||
|
||||
async function ensureOrganizationExistsTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
|
||||
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
|
||||
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
|
||||
}
|
||||
|
||||
async function replaceProjectGrant(
|
||||
prisma: Prisma.TransactionClient,
|
||||
input: {
|
||||
readonly projectId: string;
|
||||
readonly principalType: "USER" | "FEISHU_CHAT";
|
||||
readonly principalId: string;
|
||||
readonly role: PermissionRole;
|
||||
readonly createdByUserId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await prisma.permissionGrant.updateMany({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: input.projectId,
|
||||
principalType: input.principalType,
|
||||
principalId: input.principalId,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: input.projectId,
|
||||
principalType: input.principalType,
|
||||
principalId: input.principalId,
|
||||
role: input.role,
|
||||
createdByUserId: input.createdByUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function defaultProjectPermissionSettings(projectId: string): Prisma.PermissionSettingsCreateInput {
|
||||
return {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: projectId,
|
||||
externalShare: "DISABLED",
|
||||
comment: "ROLE",
|
||||
copyDownload: "ROLE",
|
||||
collaboratorMgmt: "MANAGE_ONLY",
|
||||
agentTrigger: "ROLE",
|
||||
agentCancel: "MANAGE_ONLY",
|
||||
};
|
||||
}
|
||||
|
||||
function isOrgAdminRole(role: OrganizationMemberRole): boolean {
|
||||
return role === "OWNER" || role === "ADMIN";
|
||||
}
|
||||
|
||||
function requireNonEmpty(value: string, label: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") throw new Error(`${label} is required`);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function createProjectId(): string {
|
||||
return `project_${randomUUID().replaceAll("-", "")}`;
|
||||
}
|
||||
|
||||
function projectWorkspaceDir(input: {
|
||||
readonly workspaceRoot: string;
|
||||
readonly organizationSlug: string;
|
||||
readonly projectId: string;
|
||||
}): string {
|
||||
const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root"));
|
||||
const dir = resolve(root, safePathSegment(input.organizationSlug, "organization slug"), safePathSegment(input.projectId, "project id"));
|
||||
const rel = relative(root, dir);
|
||||
if (rel === "" || rel.startsWith("..")) {
|
||||
throw new Error(`allocated workspace escapes root: ${dir}`);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function safePathSegment(value: string, label: string): string {
|
||||
const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_");
|
||||
if (segment === "." || segment === ".." || segment === "") {
|
||||
throw new Error(`${label} is not a safe path segment`);
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export async function resetDb(): Promise<void> {
|
||||
"AgentRun",
|
||||
"AgentSession",
|
||||
"ProjectGroupBinding",
|
||||
"Folder",
|
||||
"OrganizationProjectSettings",
|
||||
"OrganizationMembership",
|
||||
"PlatformRoleAssignment",
|
||||
"User",
|
||||
@@ -68,6 +70,25 @@ export async function seedTestOrganization(
|
||||
name: "Test Default Organization",
|
||||
},
|
||||
});
|
||||
await prisma.organizationProjectSettings.upsert({
|
||||
where: { organizationId: id },
|
||||
update: {},
|
||||
create: { organizationId: id, membersCanCreateProjects: true },
|
||||
});
|
||||
const inbox = await prisma.folder.findFirst({
|
||||
where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (inbox === null) {
|
||||
await prisma.folder.create({
|
||||
data: {
|
||||
id: `folder_inbox_${id}`,
|
||||
organizationId: id,
|
||||
name: "Inbox",
|
||||
sortKey: "000000",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A logger that discards everything (tests don't need fastify's pino). */
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
|
||||
import {
|
||||
archiveFeishuChatBinding,
|
||||
bindFeishuChatToProject,
|
||||
createFolder,
|
||||
createProjectFromFeishuChat,
|
||||
createProjectFromOrgAdmin,
|
||||
setMembersCanCreateProjects,
|
||||
} from "../../src/projectOnboarding.js";
|
||||
|
||||
const workspaceRoots: string[] = [];
|
||||
|
||||
describe("ADR-0021 project onboarding", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (workspaceRoots.length > 0) {
|
||||
const root = workspaceRoots.pop();
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("lets an org admin create an unbound project in a folder", async () => {
|
||||
await seedUser("u-admin", "ou_admin", "ADMIN");
|
||||
const folder = await createFolder(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "Grade 7",
|
||||
sortKey: "010000",
|
||||
});
|
||||
const workspaceRoot = await tempWorkspaceRoot();
|
||||
|
||||
const result = await createProjectFromOrgAdmin(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_admin",
|
||||
name: "Newton Lesson",
|
||||
folderId: folder.id,
|
||||
workspaceRoot,
|
||||
});
|
||||
|
||||
expect(result.folderId).toBe(folder.id);
|
||||
expect(result.chatId).toBeUndefined();
|
||||
expect((await stat(result.workspaceDir)).isDirectory()).toBe(true);
|
||||
const grant = await prisma.permissionGrant.findFirst({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: result.projectId,
|
||||
principalType: "USER",
|
||||
principalId: "ou_admin",
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { role: true },
|
||||
});
|
||||
expect(grant?.role).toBe("MANAGE");
|
||||
const settings = await prisma.permissionSettings.findUnique({
|
||||
where: { resourceType_resourceId: { resourceType: "PROJECT", resourceId: result.projectId } },
|
||||
});
|
||||
expect(settings?.agentTrigger).toBe("ROLE");
|
||||
});
|
||||
|
||||
it("lets an org member create and bind a project from an unbound Feishu chat", async () => {
|
||||
await seedUser("u-member", "ou_member", "MEMBER");
|
||||
const workspaceRoot = await tempWorkspaceRoot();
|
||||
|
||||
const result = await createProjectFromFeishuChat(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_member",
|
||||
chatId: "chat-onboard",
|
||||
name: "Poetry Workshop",
|
||||
workspaceRoot,
|
||||
});
|
||||
|
||||
expect(result.chatId).toBe("chat-onboard");
|
||||
const binding = await prisma.projectGroupBinding.findFirst({
|
||||
where: { projectId: result.projectId, chatId: "chat-onboard", archivedAt: null },
|
||||
});
|
||||
expect(binding).not.toBeNull();
|
||||
const grants = await prisma.permissionGrant.findMany({
|
||||
where: { resourceType: "PROJECT", resourceId: result.projectId, revokedAt: null },
|
||||
select: { principalType: true, principalId: true, role: true },
|
||||
orderBy: { principalType: "asc" },
|
||||
});
|
||||
expect(grants).toEqual(expect.arrayContaining([
|
||||
{ principalType: "USER", principalId: "ou_member", role: "MANAGE" },
|
||||
{ principalType: "FEISHU_CHAT", principalId: "chat-onboard", role: "EDIT" },
|
||||
]));
|
||||
});
|
||||
|
||||
it("blocks ordinary Feishu project creation when the org setting is off", async () => {
|
||||
await seedUser("u-member", "ou_member", "MEMBER");
|
||||
await setMembersCanCreateProjects(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
await expect(createProjectFromFeishuChat(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_member",
|
||||
chatId: "chat-denied",
|
||||
name: "Denied Project",
|
||||
workspaceRoot: await tempWorkspaceRoot(),
|
||||
})).rejects.toThrow(/members cannot create projects/);
|
||||
});
|
||||
|
||||
it("binds an existing project once, archives the binding, then allows a new active binding", async () => {
|
||||
await seedUser("u-owner", "ou_owner", "OWNER");
|
||||
const workspaceRoot = await tempWorkspaceRoot();
|
||||
const project = await createProjectFromOrgAdmin(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_owner",
|
||||
name: "Reusable Project",
|
||||
workspaceRoot,
|
||||
});
|
||||
|
||||
await bindFeishuChatToProject(prisma, {
|
||||
projectId: project.projectId,
|
||||
actorFeishuOpenId: "ou_owner",
|
||||
chatId: "chat-first",
|
||||
});
|
||||
await expect(bindFeishuChatToProject(prisma, {
|
||||
projectId: project.projectId,
|
||||
actorFeishuOpenId: "ou_owner",
|
||||
chatId: "chat-second",
|
||||
})).rejects.toThrow(/already bound/);
|
||||
|
||||
await expect(archiveFeishuChatBinding(prisma, {
|
||||
projectId: project.projectId,
|
||||
actorFeishuOpenId: "ou_owner",
|
||||
})).resolves.toMatchObject({ archived: true, chatId: "chat-first" });
|
||||
await bindFeishuChatToProject(prisma, {
|
||||
projectId: project.projectId,
|
||||
actorFeishuOpenId: "ou_owner",
|
||||
chatId: "chat-second",
|
||||
});
|
||||
|
||||
const activeBindings = await prisma.projectGroupBinding.findMany({
|
||||
where: { projectId: project.projectId, archivedAt: null },
|
||||
select: { chatId: true },
|
||||
});
|
||||
expect(activeBindings).toEqual([{ chatId: "chat-second" }]);
|
||||
const oldChatGrant = await prisma.permissionGrant.findFirst({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: project.projectId,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: "chat-first",
|
||||
revokedAt: null,
|
||||
},
|
||||
});
|
||||
expect(oldChatGrant).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise<void> {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
feishuOpenId,
|
||||
displayName: feishuOpenId,
|
||||
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function tempWorkspaceRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-onboarding-"));
|
||||
workspaceRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
@@ -255,7 +255,7 @@ function mockPrisma(): PrismaClient {
|
||||
create: vi.fn(async () => ({ id: "receipt-1" })),
|
||||
},
|
||||
projectGroupBinding: {
|
||||
findUnique: vi.fn(async () => ({ projectId: "project-1" })),
|
||||
findFirst: vi.fn(async () => ({ projectId: "project-1" })),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
|
||||
|
||||
@@ -17,6 +17,8 @@ structure Identifiers where
|
||||
OrganizationId : Type
|
||||
/-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/
|
||||
TeamId : Type
|
||||
/-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/
|
||||
FolderId : Type
|
||||
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
|
||||
ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/
|
||||
RunId : Type
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Spec.System.ProjectGroup
|
||||
import Spec.System.Organization
|
||||
import Spec.System.ProjectWorkspace
|
||||
import Spec.System.Run
|
||||
import Spec.System.Lock
|
||||
import Spec.System.Memory
|
||||
@@ -16,6 +17,8 @@ import Spec.System.Audit
|
||||
|
||||
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
|
||||
- `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org。
|
||||
- `ProjectWorkspace` —— org 后台 project explorer:folder 是透明组织节点,project 仍是权限边界
|
||||
(ADR-0021)。
|
||||
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
|
||||
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
|
||||
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。
|
||||
@@ -26,5 +29,5 @@ import Spec.System.Audit
|
||||
(ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。
|
||||
- `Audit` —— 有意从简(内容多为 plumbing,OPEN)。
|
||||
|
||||
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020。
|
||||
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020, 0021。
|
||||
-/
|
||||
|
||||
@@ -6,14 +6,12 @@ import Spec.Prelude
|
||||
ADR-0001 的核心:一个 project 对应一个**长生命周期**飞书项目群;群是协作空间,**不是锁
|
||||
owner**(锁归 `AgentRun`,见 `Lock` / ADR-0002),不是临时处理 session。群可在无 Claude
|
||||
处理时保持开启;教师离群/静音与项目权限、与 Claude 生命周期相互独立。本模块钉死
|
||||
project↔group 的一对一绑定——likec4 画得出"project has group",画不出"恰好一个、且群
|
||||
不持锁"。
|
||||
project↔group 的**active**一对一绑定——likec4 画得出"project has group",画不出"恰好一个、
|
||||
且群不持锁"。
|
||||
|
||||
**群失效态与绑定快照(`OPEN`, ADR-0001 Consequences):** 群解散/归档/不可达时,
|
||||
`GroupBinding` 快照应变为 `none`(解绑、允许 rebind)还是保持 `some` 但指向失效 chat
|
||||
(悬挂、待管理员干预)未决策。ADR-0001 已点名 "rebinding or group archival needs
|
||||
explicit product rules";本模块只刻画健康态 1:1 不变式,不臆造失效态语义。实现遇到
|
||||
群解散必须 surface,不得默认任一方。
|
||||
**绑定历史(`PINNED`, ADR-0021):** active binding 严格 1:1;实现可以保留 archived
|
||||
historical binding rows 供审计/纠错,但 `GroupBinding` 谓词只刻画当前 active 快照。
|
||||
群解散/不可达的自动化处理仍为 `OPEN`;pilot 纠错由 org admin 显式归档绑定。
|
||||
-/
|
||||
|
||||
namespace Spec.System
|
||||
@@ -27,13 +25,14 @@ structure ProjectGroup where
|
||||
group";chat 标识见 `Identifiers.ChatId`)。 -/
|
||||
chat : I.ChatId
|
||||
|
||||
/-- 项目↔群绑定表(`PINNED` 每项目至多一个群, ADR-0001)。`ProjectId → Option ChatId` 的
|
||||
结构本身即"一个 project 至多绑一个群"(由 `Option` 自带);良构补另一半——单射。 -/
|
||||
/-- 项目↔active 群绑定表(`PINNED` 每项目至多一个 active 群, ADR-0001/0021)。
|
||||
`ProjectId → Option ChatId` 的结构本身即"一个 project 至多绑一个 active 群"(由 `Option`
|
||||
自带);良构补另一半——单射。 -/
|
||||
def GroupBinding := I.ProjectId → Option I.ChatId
|
||||
|
||||
/-- 绑定良构:**单射**——不同 project 不绑同一 chat(`PINNED` 1:1 的另一半, ADR-0001)。
|
||||
"每 project 至多一个群"由 `Option` 结构自带;这条钉死"每群至多属于一个 project"。群
|
||||
rebinding/archival 是生命周期事件(ADR-0001 Consequences),不在本快照不变式内。 -/
|
||||
/-- Active 绑定良构:**单射**——不同 project 不绑同一 active chat(`PINNED` 1:1 的另一半,
|
||||
ADR-0001/0021)。"每 project 至多一个群"由 `Option` 结构自带;这条钉死"每群至多属于一个
|
||||
project"。archived historical bindings 不在本快照不变式内。 -/
|
||||
def GroupBinding.WellFormed (b : GroupBinding I) : Prop :=
|
||||
∀ p₁ p₂ c, b p₁ = some c → b p₂ = some c → p₁ = p₂
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Spec.Prelude
|
||||
|
||||
/-!
|
||||
# ProjectWorkspace —— project explorer 与飞书建项入口(ADR-0021)
|
||||
|
||||
ADR-0021 把 org 后台里的"文件管理器式"项目管理收口为透明 folder + project:
|
||||
folder 只负责导航、排序、层级与用量聚合,当前不是权限资源。project 仍是授权边界。
|
||||
|
||||
本模块只钉死会影响实现分歧的不变量:folder/project 同 org、folder 不参与权限、普通成员
|
||||
从飞书群创建 project 必须受 org policy 控制。folder visibility/team policy/继承授权仍为
|
||||
未来扩展,不得在当前实现中半隐式加入。
|
||||
-/
|
||||
|
||||
namespace Spec.System
|
||||
|
||||
variable (I : Identifiers)
|
||||
|
||||
/-- Project explorer folder 的租户归属(`PINNED`, ADR-0021):folder 是 org 内透明节点。 -/
|
||||
structure FolderTenancy where
|
||||
/-- 被归属的 folder(`PINNED`, ADR-0021)。 -/
|
||||
folder : I.FolderId
|
||||
/-- folder 所属 organization(`PINNED`, ADR-0021)。 -/
|
||||
organization : I.OrganizationId
|
||||
|
||||
/-- Project 放入 folder 的作用域(`PINNED`, ADR-0021):project 可以位于某个 folder,
|
||||
但二者必须同 org。folder 不改变 project 权限。 -/
|
||||
structure ProjectFolderPlacement where
|
||||
/-- 被放置的 project(`PINNED`, ADR-0021)。 -/
|
||||
project : I.ProjectId
|
||||
/-- project 所在 folder(`PINNED`, ADR-0021)。 -/
|
||||
folder : I.FolderId
|
||||
|
||||
/-- Project-folder placement 是良构的 iff project 与 folder 解析到同一 organization
|
||||
(`PINNED`, ADR-0021)。 -/
|
||||
def ProjectFolderPlacement.WellScoped
|
||||
(placement : ProjectFolderPlacement I)
|
||||
(projectOrg : I.ProjectId → Option I.OrganizationId)
|
||||
(folderOrg : I.FolderId → Option I.OrganizationId) : Prop :=
|
||||
∃ o, projectOrg placement.project = some o ∧ folderOrg placement.folder = some o
|
||||
|
||||
/-- Folder 当前透明(`PINNED`, ADR-0021):folder 不是权限资源,不持有 grants,移动 project
|
||||
不改变 project 自身授权。未来 folder policy 若出现,必须新增显式语义而不是复用本谓词。 -/
|
||||
structure FolderTransparent where
|
||||
/-- 透明性命题本身;字段存在是为了让 contract 明确可引用(`PINNED`, ADR-0021)。 -/
|
||||
current : True
|
||||
|
||||
/-- 普通 org member 是否可从 Feishu 群自助创建 project 的 org policy(`PINNED`, ADR-0021)。 -/
|
||||
structure MemberProjectCreationPolicy where
|
||||
/-- policy 所属 organization(`PINNED`, ADR-0021)。 -/
|
||||
organization : I.OrganizationId
|
||||
/-- 为 true 时普通成员可从未绑定飞书群创建 project;owner/admin 可走后台入口。 -/
|
||||
membersCanCreateProjects : Bool
|
||||
|
||||
end Spec.System
|
||||
Reference in New Issue
Block a user