forked from EduCraft/curriculum-project-hub
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 008c8bcd09 | |||
| 683d5674d1 | |||
| 1f8510a47f | |||
| c4f052efa2 | |||
| 87e3d3f990 | |||
| 519ea8144f | |||
| 34e07e229f |
@@ -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.
|
||||
@@ -21,6 +21,9 @@ ANTHROPIC_API_KEY=""
|
||||
# Agent run policy (optional). Defaults to 25.
|
||||
# HUB_AGENT_MAX_TURNS=25
|
||||
|
||||
# System-managed root for project workspaces created from org admin / Feishu onboarding.
|
||||
HUB_PROJECT_WORKSPACE_ROOT="./data/project-workspaces"
|
||||
|
||||
# Feishu (lark) bot credentials.
|
||||
FEISHU_APP_ID=""
|
||||
FEISHU_APP_SECRET=""
|
||||
@@ -31,3 +34,18 @@ FEISHU_BOT_OPEN_ID=""
|
||||
|
||||
# Hub server port. Defaults to 8788.
|
||||
PORT=8788
|
||||
|
||||
# --- Org admin web (ADR-0021) ---------------------------------------------
|
||||
# Public base URL of this Hub (no trailing slash). Used for Feishu OAuth
|
||||
# redirect_uri = ${HUB_PUBLIC_BASE_URL}/auth/feishu/callback
|
||||
# Configure the same callback URL in the Feishu developer console under
|
||||
# Security Settings → Redirect URLs.
|
||||
HUB_PUBLIC_BASE_URL="http://127.0.0.1:8788"
|
||||
|
||||
# HMAC secret for signed session + OAuth state cookies. Generate with:
|
||||
# openssl rand -base64 32
|
||||
HUB_SESSION_SECRET=""
|
||||
|
||||
# Optional OAuth scope (space-separated). Default: contact:user.base:readonly
|
||||
# Apply matching scopes in the Feishu developer console.
|
||||
# HUB_OAUTH_SCOPE="contact:user.base:readonly"
|
||||
|
||||
@@ -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) -----------------------------
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Feishu web OAuth (user login) for org admin panel.
|
||||
*
|
||||
* Pilot uses the process-global FEISHU_APP_ID/SECRET (same app as the bot).
|
||||
* Per-org encrypted Feishu app secrets are deferred (ADR-0021).
|
||||
*
|
||||
* Flow:
|
||||
* 1. GET authorize URL → user consents
|
||||
* 2. callback code → user_access_token (authen/v2/oauth/token)
|
||||
* 3. user_access_token → user info (authen/v1/user_info) → open_id
|
||||
*/
|
||||
|
||||
export interface FeishuOAuthConfig {
|
||||
readonly appId: string;
|
||||
readonly appSecret: string;
|
||||
readonly redirectUri: string;
|
||||
/** Space-separated scopes requested at authorize time. */
|
||||
readonly scope: string;
|
||||
readonly authorizeBaseUrl?: string;
|
||||
readonly tokenUrl?: string;
|
||||
readonly userInfoUrl?: string;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface FeishuOAuthUser {
|
||||
readonly openId: string;
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_AUTHORIZE_BASE = "https://accounts.feishu.cn/open-apis/authen/v1/authorize";
|
||||
const DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
|
||||
const DEFAULT_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info";
|
||||
|
||||
/** Minimal scopes for open_id + name/avatar on user_info. */
|
||||
export const DEFAULT_OAUTH_SCOPE = "contact:user.base:readonly";
|
||||
|
||||
export function buildAuthorizeUrl(config: FeishuOAuthConfig, state: string): string {
|
||||
const base = config.authorizeBaseUrl ?? DEFAULT_AUTHORIZE_BASE;
|
||||
const url = new URL(base);
|
||||
url.searchParams.set("client_id", config.appId);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", config.redirectUri);
|
||||
url.searchParams.set("state", state);
|
||||
if (config.scope.trim() !== "") {
|
||||
url.searchParams.set("scope", config.scope);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function exchangeCodeForUser(
|
||||
config: FeishuOAuthConfig,
|
||||
code: string,
|
||||
): Promise<FeishuOAuthUser> {
|
||||
const accessToken = await exchangeCodeForAccessToken(config, code);
|
||||
return fetchUserInfo(config, accessToken);
|
||||
}
|
||||
|
||||
async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise<string> {
|
||||
const fetchImpl = config.fetchImpl ?? fetch;
|
||||
const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL;
|
||||
const res = await fetchImpl(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.appId,
|
||||
client_secret: config.appSecret,
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
}),
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
code?: number;
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
msg?: string;
|
||||
};
|
||||
if (!res.ok || body.code !== 0 || typeof body.access_token !== "string" || body.access_token === "") {
|
||||
const detail = body.error_description ?? body.error ?? body.msg ?? `HTTP ${res.status}`;
|
||||
throw new Error(`Feishu OAuth token exchange failed: ${detail}`);
|
||||
}
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
async function fetchUserInfo(config: FeishuOAuthConfig, userAccessToken: string): Promise<FeishuOAuthUser> {
|
||||
const fetchImpl = config.fetchImpl ?? fetch;
|
||||
const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL;
|
||||
const res = await fetchImpl(userInfoUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${userAccessToken}` },
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
open_id?: string;
|
||||
name?: string;
|
||||
en_name?: string;
|
||||
avatar_url?: string;
|
||||
avatar_middle?: string;
|
||||
};
|
||||
};
|
||||
if (!res.ok || body.code !== 0 || body.data === undefined) {
|
||||
throw new Error(`Feishu user_info failed: ${body.msg ?? `HTTP ${res.status}`}`);
|
||||
}
|
||||
const openId = body.data.open_id;
|
||||
if (typeof openId !== "string" || openId === "") {
|
||||
throw new Error("Feishu user_info response missing open_id");
|
||||
}
|
||||
const displayName =
|
||||
(typeof body.data.name === "string" && body.data.name !== "" ? body.data.name : null) ??
|
||||
(typeof body.data.en_name === "string" && body.data.en_name !== "" ? body.data.en_name : null) ??
|
||||
openId;
|
||||
const avatar =
|
||||
(typeof body.data.avatar_url === "string" && body.data.avatar_url !== ""
|
||||
? body.data.avatar_url
|
||||
: null) ??
|
||||
(typeof body.data.avatar_middle === "string" && body.data.avatar_middle !== ""
|
||||
? body.data.avatar_middle
|
||||
: null);
|
||||
return { openId, displayName, avatarUrl: avatar };
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* HTTP guards for org admin APIs (ADR-0021).
|
||||
*
|
||||
* Platform admin is a separate control plane — not modeled here.
|
||||
*/
|
||||
import type { Organization, OrganizationMemberRole, PrismaClient, User } from "@prisma/client";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
verifySession,
|
||||
type SessionPayload,
|
||||
} from "./session.js";
|
||||
|
||||
export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
|
||||
|
||||
export interface AuthContext {
|
||||
readonly session: SessionPayload;
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export interface OrgAuthContext extends AuthContext {
|
||||
readonly organization: Organization;
|
||||
readonly membershipRole: OrganizationMemberRole;
|
||||
}
|
||||
|
||||
export interface GuardDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly statusCode: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(statusCode: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireSession(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: GuardDeps,
|
||||
): Promise<AuthContext | null> {
|
||||
const raw = request.cookies[SESSION_COOKIE_NAME];
|
||||
if (raw === undefined || raw === "") {
|
||||
await sendError(reply, 401, "unauthenticated", "login required");
|
||||
return null;
|
||||
}
|
||||
const session = verifySession(raw, deps.sessionSecret);
|
||||
if (session === null) {
|
||||
await sendError(reply, 401, "unauthenticated", "session invalid or expired");
|
||||
return null;
|
||||
}
|
||||
const user = await deps.prisma.user.findUnique({ where: { id: session.userId } });
|
||||
if (user === null || user.feishuOpenId !== session.feishuOpenId) {
|
||||
await sendError(reply, 401, "unauthenticated", "session user not found");
|
||||
return null;
|
||||
}
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export async function requireOrgRole(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: GuardDeps,
|
||||
options: {
|
||||
readonly orgSlug: string;
|
||||
readonly roles?: readonly OrganizationMemberRole[];
|
||||
},
|
||||
): Promise<OrgAuthContext | null> {
|
||||
const auth = await requireSession(request, reply, deps);
|
||||
if (auth === null) return null;
|
||||
|
||||
const allowed = options.roles ?? ORG_ADMIN_ROLES;
|
||||
const organization = await deps.prisma.organization.findUnique({
|
||||
where: { slug: options.orgSlug },
|
||||
});
|
||||
if (organization === null) {
|
||||
await sendError(reply, 404, "org_not_found", `organization not found: ${options.orgSlug}`);
|
||||
return null;
|
||||
}
|
||||
if (organization.status !== "ACTIVE") {
|
||||
await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const membership = await deps.prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
userId: auth.user.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { role: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
await sendError(reply, 403, "forbidden", "not a member of this organization");
|
||||
return null;
|
||||
}
|
||||
if (!allowed.includes(membership.role)) {
|
||||
await sendError(reply, 403, "forbidden", `requires role: ${allowed.join("|")}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...auth,
|
||||
organization,
|
||||
membershipRole: membership.role,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireOrgProject(
|
||||
deps: GuardDeps,
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
): Promise<{ readonly id: string; readonly organizationId: string; readonly name: string }> {
|
||||
const project = await deps.prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, organizationId: true, name: true, archivedAt: true },
|
||||
});
|
||||
if (project === null || project.organizationId !== organizationId) {
|
||||
throw new HttpError(404, "project_not_found", `project not found: ${projectId}`);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function sendError(
|
||||
reply: FastifyReply,
|
||||
statusCode: number,
|
||||
code: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await reply.status(statusCode).send({ error: { code, message } });
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Signed cookie session for org admin web login (ADR-0021).
|
||||
*
|
||||
* Payload is HMAC-SHA256 signed (HUB_SESSION_SECRET). No server-side session
|
||||
* table in v1 — logout clears the cookie; stolen-cookie revoke is deferred.
|
||||
*/
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export const SESSION_COOKIE_NAME = "cph_session";
|
||||
export const OAUTH_STATE_COOKIE_NAME = "cph_oauth_state";
|
||||
|
||||
/** Default session TTL: 7 days. */
|
||||
export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
/** OAuth state cookie TTL: 10 minutes. */
|
||||
export const OAUTH_STATE_TTL_SECONDS = 10 * 60;
|
||||
|
||||
export interface SessionPayload {
|
||||
readonly userId: string;
|
||||
readonly feishuOpenId: string;
|
||||
readonly iat: number;
|
||||
readonly exp: number;
|
||||
}
|
||||
|
||||
export interface OAuthStatePayload {
|
||||
readonly nonce: string;
|
||||
readonly returnTo: string;
|
||||
readonly exp: number;
|
||||
}
|
||||
|
||||
export function signSession(
|
||||
payload: Omit<SessionPayload, "iat" | "exp">,
|
||||
secret: string,
|
||||
ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS,
|
||||
nowSeconds: number = Math.floor(Date.now() / 1000),
|
||||
): string {
|
||||
const full: SessionPayload = {
|
||||
userId: payload.userId,
|
||||
feishuOpenId: payload.feishuOpenId,
|
||||
iat: nowSeconds,
|
||||
exp: nowSeconds + ttlSeconds,
|
||||
};
|
||||
return signJson(full, secret);
|
||||
}
|
||||
|
||||
export function verifySession(
|
||||
token: string,
|
||||
secret: string,
|
||||
nowSeconds: number = Math.floor(Date.now() / 1000),
|
||||
): SessionPayload | null {
|
||||
const payload = verifyJson<SessionPayload>(token, secret);
|
||||
if (payload === null) return null;
|
||||
if (typeof payload.userId !== "string" || payload.userId === "") return null;
|
||||
if (typeof payload.feishuOpenId !== "string" || payload.feishuOpenId === "") return null;
|
||||
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
|
||||
if (payload.exp <= nowSeconds) return null;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function signOAuthState(
|
||||
payload: Omit<OAuthStatePayload, "exp">,
|
||||
secret: string,
|
||||
ttlSeconds: number = OAUTH_STATE_TTL_SECONDS,
|
||||
nowSeconds: number = Math.floor(Date.now() / 1000),
|
||||
): string {
|
||||
const full: OAuthStatePayload = {
|
||||
nonce: payload.nonce,
|
||||
returnTo: payload.returnTo,
|
||||
exp: nowSeconds + ttlSeconds,
|
||||
};
|
||||
return signJson(full, secret);
|
||||
}
|
||||
|
||||
export function verifyOAuthState(
|
||||
token: string,
|
||||
secret: string,
|
||||
nowSeconds: number = Math.floor(Date.now() / 1000),
|
||||
): OAuthStatePayload | null {
|
||||
const payload = verifyJson<OAuthStatePayload>(token, secret);
|
||||
if (payload === null) return null;
|
||||
if (typeof payload.nonce !== "string" || payload.nonce === "") return null;
|
||||
if (typeof payload.returnTo !== "string") return null;
|
||||
if (typeof payload.exp !== "number" || payload.exp <= nowSeconds) return null;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function signJson(value: unknown, secret: string): string {
|
||||
const body = Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
||||
const sig = hmac(body, secret);
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
function verifyJson<T>(token: string, secret: string): T | null {
|
||||
const dot = token.indexOf(".");
|
||||
if (dot <= 0 || dot === token.length - 1) return null;
|
||||
const body = token.slice(0, dot);
|
||||
const sig = token.slice(dot + 1);
|
||||
const expected = hmac(body, secret);
|
||||
if (!safeEqual(sig, expected)) return null;
|
||||
try {
|
||||
return JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hmac(body: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(body).digest("base64url");
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Map domain / HTTP errors to consistent JSON responses.
|
||||
*/
|
||||
import type { FastifyReply } from "fastify";
|
||||
import { HttpError, sendError } from "./auth/guards.js";
|
||||
|
||||
export async function handleRouteError(reply: FastifyReply, err: unknown): Promise<void> {
|
||||
if (err instanceof HttpError) {
|
||||
await sendError(reply, err.statusCode, err.code, err.message);
|
||||
return;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
const mapped = mapDomainError(err.message);
|
||||
if (mapped !== null) {
|
||||
await sendError(reply, mapped.statusCode, mapped.code, mapped.message);
|
||||
return;
|
||||
}
|
||||
await sendError(reply, 500, "internal_error", "internal error");
|
||||
return;
|
||||
}
|
||||
await sendError(reply, 500, "internal_error", "internal error");
|
||||
}
|
||||
|
||||
function mapDomainError(message: string): { statusCode: number; code: string; message: string } | null {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("not found")) {
|
||||
return { statusCode: 404, code: "not_found", message };
|
||||
}
|
||||
if (
|
||||
lower.includes("requires") ||
|
||||
lower.includes("forbidden") ||
|
||||
lower.includes("cannot") ||
|
||||
lower.includes("not an active member") ||
|
||||
lower.includes("refused")
|
||||
) {
|
||||
return { statusCode: 403, code: "forbidden", message };
|
||||
}
|
||||
if (
|
||||
lower.includes("is required") ||
|
||||
lower.includes("already") ||
|
||||
lower.includes("invalid") ||
|
||||
lower.includes("accepts only")
|
||||
) {
|
||||
return { statusCode: 400, code: "bad_request", message };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Registers org-admin HTTP surface: auth, org APIs, (later) static SPA.
|
||||
*/
|
||||
import cookie from "@fastify/cookie";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { registerAuthRoutes } from "./routes/authRoutes.js";
|
||||
import { registerOrgRoutes } from "./routes/orgRoutes.js";
|
||||
|
||||
export interface AdminPluginConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
readonly publicBaseUrl: string;
|
||||
readonly feishuAppId: string;
|
||||
readonly feishuAppSecret: string;
|
||||
readonly projectWorkspaceRoot: string;
|
||||
readonly cookieSecure?: boolean;
|
||||
readonly oauthScope?: string;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function registerAdminPlugin(
|
||||
app: FastifyInstance,
|
||||
config: AdminPluginConfig,
|
||||
): Promise<void> {
|
||||
await app.register(cookie);
|
||||
|
||||
const cookieSecure =
|
||||
config.cookieSecure ?? config.publicBaseUrl.startsWith("https://");
|
||||
|
||||
await registerAuthRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
feishuAppId: config.feishuAppId,
|
||||
feishuAppSecret: config.feishuAppSecret,
|
||||
cookieSecure,
|
||||
...(config.oauthScope !== undefined ? { oauthScope: config.oauthScope } : {}),
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
});
|
||||
|
||||
await registerOrgRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
projectWorkspaceRoot: config.projectWorkspaceRoot,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PermissionRole, PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
grantTeamProjectAccess,
|
||||
listProjectTeamAccess,
|
||||
revokeTeamProjectAccess,
|
||||
} from "../../permissions/projectTeamAccess.js";
|
||||
import { requireOrgProject, requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
const ROLES: readonly PermissionRole[] = ["READ", "EDIT", "MANAGE"];
|
||||
|
||||
export async function registerAccessRoutes(
|
||||
app: FastifyInstance,
|
||||
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
|
||||
app.get("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
await requireOrgProject(guardDeps, auth.organization.id, projectId);
|
||||
return { access: await listProjectTeamAccess(config.prisma, projectId) };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
await requireOrgProject(guardDeps, auth.organization.id, projectId);
|
||||
const body = request.body as {
|
||||
teamId?: unknown;
|
||||
teamSlug?: unknown;
|
||||
role?: unknown;
|
||||
};
|
||||
const role = parseRole(body.role);
|
||||
if (role === null) {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "role must be READ|EDIT|MANAGE" },
|
||||
});
|
||||
}
|
||||
const entry = await grantTeamProjectAccess(config.prisma, {
|
||||
projectId,
|
||||
role,
|
||||
createdByUserId: auth.user.id,
|
||||
...(typeof body.teamId === "string" ? { teamId: body.teamId } : {}),
|
||||
...(typeof body.teamSlug === "string" ? { teamSlug: body.teamSlug } : {}),
|
||||
});
|
||||
return entry;
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete(
|
||||
"/api/org/:orgSlug/projects/:projectId/team-access/:teamId",
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId, teamId } = request.params as {
|
||||
orgSlug: string;
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
};
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
await requireOrgProject(guardDeps, auth.organization.id, projectId);
|
||||
const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId });
|
||||
return { revoked: count };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function parseRole(value: unknown): PermissionRole | null {
|
||||
if (typeof value !== "string") return null;
|
||||
return (ROLES as readonly string[]).includes(value) ? (value as PermissionRole) : null;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Feishu OAuth + session routes for org admin web login.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
DEFAULT_OAUTH_SCOPE,
|
||||
exchangeCodeForUser,
|
||||
type FeishuOAuthConfig,
|
||||
} from "../auth/feishuOAuth.js";
|
||||
import {
|
||||
HttpError,
|
||||
requireSession,
|
||||
type GuardDeps,
|
||||
} from "../auth/guards.js";
|
||||
import {
|
||||
OAUTH_STATE_COOKIE_NAME,
|
||||
SESSION_COOKIE_NAME,
|
||||
signOAuthState,
|
||||
signSession,
|
||||
verifyOAuthState,
|
||||
} from "../auth/session.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
export interface AuthRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
readonly publicBaseUrl: string;
|
||||
readonly feishuAppId: string;
|
||||
readonly feishuAppSecret: string;
|
||||
readonly oauthScope?: string;
|
||||
readonly cookieSecure: boolean;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app: FastifyInstance, config: AuthRouteConfig): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
const redirectUri = `${trimTrailingSlash(config.publicBaseUrl)}/auth/feishu/callback`;
|
||||
const oauthConfig: FeishuOAuthConfig = {
|
||||
appId: config.feishuAppId,
|
||||
appSecret: config.feishuAppSecret,
|
||||
redirectUri,
|
||||
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
};
|
||||
|
||||
app.get("/auth/feishu", async (request, reply) => {
|
||||
try {
|
||||
const returnTo = sanitizeReturnTo(
|
||||
typeof request.query === "object" && request.query !== null && "returnTo" in request.query
|
||||
? String((request.query as { returnTo?: string }).returnTo ?? "")
|
||||
: "",
|
||||
);
|
||||
const nonce = randomBytes(16).toString("hex");
|
||||
const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret);
|
||||
// state query param is the signed blob (CSRF + returnTo). Cookie mirrors nonce for double-submit.
|
||||
reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.cookieSecure,
|
||||
maxAge: 600,
|
||||
});
|
||||
const url = buildAuthorizeUrl(oauthConfig, stateToken);
|
||||
return reply.redirect(url);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/auth/feishu/callback", async (request, reply) => {
|
||||
try {
|
||||
const query = request.query as {
|
||||
code?: string;
|
||||
state?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (typeof query.error === "string" && query.error !== "") {
|
||||
return reply.redirect(`/admin/login?error=${encodeURIComponent(query.error)}`);
|
||||
}
|
||||
const code = query.code;
|
||||
const state = query.state;
|
||||
if (typeof code !== "string" || code === "" || typeof state !== "string" || state === "") {
|
||||
throw new HttpError(400, "bad_request", "missing OAuth code or state");
|
||||
}
|
||||
|
||||
const statePayload = verifyOAuthState(state, config.sessionSecret);
|
||||
if (statePayload === null) {
|
||||
throw new HttpError(400, "bad_request", "invalid or expired OAuth state");
|
||||
}
|
||||
const cookieNonce = request.cookies[OAUTH_STATE_COOKIE_NAME];
|
||||
if (cookieNonce === undefined || cookieNonce !== statePayload.nonce) {
|
||||
throw new HttpError(400, "bad_request", "OAuth state mismatch");
|
||||
}
|
||||
reply.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/" });
|
||||
|
||||
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
|
||||
const user = await config.prisma.user.upsert({
|
||||
where: { feishuOpenId: feishuUser.openId },
|
||||
create: {
|
||||
feishuOpenId: feishuUser.openId,
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
update: {
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
});
|
||||
|
||||
setSessionCookie(reply, config, {
|
||||
userId: user.id,
|
||||
feishuOpenId: user.feishuOpenId,
|
||||
});
|
||||
|
||||
const destination = await resolvePostLoginRedirect(
|
||||
config.prisma,
|
||||
user.id,
|
||||
statePayload.returnTo,
|
||||
);
|
||||
return reply.redirect(destination);
|
||||
} catch (err) {
|
||||
request.log.error({ err }, "feishu oauth callback failed");
|
||||
return reply.redirect(`/admin/login?error=${encodeURIComponent("oauth_failed")}`);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/auth/logout", async (_request, reply) => {
|
||||
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.get("/api/me", async (request, reply) => {
|
||||
try {
|
||||
const auth = await requireSession(request, reply, guardDeps);
|
||||
if (auth === null) return;
|
||||
|
||||
const memberships = await config.prisma.organizationMembership.findMany({
|
||||
where: { userId: auth.user.id, revokedAt: null },
|
||||
select: {
|
||||
role: true,
|
||||
organization: {
|
||||
select: { id: true, slug: true, name: true, status: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: auth.user.id,
|
||||
feishuOpenId: auth.user.feishuOpenId,
|
||||
displayName: auth.user.displayName,
|
||||
avatarUrl: auth.user.avatarUrl,
|
||||
},
|
||||
organizations: memberships.map((m) => ({
|
||||
id: m.organization.id,
|
||||
slug: m.organization.slug,
|
||||
name: m.organization.name,
|
||||
status: m.organization.status,
|
||||
role: m.role,
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Minimal login page until admin SPA lands (PR7).
|
||||
app.get("/admin/login", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const q = request.query as { error?: string; returnTo?: string };
|
||||
const returnTo = sanitizeReturnTo(q.returnTo ?? "");
|
||||
const loginHref =
|
||||
returnTo === "/admin"
|
||||
? "/auth/feishu"
|
||||
: `/auth/feishu?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
const errorHtml =
|
||||
typeof q.error === "string" && q.error !== ""
|
||||
? `<p class="err">Login failed: ${escapeHtml(q.error)}</p>`
|
||||
: "";
|
||||
const html = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>CPH Org Admin — Login</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#f6f7f9;color:#1a1a1a}
|
||||
.card{background:#fff;padding:2rem 2.5rem;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);max-width:22rem;text-align:center}
|
||||
a.btn{display:inline-block;margin-top:1rem;padding:.7rem 1.2rem;background:#3370ff;color:#fff;border-radius:8px;text-decoration:none;font-weight:600}
|
||||
a.btn:hover{background:#245bdb}
|
||||
.err{color:#c45656;font-size:.9rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Org Admin</h1>
|
||||
<p>Sign in with Feishu to manage your organization.</p>
|
||||
${errorHtml}
|
||||
<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
return reply.type("text/html").send(html);
|
||||
});
|
||||
}
|
||||
|
||||
export function setSessionCookie(
|
||||
reply: FastifyReply,
|
||||
config: Pick<AuthRouteConfig, "sessionSecret" | "cookieSecure">,
|
||||
identity: { readonly userId: string; readonly feishuOpenId: string },
|
||||
): void {
|
||||
const token = signSession(identity, config.sessionSecret);
|
||||
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.cookieSecure,
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
}
|
||||
|
||||
/** Exported for tests that need a pre-authenticated cookie value. */
|
||||
export function mintSessionToken(
|
||||
identity: { readonly userId: string; readonly feishuOpenId: string },
|
||||
sessionSecret: string,
|
||||
): string {
|
||||
return signSession(identity, sessionSecret);
|
||||
}
|
||||
|
||||
export function sessionCookieHeader(token: string): string {
|
||||
return `${SESSION_COOKIE_NAME}=${token}`;
|
||||
}
|
||||
|
||||
async function resolvePostLoginRedirect(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
returnTo: string,
|
||||
): Promise<string> {
|
||||
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
|
||||
return returnTo;
|
||||
}
|
||||
const membership = await prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
role: { in: ["OWNER", "ADMIN"] },
|
||||
organization: { status: "ACTIVE" },
|
||||
},
|
||||
select: { organization: { select: { slug: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (membership !== null) {
|
||||
return `/admin/org/${membership.organization.slug}`;
|
||||
}
|
||||
// Member-only or no org: still land on a shell page (SPA will explain).
|
||||
const any = await prisma.organizationMembership.findFirst({
|
||||
where: { userId, revokedAt: null, organization: { status: "ACTIVE" } },
|
||||
select: { organization: { select: { slug: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (any !== null) {
|
||||
return `/admin/org/${any.organization.slug}`;
|
||||
}
|
||||
return "/admin/login?error=no_organization";
|
||||
}
|
||||
|
||||
/**
|
||||
* Only allow relative same-origin paths under /admin to avoid open redirects.
|
||||
*/
|
||||
export function sanitizeReturnTo(raw: string): string {
|
||||
if (raw === "" || !raw.startsWith("/") || raw.startsWith("//") || raw.includes("://")) {
|
||||
return "/admin";
|
||||
}
|
||||
if (!raw.startsWith("/admin")) {
|
||||
return "/admin";
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(url: string): string {
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Org explorer HTTP routes under `/api/org/:orgSlug`.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
archiveFolder,
|
||||
archiveOrgProjectChatBinding,
|
||||
archiveProject,
|
||||
createOrgFolder,
|
||||
createOrgProject,
|
||||
getOrgProjectDetail,
|
||||
listOrgExplorer,
|
||||
moveOrgProjectToFolder,
|
||||
renameFolder,
|
||||
renameProject,
|
||||
} from "../../org/explorer.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
export interface ExplorerRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
readonly projectWorkspaceRoot: string;
|
||||
}
|
||||
|
||||
export async function registerExplorerRoutes(
|
||||
app: FastifyInstance,
|
||||
config: ExplorerRouteConfig,
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
|
||||
app.get("/api/org/:orgSlug/explorer", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await listOrgExplorer(config.prisma, auth.organization.id);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/folders", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as {
|
||||
name?: unknown;
|
||||
parentId?: unknown;
|
||||
sortKey?: unknown;
|
||||
};
|
||||
if (typeof body.name !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "name is required" },
|
||||
});
|
||||
}
|
||||
const folder = await createOrgFolder(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
name: body.name,
|
||||
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
|
||||
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
|
||||
});
|
||||
return reply.status(201).send({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
sortKey: folder.sortKey,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/folders/:folderId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as {
|
||||
name?: unknown;
|
||||
sortKey?: unknown;
|
||||
parentId?: unknown;
|
||||
};
|
||||
const folder = await renameFolder(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
folderId,
|
||||
...(typeof body.name === "string" ? { name: body.name } : {}),
|
||||
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
|
||||
...(body.parentId === null || typeof body.parentId === "string"
|
||||
? { parentId: body.parentId as string | null }
|
||||
: {}),
|
||||
});
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
sortKey: folder.sortKey,
|
||||
};
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/folders/:folderId/archive", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await archiveFolder(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
folderId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/projects", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { name?: unknown; folderId?: unknown };
|
||||
if (typeof body.name !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "name is required" },
|
||||
});
|
||||
}
|
||||
const result = await createOrgProject(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
actorFeishuOpenId: auth.user.feishuOpenId,
|
||||
name: body.name,
|
||||
workspaceRoot: config.projectWorkspaceRoot,
|
||||
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
||||
});
|
||||
return reply.status(201).send(result);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await getOrgProjectDetail(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
projectId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { name?: unknown };
|
||||
if (typeof body.name !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "name is required" },
|
||||
});
|
||||
}
|
||||
return await renameProject(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
projectId,
|
||||
name: body.name,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/projects/:projectId/folder", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { folderId?: unknown };
|
||||
if (body.folderId !== null && typeof body.folderId !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "folderId must be string or null" },
|
||||
});
|
||||
}
|
||||
return await moveOrgProjectToFolder(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
projectId,
|
||||
folderId: body.folderId as string | null,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/projects/:projectId/archive", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await archiveProject(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
projectId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/projects/:projectId/binding/archive", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await archiveOrgProjectChatBinding(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
projectId,
|
||||
actorFeishuOpenId: auth.user.feishuOpenId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
addOrgMember,
|
||||
listOrgMembers,
|
||||
revokeOrgMember,
|
||||
setOrgMemberRole,
|
||||
} from "../../org/members.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
const ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN", "MEMBER"];
|
||||
|
||||
export async function registerMembersRoutes(
|
||||
app: FastifyInstance,
|
||||
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
|
||||
app.get("/api/org/:orgSlug/members", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return { members: await listOrgMembers(config.prisma, auth.organization.id) };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/members", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as {
|
||||
feishuOpenId?: unknown;
|
||||
displayName?: unknown;
|
||||
role?: unknown;
|
||||
};
|
||||
if (typeof body.feishuOpenId !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "feishuOpenId is required" },
|
||||
});
|
||||
}
|
||||
const role = parseRole(body.role) ?? "MEMBER";
|
||||
const member = await addOrgMember(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
actorRole: auth.membershipRole,
|
||||
feishuOpenId: body.feishuOpenId,
|
||||
role,
|
||||
...(typeof body.displayName === "string" ? { displayName: body.displayName } : {}),
|
||||
});
|
||||
return reply.status(201).send(member);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/members/:userId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, userId } = request.params as { orgSlug: string; userId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { role?: unknown };
|
||||
const role = parseRole(body.role);
|
||||
if (role === null) {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "role must be OWNER|ADMIN|MEMBER" },
|
||||
});
|
||||
}
|
||||
return await setOrgMemberRole(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
actorUserId: auth.user.id,
|
||||
actorRole: auth.membershipRole,
|
||||
targetUserId: userId,
|
||||
role,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/members/:userId/revoke", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, userId } = request.params as { orgSlug: string; userId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await revokeOrgMember(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
actorUserId: auth.user.id,
|
||||
actorRole: auth.membershipRole,
|
||||
targetUserId: userId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseRole(value: unknown): OrganizationMemberRole | null {
|
||||
if (typeof value !== "string") return null;
|
||||
return (ROLES as readonly string[]).includes(value) ? (value as OrganizationMemberRole) : null;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Mount point for `/api/org/:orgSlug/*` org-admin APIs.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
import {
|
||||
ensureOrganizationProjectSettings,
|
||||
setMembersCanCreateProjects,
|
||||
} from "../../projectOnboarding.js";
|
||||
import { registerAccessRoutes } from "./accessRoutes.js";
|
||||
import { registerExplorerRoutes } from "./explorerRoutes.js";
|
||||
import { registerMembersRoutes } from "./membersRoutes.js";
|
||||
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||
|
||||
export interface OrgRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
readonly projectWorkspaceRoot: string;
|
||||
}
|
||||
|
||||
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
|
||||
app.get("/api/org/:orgSlug", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return {
|
||||
organization: {
|
||||
id: auth.organization.id,
|
||||
slug: auth.organization.slug,
|
||||
name: auth.organization.name,
|
||||
status: auth.organization.status,
|
||||
},
|
||||
actorRole: auth.membershipRole,
|
||||
};
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/settings", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const settings = await ensureOrganizationProjectSettings(config.prisma, auth.organization.id);
|
||||
return {
|
||||
membersCanCreateProjects: settings.membersCanCreateProjects,
|
||||
};
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/settings", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { membersCanCreateProjects?: unknown };
|
||||
if (typeof body.membersCanCreateProjects !== "boolean") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "membersCanCreateProjects must be a boolean" },
|
||||
});
|
||||
}
|
||||
const settings = await setMembersCanCreateProjects(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
enabled: body.membersCanCreateProjects,
|
||||
});
|
||||
return {
|
||||
membersCanCreateProjects: settings.membersCanCreateProjects,
|
||||
};
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
await registerExplorerRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
projectWorkspaceRoot: config.projectWorkspaceRoot,
|
||||
});
|
||||
await registerMembersRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerTeamsRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerAccessRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerSessionsAndUsageRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { getSessionDetail, listProjectSessions } from "../../org/sessions.js";
|
||||
import { getOrgUsage, getProjectUsage } from "../../org/usage.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
export async function registerSessionsAndUsageRoutes(
|
||||
app: FastifyInstance,
|
||||
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
|
||||
app.get("/api/org/:orgSlug/projects/:projectId/sessions", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const q = request.query as { limit?: string };
|
||||
const limit = q.limit !== undefined ? Number(q.limit) : undefined;
|
||||
return {
|
||||
sessions: await listProjectSessions(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
projectId,
|
||||
...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}),
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/sessions/:sessionId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, sessionId } = request.params as { orgSlug: string; sessionId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await getSessionDetail(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/usage", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const q = request.query as { from?: string; to?: string; folderId?: string };
|
||||
return await getOrgUsage(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
...(q.from !== undefined && q.from !== "" ? { from: new Date(q.from) } : {}),
|
||||
...(q.to !== undefined && q.to !== "" ? { to: new Date(q.to) } : {}),
|
||||
...(q.folderId !== undefined && q.folderId !== "" ? { folderId: q.folderId } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/projects/:projectId/usage", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const q = request.query as { from?: string; to?: string };
|
||||
return await getProjectUsage(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
projectId,
|
||||
...(q.from !== undefined && q.from !== "" ? { from: new Date(q.from) } : {}),
|
||||
...(q.to !== undefined && q.to !== "" ? { to: new Date(q.to) } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
addTeamMember,
|
||||
archiveTeam,
|
||||
createTeam,
|
||||
listOrgTeams,
|
||||
listTeamMembers,
|
||||
revokeTeamMember,
|
||||
updateTeam,
|
||||
} from "../../org/teams.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
export async function registerTeamsRoutes(
|
||||
app: FastifyInstance,
|
||||
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
|
||||
app.get("/api/org/:orgSlug/teams", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return { teams: await listOrgTeams(config.prisma, auth.organization.id) };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/teams", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { slug?: unknown; name?: unknown; description?: unknown };
|
||||
if (typeof body.slug !== "string" || typeof body.name !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "slug and name are required" },
|
||||
});
|
||||
}
|
||||
const team = await createTeam(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
slug: body.slug,
|
||||
name: body.name,
|
||||
...(typeof body.description === "string" ? { description: body.description } : {}),
|
||||
});
|
||||
return reply.status(201).send(team);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/teams/:teamId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { name?: unknown; description?: unknown };
|
||||
return await updateTeam(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
teamId,
|
||||
...(typeof body.name === "string" ? { name: body.name } : {}),
|
||||
...(body.description === null || typeof body.description === "string"
|
||||
? { description: body.description as string | null }
|
||||
: {}),
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/teams/:teamId/archive", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await archiveTeam(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
teamId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/teams/:teamId/members", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return {
|
||||
members: await listTeamMembers(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
teamId,
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/teams/:teamId/members", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { userId?: unknown; feishuOpenId?: unknown };
|
||||
const member = await addTeamMember(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
teamId,
|
||||
...(typeof body.userId === "string" ? { userId: body.userId } : {}),
|
||||
...(typeof body.feishuOpenId === "string" ? { feishuOpenId: body.feishuOpenId } : {}),
|
||||
});
|
||||
return reply.status(201).send(member);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/teams/:teamId/members/:userId/revoke", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, teamId, userId } = request.params as {
|
||||
orgSlug: string;
|
||||
teamId: string;
|
||||
userId: string;
|
||||
};
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await revokeTeamMember(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
export interface OnboardingProjectOption {
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
readonly folderName?: string | undefined;
|
||||
}
|
||||
|
||||
export interface OnboardingFolderOption {
|
||||
readonly folderId: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface ProjectOnboardingActionValue {
|
||||
readonly action: "create_project_from_chat" | "bind_project";
|
||||
readonly organization_id: string;
|
||||
readonly project_id?: string | undefined;
|
||||
readonly folder_id?: string | undefined;
|
||||
}
|
||||
|
||||
export function buildUnboundChatOnboardingCard(params: {
|
||||
readonly organizationId: string;
|
||||
readonly organizationName: string;
|
||||
readonly folders: readonly OnboardingFolderOption[];
|
||||
readonly projects: readonly OnboardingProjectOption[];
|
||||
readonly canCreateProject: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const actions: unknown[] = [];
|
||||
if (params.canCreateProject) {
|
||||
const folders = params.folders.length === 0 ? [{ folderId: undefined, name: "默认位置" }] : params.folders.slice(0, 3);
|
||||
for (const folder of folders) {
|
||||
actions.push({
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: `新建到 ${buttonLabel(folder.name, 14)}` },
|
||||
type: "primary",
|
||||
value: {
|
||||
project_onboarding: {
|
||||
action: "create_project_from_chat",
|
||||
organization_id: params.organizationId,
|
||||
...(folder.folderId !== undefined ? { folder_id: folder.folderId } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const project of params.projects.slice(0, 5)) {
|
||||
actions.push({
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: buttonProjectLabel(project) },
|
||||
type: "default",
|
||||
value: {
|
||||
project_onboarding: {
|
||||
action: "bind_project",
|
||||
organization_id: params.organizationId,
|
||||
project_id: project.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const elements: unknown[] = [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: [
|
||||
`这个飞书群还没有绑定项目。`,
|
||||
``,
|
||||
`组织: **${escapeMarkdown(params.organizationName)}**`,
|
||||
`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`,
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
|
||||
if (actions.length > 0) {
|
||||
elements.push({ tag: "action", actions });
|
||||
} else {
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: "你当前没有可绑定项目,也没有新建项目权限。请联系组织管理员。",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
title: { tag: "plain_text", content: "绑定项目" },
|
||||
template: "blue",
|
||||
},
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProjectOnboardingResolvedCard(params: {
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly template: "green" | "red";
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
title: { tag: "plain_text", content: params.title },
|
||||
template: params.template,
|
||||
},
|
||||
elements: [{ tag: "markdown", content: params.body }],
|
||||
};
|
||||
}
|
||||
|
||||
export function projectOnboardingActionFromValue(value: unknown): ProjectOnboardingActionValue | null {
|
||||
const raw = unwrapValue(value);
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
if (!("project_onboarding" in raw)) return null;
|
||||
const action = raw.project_onboarding;
|
||||
if (typeof action !== "object" || action === null || Array.isArray(action)) return null;
|
||||
const rawAction = (action as { action?: unknown }).action;
|
||||
const organizationId = (action as { organization_id?: unknown }).organization_id;
|
||||
const projectId = (action as { project_id?: unknown }).project_id;
|
||||
const folderId = (action as { folder_id?: unknown }).folder_id;
|
||||
if ((rawAction !== "create_project_from_chat" && rawAction !== "bind_project") || typeof organizationId !== "string" || organizationId === "") {
|
||||
return null;
|
||||
}
|
||||
if (rawAction === "bind_project" && (typeof projectId !== "string" || projectId === "")) {
|
||||
return null;
|
||||
}
|
||||
if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
action: rawAction,
|
||||
organization_id: organizationId,
|
||||
...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}),
|
||||
...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function unwrapValue(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function buttonProjectLabel(project: OnboardingProjectOption): string {
|
||||
const folderPrefix = project.folderName === undefined ? "" : `${project.folderName} / `;
|
||||
const label = `${folderPrefix}${project.name}`;
|
||||
return buttonLabel(label, 24);
|
||||
}
|
||||
|
||||
function buttonLabel(label: string, maxLength: number): string {
|
||||
return label.length <= maxLength ? label : `${label.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function escapeMarkdown(value: string): string {
|
||||
return value.replace(/([`*_{}[\]<>])/g, "\\$1");
|
||||
}
|
||||
+252
-4
@@ -15,6 +15,8 @@ import type { FastifyBaseLogger } from "fastify";
|
||||
import {
|
||||
sendText,
|
||||
sendTextMessage,
|
||||
sendCard,
|
||||
patchCard,
|
||||
reactToMessage,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
@@ -42,6 +44,19 @@ import { SenderNameCache } from "./senderCache.js";
|
||||
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
|
||||
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
|
||||
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
|
||||
import {
|
||||
bindFeishuChatToProject,
|
||||
createProjectFromFeishuChat,
|
||||
ensureOrganizationProjectSettings,
|
||||
} from "../projectOnboarding.js";
|
||||
import {
|
||||
buildProjectOnboardingResolvedCard,
|
||||
buildUnboundChatOnboardingCard,
|
||||
projectOnboardingActionFromValue,
|
||||
type OnboardingFolderOption,
|
||||
type OnboardingProjectOption,
|
||||
type ProjectOnboardingActionValue,
|
||||
} from "./projectOnboardingCard.js";
|
||||
|
||||
export { ApprovalManager } from "./approval.js";
|
||||
export type { ApprovalResult, PendingApproval } from "./approval.js";
|
||||
@@ -57,6 +72,7 @@ interface TriggerDeps {
|
||||
readonly authorizer?: PermissionAuthorizer | undefined;
|
||||
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
|
||||
readonly triggerQueue?: TriggerQueue | undefined;
|
||||
readonly projectWorkspaceRoot?: string | undefined;
|
||||
}
|
||||
|
||||
interface TriggerActor {
|
||||
@@ -521,6 +537,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Project onboarding buttons for unbound Feishu chats.
|
||||
const onboardingAction = projectOnboardingActionFromValue(event.action.value);
|
||||
if (onboardingAction !== null) {
|
||||
await handleProjectOnboardingAction(event, rt, onboardingAction);
|
||||
return;
|
||||
}
|
||||
|
||||
// Interrupt button on a live run card.
|
||||
const runId = interruptRunFromValue(event.action.value);
|
||||
if (runId !== null) {
|
||||
@@ -528,6 +551,78 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
}
|
||||
};
|
||||
|
||||
async function handleProjectOnboardingAction(
|
||||
event: CardActionEvent,
|
||||
rt: FeishuRuntime,
|
||||
action: ProjectOnboardingActionValue,
|
||||
): Promise<void> {
|
||||
const chatId = nonEmpty(event.context?.open_chat_id);
|
||||
const messageId = nonEmpty(event.context?.open_message_id);
|
||||
const operatorOpenId = nonEmpty(event.operator.open_id);
|
||||
if (chatId === undefined || operatorOpenId === undefined) {
|
||||
deps.logger.warn({ action }, "project onboarding: card action missing chat id or operator open_id");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (action.action === "create_project_from_chat") {
|
||||
if (deps.projectWorkspaceRoot === undefined || deps.projectWorkspaceRoot.trim() === "") {
|
||||
throw new Error("HUB_PROJECT_WORKSPACE_ROOT is required for Feishu project creation");
|
||||
}
|
||||
const name = defaultProjectNameForChat(chatId);
|
||||
const project = await createProjectFromFeishuChat(deps.prisma, {
|
||||
organizationId: action.organization_id,
|
||||
actorFeishuOpenId: operatorOpenId,
|
||||
chatId,
|
||||
name,
|
||||
workspaceRoot: deps.projectWorkspaceRoot,
|
||||
folderId: action.folder_id,
|
||||
});
|
||||
await resolveProjectOnboardingCard(rt, messageId, {
|
||||
title: "已创建并绑定项目",
|
||||
body: `项目 **${escapeCardMarkdown(name)}** 已绑定到本群。后续 @bot 会进入这个项目的 session。`,
|
||||
template: "green",
|
||||
});
|
||||
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat");
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = action.project_id;
|
||||
if (projectId === undefined) {
|
||||
throw new Error("bind_project action requires project_id");
|
||||
}
|
||||
const project = await bindFeishuChatToProject(deps.prisma, {
|
||||
projectId,
|
||||
actorFeishuOpenId: operatorOpenId,
|
||||
chatId,
|
||||
});
|
||||
await resolveProjectOnboardingCard(rt, messageId, {
|
||||
title: "已绑定项目",
|
||||
body: `本群已绑定到项目 \`${project.projectId}\`。后续 @bot 会进入这个项目的 session。`,
|
||||
template: "green",
|
||||
});
|
||||
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project");
|
||||
} catch (e) {
|
||||
const reason = e instanceof Error ? e.message : String(e);
|
||||
deps.logger.warn({ chatId, operatorOpenId, action, err: reason }, "project onboarding: action failed");
|
||||
await resolveProjectOnboardingCard(rt, messageId, {
|
||||
title: "绑定失败",
|
||||
body: reason,
|
||||
template: "red",
|
||||
});
|
||||
await sendText(rt, chatId, `项目绑定失败: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveProjectOnboardingCard(
|
||||
rt: FeishuRuntime,
|
||||
messageId: string | undefined,
|
||||
params: { readonly title: string; readonly body: string; readonly template: "green" | "red" },
|
||||
): Promise<void> {
|
||||
if (messageId === undefined) return;
|
||||
await patchCard(rt, messageId, buildProjectOnboardingResolvedCard(params));
|
||||
}
|
||||
|
||||
/** Abort a live run after authorizing agent.cancel on its project. */
|
||||
async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise<void> {
|
||||
const chatId = nonEmpty(event.context?.open_chat_id);
|
||||
@@ -535,8 +630,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,12 +713,13 @@ 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) {
|
||||
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
|
||||
await sendUnboundChatOnboardingCard(event, rt);
|
||||
return;
|
||||
}
|
||||
const projectId = binding.projectId;
|
||||
@@ -743,6 +839,137 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await startAgentRun(runContext, cleanPrompt);
|
||||
};
|
||||
|
||||
async function sendUnboundChatOnboardingCard(event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> {
|
||||
const msg = event.message;
|
||||
if (!messageMentionsBot(msg)) return;
|
||||
const chatId = msg.chat_id;
|
||||
const senderOpenId = event.sender.sender_id.open_id ?? "";
|
||||
if (senderOpenId === "") {
|
||||
await sendText(rt, chatId, "无法识别发送者,请先用可识别的飞书用户身份操作。", sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
const organization = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId);
|
||||
if (organization.status !== "ok") {
|
||||
await sendText(rt, chatId, organization.message, sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
|
||||
const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role);
|
||||
const projects = await listBindableProjectsForActor({
|
||||
organizationId: organization.organizationId,
|
||||
actorFeishuOpenId: senderOpenId,
|
||||
isOrgAdmin: isOrgAdminRole(organization.role),
|
||||
});
|
||||
const folders = await listCreatableRootFolders(organization.organizationId);
|
||||
|
||||
await sendCard(
|
||||
rt,
|
||||
chatId,
|
||||
buildUnboundChatOnboardingCard({
|
||||
organizationId: organization.organizationId,
|
||||
organizationName: organization.organizationName,
|
||||
folders,
|
||||
projects,
|
||||
canCreateProject,
|
||||
}),
|
||||
sendOptionsForTriggerMessage(msg),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveSingleActiveOrganizationForFeishuUser(feishuOpenId: string): Promise<
|
||||
| {
|
||||
readonly status: "ok";
|
||||
readonly organizationId: string;
|
||||
readonly organizationName: string;
|
||||
readonly role: "OWNER" | "ADMIN" | "MEMBER";
|
||||
}
|
||||
| { readonly status: "error"; readonly message: string }
|
||||
> {
|
||||
const user = await deps.prisma.user.findUnique({
|
||||
where: { feishuOpenId },
|
||||
select: {
|
||||
organizationMemberships: {
|
||||
where: {
|
||||
revokedAt: null,
|
||||
organization: { status: "ACTIVE" },
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
organization: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (user === null) {
|
||||
return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
|
||||
}
|
||||
if (user.organizationMemberships.length === 0) {
|
||||
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" };
|
||||
}
|
||||
if (user.organizationMemberships.length > 1) {
|
||||
return { status: "error", message: "你属于多个组织。请先从组织后台选择项目绑定,或等待多组织选择入口。" };
|
||||
}
|
||||
const membership = user.organizationMemberships[0]!;
|
||||
return {
|
||||
status: "ok",
|
||||
organizationId: membership.organization.id,
|
||||
organizationName: membership.organization.name,
|
||||
role: membership.role,
|
||||
};
|
||||
}
|
||||
|
||||
async function listBindableProjectsForActor(input: {
|
||||
readonly organizationId: string;
|
||||
readonly actorFeishuOpenId: string;
|
||||
readonly isOrgAdmin: boolean;
|
||||
}): Promise<readonly OnboardingProjectOption[]> {
|
||||
const candidates = await deps.prisma.project.findMany({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
archivedAt: null,
|
||||
groupBindings: { none: { archivedAt: null } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
folder: { select: { name: true } },
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
const allowed: OnboardingProjectOption[] = [];
|
||||
for (const project of candidates) {
|
||||
if (!input.isOrgAdmin) {
|
||||
const decision = await authorizer.can({
|
||||
actor: { feishuOpenId: input.actorFeishuOpenId },
|
||||
action: "collaborator.manage",
|
||||
resource: { type: "PROJECT", id: project.id },
|
||||
});
|
||||
if (!decision.allowed) continue;
|
||||
}
|
||||
allowed.push({
|
||||
projectId: project.id,
|
||||
name: project.name,
|
||||
...(project.folder?.name !== undefined ? { folderName: project.folder.name } : {}),
|
||||
});
|
||||
if (allowed.length >= 5) break;
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
async function listCreatableRootFolders(organizationId: string): Promise<readonly OnboardingFolderOption[]> {
|
||||
const folders = await deps.prisma.folder.findMany({
|
||||
where: { organizationId, parentId: null, archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
orderBy: [{ sortKey: "asc" }, { name: "asc" }],
|
||||
take: 3,
|
||||
});
|
||||
return folders.map((folder) => ({ folderId: folder.id, name: folder.name }));
|
||||
}
|
||||
|
||||
return Object.assign(onMessage, { onCardAction, approvalManager });
|
||||
}
|
||||
|
||||
@@ -793,6 +1020,10 @@ function nonEmpty(value: string | undefined): string | undefined {
|
||||
return value === undefined || value === "" ? undefined : value;
|
||||
}
|
||||
|
||||
function messageMentionsBot(msg: MessageReceiveEvent["message"]): boolean {
|
||||
return msg.mentions !== undefined && msg.mentions.length > 0;
|
||||
}
|
||||
|
||||
function sendOptionsForTriggerMessage(msg: MessageReceiveEvent["message"]): SendMessageOptions {
|
||||
return { replyToMessageId: msg.message_id };
|
||||
}
|
||||
@@ -974,6 +1205,23 @@ function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTool
|
||||
"Do not say a file is attached or sent unless that tool returns success.";
|
||||
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
|
||||
}
|
||||
|
||||
function isOrgAdminRole(role: "OWNER" | "ADMIN" | "MEMBER"): boolean {
|
||||
return role === "OWNER" || role === "ADMIN";
|
||||
}
|
||||
|
||||
function defaultProjectNameForChat(chatId: string): string {
|
||||
return `飞书群项目 ${shortId(chatId)}`;
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
return value.length <= 8 ? value : value.slice(-8);
|
||||
}
|
||||
|
||||
function escapeCardMarkdown(value: string): string {
|
||||
return value.replace(/([`*_{}[\]<>])/g, "\\$1");
|
||||
}
|
||||
|
||||
/** Lark text-message content: `{"text":"@_user_1 do something"}`. */
|
||||
const TextMessageContentSchema = z.object({ text: z.string() });
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Org project explorer (ADR-0021).
|
||||
*
|
||||
* Folders are transparent navigation nodes (not ACL resources). Project grants
|
||||
* stay on PROJECT. Archive folder refuses when active children/projects remain.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
archiveFeishuChatBinding,
|
||||
createFolder,
|
||||
createProjectFromOrgAdmin,
|
||||
moveProjectToFolder,
|
||||
} from "../projectOnboarding.js";
|
||||
|
||||
export interface ExplorerFolderNode {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly parentId: string | null;
|
||||
readonly sortKey: string;
|
||||
readonly projectCount: number;
|
||||
readonly childFolderCount: number;
|
||||
}
|
||||
|
||||
export interface ExplorerProjectNode {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly folderId: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly binding: { readonly chatId: string; readonly createdAt: string } | null;
|
||||
}
|
||||
|
||||
export interface OrgExplorer {
|
||||
readonly folders: readonly ExplorerFolderNode[];
|
||||
readonly projects: readonly ExplorerProjectNode[];
|
||||
}
|
||||
|
||||
export async function listOrgExplorer(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): Promise<OrgExplorer> {
|
||||
const [folders, projects] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: { organizationId, archivedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parentId: true,
|
||||
sortKey: true,
|
||||
_count: {
|
||||
select: {
|
||||
children: { where: { archivedAt: null } },
|
||||
projects: { where: { archivedAt: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sortKey: "asc" }, { name: "asc" }],
|
||||
}),
|
||||
prisma.project.findMany({
|
||||
where: { organizationId, archivedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
folderId: true,
|
||||
createdAt: true,
|
||||
groupBindings: {
|
||||
where: { archivedAt: null },
|
||||
select: { chatId: true, createdAt: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: [{ name: "asc" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
folders: folders.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
parentId: f.parentId,
|
||||
sortKey: f.sortKey,
|
||||
projectCount: f._count.projects,
|
||||
childFolderCount: f._count.children,
|
||||
})),
|
||||
projects: projects.map((p) => {
|
||||
const binding = p.groupBindings[0];
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
folderId: p.folderId,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
binding:
|
||||
binding === undefined
|
||||
? null
|
||||
: { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOrgFolder(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly name: string;
|
||||
readonly parentId?: string | undefined;
|
||||
readonly sortKey?: string | undefined;
|
||||
},
|
||||
) {
|
||||
return createFolder(prisma, input);
|
||||
}
|
||||
|
||||
export async function renameFolder(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly folderId: string;
|
||||
readonly name?: string | undefined;
|
||||
readonly sortKey?: string | undefined;
|
||||
readonly parentId?: string | null | undefined;
|
||||
},
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
|
||||
if (input.parentId !== undefined && input.parentId !== null) {
|
||||
if (input.parentId === folder.id) {
|
||||
throw new Error("folder cannot be its own parent");
|
||||
}
|
||||
await requireActiveFolder(tx, input.parentId, input.organizationId);
|
||||
}
|
||||
const name =
|
||||
input.name !== undefined ? requireNonEmpty(input.name, "folder name") : undefined;
|
||||
return tx.folder.update({
|
||||
where: { id: folder.id },
|
||||
data: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}),
|
||||
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-archive a folder. Refuses if it still has active child folders or projects.
|
||||
*/
|
||||
export async function archiveFolder(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly folderId: string },
|
||||
): Promise<{ readonly archived: true; readonly folderId: string }> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
|
||||
const childFolders = await tx.folder.count({
|
||||
where: { parentId: folder.id, archivedAt: null },
|
||||
});
|
||||
if (childFolders > 0) {
|
||||
throw new Error(
|
||||
`cannot archive folder ${folder.id}: still has ${childFolders} active child folder(s)`,
|
||||
);
|
||||
}
|
||||
const projects = await tx.project.count({
|
||||
where: { folderId: folder.id, archivedAt: null },
|
||||
});
|
||||
if (projects > 0) {
|
||||
throw new Error(
|
||||
`cannot archive folder ${folder.id}: still has ${projects} active project(s)`,
|
||||
);
|
||||
}
|
||||
await tx.folder.update({
|
||||
where: { id: folder.id },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
return { archived: true as const, folderId: folder.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrgProject(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly actorFeishuOpenId: string;
|
||||
readonly name: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly folderId?: string | undefined;
|
||||
},
|
||||
) {
|
||||
return createProjectFromOrgAdmin(prisma, input);
|
||||
}
|
||||
|
||||
export async function renameProject(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
},
|
||||
) {
|
||||
const name = requireNonEmpty(input.name, "project name");
|
||||
const project = await requireActiveProject(prisma, input.projectId, input.organizationId);
|
||||
return prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { name },
|
||||
select: { id: true, name: true, folderId: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveOrgProjectToFolder(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
readonly folderId: string | null;
|
||||
},
|
||||
) {
|
||||
await requireActiveProject(prisma, input.projectId, input.organizationId);
|
||||
return moveProjectToFolder(prisma, {
|
||||
projectId: input.projectId,
|
||||
folderId: input.folderId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveProject(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly projectId: string },
|
||||
): Promise<{ readonly archived: true; readonly projectId: string }> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const project = await requireActiveProject(tx, input.projectId, input.organizationId);
|
||||
const activeBinding = await tx.projectGroupBinding.findFirst({
|
||||
where: { projectId: project.id, archivedAt: null },
|
||||
select: { id: true, chatId: true },
|
||||
});
|
||||
const now = new Date();
|
||||
if (activeBinding !== null) {
|
||||
await tx.projectGroupBinding.update({
|
||||
where: { id: activeBinding.id },
|
||||
data: { archivedAt: now },
|
||||
});
|
||||
await tx.permissionGrant.updateMany({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: project.id,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: activeBinding.chatId,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
}
|
||||
await tx.project.update({
|
||||
where: { id: project.id },
|
||||
data: { archivedAt: now },
|
||||
});
|
||||
return { archived: true as const, projectId: project.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrgProjectDetail(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly projectId: string },
|
||||
) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
organizationId: input.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
folderId: true,
|
||||
workspaceDir: true,
|
||||
createdAt: true,
|
||||
archivedAt: true,
|
||||
createdBy: { select: { id: true, displayName: true, feishuOpenId: true } },
|
||||
folder: { select: { id: true, name: true } },
|
||||
groupBindings: {
|
||||
where: { archivedAt: null },
|
||||
select: { chatId: true, createdAt: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (project === null) {
|
||||
throw new Error(`project not found: ${input.projectId}`);
|
||||
}
|
||||
const binding = project.groupBindings[0];
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
folderId: project.folderId,
|
||||
folder: project.folder,
|
||||
workspaceDir: project.workspaceDir,
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
archivedAt: project.archivedAt?.toISOString() ?? null,
|
||||
createdBy: project.createdBy,
|
||||
binding:
|
||||
binding === undefined
|
||||
? null
|
||||
: { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() },
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveOrgProjectChatBinding(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
readonly actorFeishuOpenId: string;
|
||||
},
|
||||
) {
|
||||
await requireActiveProject(prisma, input.projectId, input.organizationId);
|
||||
return archiveFeishuChatBinding(prisma, {
|
||||
projectId: input.projectId,
|
||||
actorFeishuOpenId: input.actorFeishuOpenId,
|
||||
});
|
||||
}
|
||||
|
||||
async function requireActiveFolder(
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
folderId: string,
|
||||
organizationId: string,
|
||||
): Promise<{ readonly id: string; readonly organizationId: string }> {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: { id: folderId },
|
||||
select: { id: true, organizationId: true, archivedAt: true },
|
||||
});
|
||||
if (folder === null || folder.archivedAt !== null || folder.organizationId !== organizationId) {
|
||||
throw new Error(`active folder not found: ${folderId}`);
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
async function requireActiveProject(
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
projectId: string,
|
||||
organizationId: string,
|
||||
): Promise<{ readonly id: string; readonly organizationId: string }> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, organizationId: true, archivedAt: true },
|
||||
});
|
||||
if (project === null || project.archivedAt !== null || project.organizationId !== organizationId) {
|
||||
throw new Error(`active project not found: ${projectId}`);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
function requireNonEmpty(value: string, label: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") throw new Error(`${label} is required`);
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Organization membership management for org admin (ADR-0021).
|
||||
*
|
||||
* Role rules (product pin, not yet in Lean):
|
||||
* 1. Actor must be OWNER or ADMIN (enforced at HTTP layer).
|
||||
* 2. Only OWNER can grant/revoke OWNER or modify another OWNER.
|
||||
* 3. Cannot revoke or demote the last remaining OWNER.
|
||||
* 4. ADMIN cannot modify OWNER memberships.
|
||||
*/
|
||||
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
|
||||
|
||||
export interface OrgMemberRow {
|
||||
readonly userId: string;
|
||||
readonly feishuOpenId: string;
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl: string | null;
|
||||
readonly role: OrganizationMemberRole;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export async function listOrgMembers(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): Promise<readonly OrgMemberRow[]> {
|
||||
const rows = await prisma.organizationMembership.findMany({
|
||||
where: { organizationId, revokedAt: null },
|
||||
select: {
|
||||
role: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ role: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
userId: row.user.id,
|
||||
feishuOpenId: row.user.feishuOpenId,
|
||||
displayName: row.user.displayName,
|
||||
avatarUrl: row.user.avatarUrl,
|
||||
role: row.role,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addOrgMember(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly actorRole: OrganizationMemberRole;
|
||||
readonly feishuOpenId: string;
|
||||
readonly displayName?: string | undefined;
|
||||
readonly role: OrganizationMemberRole;
|
||||
},
|
||||
): Promise<OrgMemberRow> {
|
||||
const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId");
|
||||
assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { feishuOpenId: openId },
|
||||
create: {
|
||||
feishuOpenId: openId,
|
||||
displayName: input.displayName?.trim() || openId,
|
||||
},
|
||||
update: {
|
||||
...(input.displayName !== undefined && input.displayName.trim() !== ""
|
||||
? { displayName: input.displayName.trim() }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
const existing = await prisma.organizationMembership.findFirst({
|
||||
where: { organizationId: input.organizationId, userId: user.id, revokedAt: null },
|
||||
});
|
||||
if (existing !== null) {
|
||||
throw new Error(`user ${user.id} is already an active member`);
|
||||
}
|
||||
|
||||
const membership = await prisma.organizationMembership.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
userId: user.id,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
feishuOpenId: user.feishuOpenId,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: membership.role,
|
||||
createdAt: membership.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setOrgMemberRole(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId: string;
|
||||
readonly actorRole: OrganizationMemberRole;
|
||||
readonly targetUserId: string;
|
||||
readonly role: OrganizationMemberRole;
|
||||
},
|
||||
): Promise<OrgMemberRow> {
|
||||
const membership = await prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.targetUserId,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
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(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId: string;
|
||||
readonly actorRole: OrganizationMemberRole;
|
||||
readonly targetUserId: string;
|
||||
},
|
||||
): Promise<{ readonly revoked: true; readonly userId: string }> {
|
||||
const membership = await prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.targetUserId,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
throw new Error(`member not found: ${input.targetUserId}`);
|
||||
}
|
||||
if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
|
||||
throw new Error("cannot revoke OWNER membership as ADMIN");
|
||||
}
|
||||
if (membership.role === "OWNER") {
|
||||
await assertNotLastOwner(prisma, input.organizationId, input.targetUserId);
|
||||
}
|
||||
|
||||
await prisma.organizationMembership.update({
|
||||
where: { id: membership.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { revoked: true as const, userId: input.targetUserId };
|
||||
}
|
||||
|
||||
function assertCanAssignRole(
|
||||
actorRole: OrganizationMemberRole,
|
||||
newRole: OrganizationMemberRole,
|
||||
targetIsOwner: boolean,
|
||||
): void {
|
||||
if (newRole === "OWNER" && actorRole !== "OWNER") {
|
||||
throw new Error("only OWNER can grant OWNER role");
|
||||
}
|
||||
if (targetIsOwner && actorRole !== "OWNER") {
|
||||
throw new Error("cannot modify OWNER membership as ADMIN");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNotLastOwner(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
const owners = await prisma.organizationMembership.count({
|
||||
where: { organizationId, role: "OWNER", revokedAt: null },
|
||||
});
|
||||
if (owners <= 1) {
|
||||
throw new Error(`cannot revoke or demote last OWNER (${targetUserId})`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireNonEmpty(value: string, label: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") throw new Error(`${label} is required`);
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Agent session / run listing for org admin.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
export async function listProjectSessions(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
readonly limit?: number | undefined;
|
||||
},
|
||||
) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: input.projectId, organizationId: input.organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (project === null) {
|
||||
throw new Error(`project not found: ${input.projectId}`);
|
||||
}
|
||||
const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
|
||||
const sessions = await prisma.agentSession.findMany({
|
||||
where: { projectId: project.id, archivedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
provider: true,
|
||||
roleId: true,
|
||||
model: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { runs: true } },
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
return sessions.map((s) => ({
|
||||
id: s.id,
|
||||
provider: s.provider,
|
||||
roleId: s.roleId,
|
||||
model: s.model,
|
||||
title: s.title,
|
||||
runCount: s._count.runs,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
updatedAt: s.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSessionDetail(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly sessionId: string },
|
||||
) {
|
||||
const session = await prisma.agentSession.findUnique({
|
||||
where: { id: input.sessionId },
|
||||
select: {
|
||||
id: true,
|
||||
provider: true,
|
||||
roleId: true,
|
||||
model: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
archivedAt: true,
|
||||
project: {
|
||||
select: { id: true, name: true, organizationId: true },
|
||||
},
|
||||
runs: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
model: true,
|
||||
provider: true,
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
costUsd: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
error: true,
|
||||
},
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (session === null || session.project.organizationId !== input.organizationId) {
|
||||
throw new Error(`session not found: ${input.sessionId}`);
|
||||
}
|
||||
return {
|
||||
id: session.id,
|
||||
provider: session.provider,
|
||||
roleId: session.roleId,
|
||||
model: session.model,
|
||||
title: session.title,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString(),
|
||||
archivedAt: session.archivedAt?.toISOString() ?? null,
|
||||
project: {
|
||||
id: session.project.id,
|
||||
name: session.project.name,
|
||||
},
|
||||
runs: session.runs.map((r) => ({
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
model: r.model,
|
||||
provider: r.provider,
|
||||
inputTokens: r.inputTokens,
|
||||
outputTokens: r.outputTokens,
|
||||
costUsd: r.costUsd === null ? null : Number(r.costUsd),
|
||||
startedAt: r.startedAt.toISOString(),
|
||||
finishedAt: r.finishedAt?.toISOString() ?? null,
|
||||
error: r.error,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Hub team lifecycle for org admin (ADR-0019 / ADR-0021).
|
||||
*
|
||||
* Archiving a team soft-archives the team row and revokes active TEAM→PROJECT
|
||||
* grants that use this team as principal (product pin).
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
export interface TeamRow {
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly memberCount: number;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface TeamMemberRow {
|
||||
readonly userId: string;
|
||||
readonly feishuOpenId: string;
|
||||
readonly displayName: string;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export async function listOrgTeams(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): Promise<readonly TeamRow[]> {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: { organizationId, archivedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
_count: { select: { memberships: { where: { revokedAt: null } } } },
|
||||
},
|
||||
orderBy: { slug: "asc" },
|
||||
});
|
||||
return teams.map((t) => ({
|
||||
id: t.id,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
memberCount: t._count.memberships,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createTeam(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
readonly description?: string | undefined;
|
||||
},
|
||||
): Promise<TeamRow> {
|
||||
const slug = sanitizeSlug(input.slug);
|
||||
const name = requireNonEmpty(input.name, "name");
|
||||
const existing = await prisma.team.findFirst({
|
||||
where: { organizationId: input.organizationId, slug, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) {
|
||||
throw new Error(`team slug already exists: ${slug}`);
|
||||
}
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
slug,
|
||||
name,
|
||||
...(input.description !== undefined ? { description: input.description } : {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: team.id,
|
||||
slug: team.slug,
|
||||
name: team.name,
|
||||
description: team.description,
|
||||
memberCount: 0,
|
||||
createdAt: team.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateTeam(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly teamId: string;
|
||||
readonly name?: string | undefined;
|
||||
readonly description?: string | null | undefined;
|
||||
},
|
||||
): Promise<TeamRow> {
|
||||
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId);
|
||||
const updated = await prisma.team.update({
|
||||
where: { id: team.id },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}),
|
||||
...(input.description !== undefined ? { description: input.description } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
_count: { select: { memberships: { where: { revokedAt: null } } } },
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: updated.id,
|
||||
slug: updated.slug,
|
||||
name: updated.name,
|
||||
description: updated.description,
|
||||
memberCount: updated._count.memberships,
|
||||
createdAt: updated.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-archive team and revoke its active project grants (TEAM principal).
|
||||
*/
|
||||
export async function archiveTeam(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly teamId: string },
|
||||
): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
|
||||
const now = new Date();
|
||||
await tx.team.update({
|
||||
where: { id: team.id },
|
||||
data: { archivedAt: now },
|
||||
});
|
||||
await tx.teamMembership.updateMany({
|
||||
where: { teamId: team.id, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
const grants = await tx.permissionGrant.updateMany({
|
||||
where: {
|
||||
principalType: "TEAM",
|
||||
principalId: team.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
return {
|
||||
archived: true as const,
|
||||
teamId: team.id,
|
||||
revokedGrants: grants.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTeamMembers(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly teamId: string },
|
||||
): Promise<readonly TeamMemberRow[]> {
|
||||
await requireActiveTeam(prisma, input.teamId, input.organizationId);
|
||||
const rows = await prisma.teamMembership.findMany({
|
||||
where: { teamId: input.teamId, revokedAt: null },
|
||||
select: {
|
||||
createdAt: true,
|
||||
user: { select: { id: true, feishuOpenId: true, displayName: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
userId: row.user.id,
|
||||
feishuOpenId: row.user.feishuOpenId,
|
||||
displayName: row.user.displayName,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addTeamMember(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly teamId: string;
|
||||
readonly userId?: string | undefined;
|
||||
readonly feishuOpenId?: string | undefined;
|
||||
},
|
||||
): Promise<TeamMemberRow> {
|
||||
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId);
|
||||
const user = await resolveUser(prisma, input);
|
||||
// User should be an org member to join a team (product pin for pilot).
|
||||
const membership = await prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
userId: user.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
throw new Error(`user ${user.id} is not an active member of the organization`);
|
||||
}
|
||||
const existing = await prisma.teamMembership.findFirst({
|
||||
where: { teamId: team.id, userId: user.id, revokedAt: null },
|
||||
});
|
||||
if (existing !== null) {
|
||||
throw new Error(`user ${user.id} is already on team ${team.id}`);
|
||||
}
|
||||
const row = await prisma.teamMembership.create({
|
||||
data: { teamId: team.id, userId: user.id },
|
||||
});
|
||||
return {
|
||||
userId: user.id,
|
||||
feishuOpenId: user.feishuOpenId,
|
||||
displayName: user.displayName,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeTeamMember(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly teamId: string;
|
||||
readonly userId: string;
|
||||
},
|
||||
): Promise<{ readonly revoked: true; readonly userId: string }> {
|
||||
await requireActiveTeam(prisma, input.teamId, input.organizationId);
|
||||
const membership = await prisma.teamMembership.findFirst({
|
||||
where: { teamId: input.teamId, userId: input.userId, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
throw new Error(`team member not found: ${input.userId}`);
|
||||
}
|
||||
await prisma.teamMembership.update({
|
||||
where: { id: membership.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { revoked: true as const, userId: input.userId };
|
||||
}
|
||||
|
||||
async function resolveUser(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined },
|
||||
): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> {
|
||||
if (input.userId !== undefined && input.userId !== "") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, feishuOpenId: true, displayName: true },
|
||||
});
|
||||
if (user === null) throw new Error(`user not found: ${input.userId}`);
|
||||
return user;
|
||||
}
|
||||
if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { feishuOpenId: input.feishuOpenId },
|
||||
select: { id: true, feishuOpenId: true, displayName: true },
|
||||
});
|
||||
if (user === null) throw new Error(`user not found: ${input.feishuOpenId}`);
|
||||
return user;
|
||||
}
|
||||
throw new Error("userId or feishuOpenId is required");
|
||||
}
|
||||
|
||||
async function requireActiveTeam(
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
teamId: string,
|
||||
organizationId: string,
|
||||
): Promise<{ readonly id: string }> {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { id: true, organizationId: true, archivedAt: true },
|
||||
});
|
||||
if (team === null || team.archivedAt !== null || team.organizationId !== organizationId) {
|
||||
throw new Error(`active team not found: ${teamId}`);
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
function sanitizeSlug(raw: string): string {
|
||||
const slug = requireNonEmpty(raw, "slug").toLowerCase();
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug)) {
|
||||
throw new Error("slug must be lowercase alphanumeric with optional hyphens");
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function requireNonEmpty(value: string, label: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") throw new Error(`${label} is required`);
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Org / project usage rollups from AgentRun cost facts (ADR-0021).
|
||||
* Not payment collection — customers may supply their own provider keys.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
export interface ProjectUsageRow {
|
||||
readonly projectId: string;
|
||||
readonly projectName: string;
|
||||
readonly folderId: string | null;
|
||||
readonly runCount: number;
|
||||
readonly runsWithCost: number;
|
||||
readonly runsWithoutCost: number;
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
readonly costUsd: number | null;
|
||||
}
|
||||
|
||||
export interface UsageReport {
|
||||
readonly from: string | null;
|
||||
readonly to: string | null;
|
||||
readonly projects: readonly ProjectUsageRow[];
|
||||
readonly totals: {
|
||||
readonly runCount: number;
|
||||
readonly runsWithCost: number;
|
||||
readonly runsWithoutCost: number;
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
readonly costUsd: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOrgUsage(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly from?: Date | undefined;
|
||||
readonly to?: Date | undefined;
|
||||
readonly folderId?: string | undefined;
|
||||
},
|
||||
): Promise<UsageReport> {
|
||||
const projectWhere: Prisma.ProjectWhereInput = {
|
||||
organizationId: input.organizationId,
|
||||
...(input.folderId !== undefined ? { folderId: input.folderId } : {}),
|
||||
};
|
||||
const projects = await prisma.project.findMany({
|
||||
where: projectWhere,
|
||||
select: { id: true, name: true, folderId: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
if (projects.length === 0) {
|
||||
return emptyReport(input.from, input.to);
|
||||
}
|
||||
|
||||
const runWhere: Prisma.AgentRunWhereInput = {
|
||||
projectId: { in: projects.map((p) => p.id) },
|
||||
...(input.from !== undefined || input.to !== undefined
|
||||
? {
|
||||
startedAt: {
|
||||
...(input.from !== undefined ? { gte: input.from } : {}),
|
||||
...(input.to !== undefined ? { lte: input.to } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const runs = await prisma.agentRun.findMany({
|
||||
where: runWhere,
|
||||
select: {
|
||||
projectId: true,
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
costUsd: true,
|
||||
},
|
||||
});
|
||||
|
||||
type MutableRow = {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
folderId: string | null;
|
||||
runCount: number;
|
||||
runsWithCost: number;
|
||||
runsWithoutCost: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costUsd: number | null;
|
||||
};
|
||||
|
||||
const byProject = new Map<string, MutableRow>();
|
||||
for (const p of projects) {
|
||||
byProject.set(p.id, {
|
||||
projectId: p.id,
|
||||
projectName: p.name,
|
||||
folderId: p.folderId,
|
||||
runCount: 0,
|
||||
runsWithCost: 0,
|
||||
runsWithoutCost: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costUsd: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
const row = byProject.get(run.projectId);
|
||||
if (row === undefined) continue;
|
||||
row.runCount += 1;
|
||||
row.inputTokens += run.inputTokens ?? 0;
|
||||
row.outputTokens += run.outputTokens ?? 0;
|
||||
if (run.costUsd === null) {
|
||||
row.runsWithoutCost += 1;
|
||||
} else {
|
||||
row.runsWithCost += 1;
|
||||
const cost = Number(run.costUsd);
|
||||
row.costUsd = (row.costUsd ?? 0) + cost;
|
||||
}
|
||||
}
|
||||
|
||||
const projectsOut: ProjectUsageRow[] = [...byProject.values()];
|
||||
let runCount = 0;
|
||||
let runsWithCost = 0;
|
||||
let runsWithoutCost = 0;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let costUsd: number | null = null;
|
||||
for (const row of projectsOut) {
|
||||
runCount += row.runCount;
|
||||
runsWithCost += row.runsWithCost;
|
||||
runsWithoutCost += row.runsWithoutCost;
|
||||
inputTokens += row.inputTokens;
|
||||
outputTokens += row.outputTokens;
|
||||
if (row.costUsd !== null) {
|
||||
costUsd = (costUsd ?? 0) + row.costUsd;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: input.from?.toISOString() ?? null,
|
||||
to: input.to?.toISOString() ?? null,
|
||||
projects: projectsOut,
|
||||
totals: {
|
||||
runCount,
|
||||
runsWithCost,
|
||||
runsWithoutCost,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
costUsd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProjectUsage(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
readonly from?: Date | undefined;
|
||||
readonly to?: Date | undefined;
|
||||
},
|
||||
): Promise<ProjectUsageRow> {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: input.projectId, organizationId: input.organizationId },
|
||||
select: { id: true, name: true, folderId: true },
|
||||
});
|
||||
if (project === null) {
|
||||
throw new Error(`project not found: ${input.projectId}`);
|
||||
}
|
||||
const report = await getOrgUsage(prisma, {
|
||||
organizationId: input.organizationId,
|
||||
from: input.from,
|
||||
to: input.to,
|
||||
});
|
||||
const row = report.projects.find((p) => p.projectId === project.id);
|
||||
return (
|
||||
row ?? {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
folderId: project.folderId,
|
||||
runCount: 0,
|
||||
runsWithCost: 0,
|
||||
runsWithoutCost: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costUsd: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function emptyReport(from?: Date, to?: Date): UsageReport {
|
||||
return {
|
||||
from: from?.toISOString() ?? null,
|
||||
to: to?.toISOString() ?? null,
|
||||
projects: [],
|
||||
totals: {
|
||||
runCount: 0,
|
||||
runsWithCost: 0,
|
||||
runsWithoutCost: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costUsd: 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;
|
||||
}
|
||||
+19
-3
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Hub entry — Fastify server + Feishu WebSocket listener.
|
||||
* Hub entry — Fastify server + Feishu WebSocket listener + org admin HTTP.
|
||||
*
|
||||
* Wires the full trigger path: lark ws receives `im.message.receive_v1` →
|
||||
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires
|
||||
@@ -7,11 +7,15 @@
|
||||
* (ADR-0017), and releases the lock on finish. Feishu context reads inside the
|
||||
* loop are ADR-0003-authorized (chat must match binding).
|
||||
*
|
||||
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
|
||||
*
|
||||
* Env (see .env.example):
|
||||
* DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT
|
||||
* DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT,
|
||||
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import Fastify from "fastify";
|
||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||
import { prisma } from "./db.js";
|
||||
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||
@@ -43,6 +47,9 @@ async function main(): Promise<void> {
|
||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
||||
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
|
||||
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
|
||||
const port = Number(process.env["PORT"] ?? "8788");
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
@@ -57,6 +64,15 @@ async function main(): Promise<void> {
|
||||
|
||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
sessionSecret,
|
||||
publicBaseUrl,
|
||||
feishuAppId,
|
||||
feishuAppSecret,
|
||||
projectWorkspaceRoot,
|
||||
});
|
||||
|
||||
// --- Feishu listener ---
|
||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||
if (feishuListenerEnabled) {
|
||||
@@ -69,7 +85,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}, 60_000);
|
||||
triggerQueuePurgeTimer.unref();
|
||||
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log });
|
||||
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
|
||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
|
||||
} else {
|
||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Org admin auth + requireOrgRole integration tests (ADR-0021).
|
||||
*/
|
||||
import Fastify from "fastify";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerAdminPlugin } from "../../src/admin/plugin.js";
|
||||
import {
|
||||
mintSessionToken,
|
||||
sessionCookieHeader,
|
||||
} from "../../src/admin/routes/authRoutes.js";
|
||||
import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
|
||||
const SESSION_SECRET = "integration-test-session-secret";
|
||||
const PUBLIC_BASE = "http://127.0.0.1:8788";
|
||||
|
||||
async function buildApp(fetchImpl?: typeof fetch) {
|
||||
const app = Fastify({ logger: false });
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
sessionSecret: SESSION_SECRET,
|
||||
publicBaseUrl: PUBLIC_BASE,
|
||||
feishuAppId: "cli_test",
|
||||
feishuAppSecret: "secret_test",
|
||||
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
|
||||
cookieSecure: false,
|
||||
...(fetchImpl !== undefined ? { fetchImpl } : {}),
|
||||
});
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seedUser(
|
||||
id: string,
|
||||
openId: string,
|
||||
role: "OWNER" | "ADMIN" | "MEMBER" = "ADMIN",
|
||||
orgId: string = DEFAULT_ORG_ID,
|
||||
): Promise<void> {
|
||||
await prisma.user.create({
|
||||
data: { id, feishuOpenId: openId, displayName: id },
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: orgId, userId: id, role },
|
||||
});
|
||||
}
|
||||
|
||||
describe("admin auth + org API guards", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("GET /api/me returns 401 without session", async () => {
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const res = await app.inject({ method: "GET", url: "/api/me" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().error.code).toBe("unauthenticated");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /api/me returns user and memberships with valid session", async () => {
|
||||
await seedUser("u-admin", "ou_admin", "ADMIN");
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const token = mintSessionToken(
|
||||
{ userId: "u-admin", feishuOpenId: "ou_admin" },
|
||||
SESSION_SECRET,
|
||||
);
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/me",
|
||||
headers: { cookie: sessionCookieHeader(token) },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
user: { id: string; feishuOpenId: string };
|
||||
organizations: Array<{ slug: string; role: string }>;
|
||||
};
|
||||
expect(body.user.id).toBe("u-admin");
|
||||
expect(body.user.feishuOpenId).toBe("ou_admin");
|
||||
expect(body.organizations).toEqual([
|
||||
expect.objectContaining({ slug: "test-default", role: "ADMIN" }),
|
||||
]);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("org summary requires OWNER or ADMIN", async () => {
|
||||
await seedUser("u-member", "ou_member", "MEMBER");
|
||||
await seedUser("u-admin", "ou_admin", "ADMIN");
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const memberToken = mintSessionToken(
|
||||
{ userId: "u-member", feishuOpenId: "ou_member" },
|
||||
SESSION_SECRET,
|
||||
);
|
||||
const memberRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default",
|
||||
headers: { cookie: sessionCookieHeader(memberToken) },
|
||||
});
|
||||
expect(memberRes.statusCode).toBe(403);
|
||||
|
||||
const adminToken = mintSessionToken(
|
||||
{ userId: "u-admin", feishuOpenId: "ou_admin" },
|
||||
SESSION_SECRET,
|
||||
);
|
||||
const adminRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default",
|
||||
headers: { cookie: sessionCookieHeader(adminToken) },
|
||||
});
|
||||
expect(adminRes.statusCode).toBe(200);
|
||||
expect(adminRes.json()).toEqual({
|
||||
organization: expect.objectContaining({
|
||||
id: DEFAULT_ORG_ID,
|
||||
slug: "test-default",
|
||||
}),
|
||||
actorRole: "ADMIN",
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("PATCH settings updates membersCanCreateProjects", async () => {
|
||||
await seedUser("u-owner", "ou_owner", "OWNER");
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const token = mintSessionToken(
|
||||
{ userId: "u-owner", feishuOpenId: "ou_owner" },
|
||||
SESSION_SECRET,
|
||||
);
|
||||
const res = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/api/org/test-default/settings",
|
||||
headers: {
|
||||
cookie: sessionCookieHeader(token),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { membersCanCreateProjects: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ membersCanCreateProjects: false });
|
||||
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default/settings",
|
||||
headers: { cookie: sessionCookieHeader(token) },
|
||||
});
|
||||
expect(getRes.json()).toEqual({ membersCanCreateProjects: false });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /auth/feishu redirects to Feishu authorize URL", async () => {
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/feishu?returnTo=/admin/org/test-default",
|
||||
});
|
||||
expect(res.statusCode).toBe(302);
|
||||
const location = res.headers.location;
|
||||
expect(location).toBeTypeOf("string");
|
||||
const url = new URL(location as string);
|
||||
expect(url.hostname).toBe("accounts.feishu.cn");
|
||||
expect(url.searchParams.get("client_id")).toBe("cli_test");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(
|
||||
`${PUBLIC_BASE}/auth/feishu/callback`,
|
||||
);
|
||||
expect(url.searchParams.get("state")).toBeTruthy();
|
||||
const setCookie = res.headers["set-cookie"];
|
||||
expect(JSON.stringify(setCookie)).toContain(OAUTH_STATE_COOKIE_NAME);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("OAuth callback upserts user and sets session cookie", async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/oauth/token")) {
|
||||
return new Response(
|
||||
JSON.stringify({ code: 0, access_token: "u-tok", token_type: "Bearer", expires_in: 7200 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url.includes("/user_info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: { open_id: "ou_new", name: "New User", avatar_url: null },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected ${url}`);
|
||||
});
|
||||
|
||||
const app = await buildApp(fetchImpl as unknown as typeof fetch);
|
||||
try {
|
||||
const nonce = "nonce-test-1";
|
||||
const state = signOAuthState(
|
||||
{ nonce, returnTo: "/admin/org/test-default" },
|
||||
SESSION_SECRET,
|
||||
);
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state)}`,
|
||||
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("/admin/org/test-default");
|
||||
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
|
||||
expect(user?.displayName).toBe("New User");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("unknown org slug returns 404 for admin", async () => {
|
||||
await seedUser("u-admin", "ou_admin", "ADMIN");
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const token = mintSessionToken(
|
||||
{ userId: "u-admin", feishuOpenId: "ou_admin" },
|
||||
SESSION_SECRET,
|
||||
);
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/does-not-exist",
|
||||
headers: { cookie: sessionCookieHeader(token) },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Org explorer API integration tests (ADR-0021).
|
||||
*/
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import Fastify from "fastify";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { registerAdminPlugin } from "../../src/admin/plugin.js";
|
||||
import {
|
||||
mintSessionToken,
|
||||
sessionCookieHeader,
|
||||
} from "../../src/admin/routes/authRoutes.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
|
||||
const SESSION_SECRET = "integration-test-session-secret";
|
||||
const workspaceRoots: string[] = [];
|
||||
|
||||
async function buildApp(workspaceRoot: string) {
|
||||
const app = Fastify({ logger: false });
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
sessionSecret: SESSION_SECRET,
|
||||
publicBaseUrl: "http://127.0.0.1:8788",
|
||||
feishuAppId: "cli_test",
|
||||
feishuAppSecret: "secret_test",
|
||||
projectWorkspaceRoot: workspaceRoot,
|
||||
cookieSecure: false,
|
||||
});
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seedAdmin(): Promise<string> {
|
||||
await prisma.user.create({
|
||||
data: { id: "u-admin", feishuOpenId: "ou_admin", displayName: "Admin" },
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: "u-admin", role: "ADMIN" },
|
||||
});
|
||||
return mintSessionToken({ userId: "u-admin", feishuOpenId: "ou_admin" }, SESSION_SECRET);
|
||||
}
|
||||
|
||||
async function tempWorkspaceRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-admin-explorer-"));
|
||||
workspaceRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("admin explorer API", () => {
|
||||
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("creates folder + project and lists them in explorer", async () => {
|
||||
const token = await seedAdmin();
|
||||
const workspaceRoot = await tempWorkspaceRoot();
|
||||
const app = await buildApp(workspaceRoot);
|
||||
try {
|
||||
const folderRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/org/test-default/folders",
|
||||
headers: {
|
||||
cookie: sessionCookieHeader(token),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { name: "Grade 7", sortKey: "010000" },
|
||||
});
|
||||
expect(folderRes.statusCode).toBe(201);
|
||||
const folder = folderRes.json() as { id: string };
|
||||
|
||||
const projectRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/org/test-default/projects",
|
||||
headers: {
|
||||
cookie: sessionCookieHeader(token),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { name: "Newton", folderId: folder.id },
|
||||
});
|
||||
expect(projectRes.statusCode).toBe(201);
|
||||
const project = projectRes.json() as {
|
||||
projectId: string;
|
||||
folderId: string;
|
||||
workspaceDir: string;
|
||||
};
|
||||
expect(project.folderId).toBe(folder.id);
|
||||
expect((await stat(project.workspaceDir)).isDirectory()).toBe(true);
|
||||
|
||||
const explorerRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default/explorer",
|
||||
headers: { cookie: sessionCookieHeader(token) },
|
||||
});
|
||||
expect(explorerRes.statusCode).toBe(200);
|
||||
const explorer = explorerRes.json() as {
|
||||
folders: Array<{ id: string; name: string; projectCount: number }>;
|
||||
projects: Array<{ id: string; name: string; folderId: string | null }>;
|
||||
};
|
||||
expect(explorer.folders.some((f) => f.id === folder.id && f.projectCount === 1)).toBe(true);
|
||||
expect(explorer.projects).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: project.projectId,
|
||||
name: "Newton",
|
||||
folderId: folder.id,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("moves project between folders and refuses archive of non-empty folder", async () => {
|
||||
const token = await seedAdmin();
|
||||
const workspaceRoot = await tempWorkspaceRoot();
|
||||
const app = await buildApp(workspaceRoot);
|
||||
try {
|
||||
const cookie = sessionCookieHeader(token);
|
||||
const a = (
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/org/test-default/folders",
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { name: "A" },
|
||||
})
|
||||
).json() as { id: string };
|
||||
const b = (
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/org/test-default/folders",
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { name: "B" },
|
||||
})
|
||||
).json() as { id: string };
|
||||
const project = (
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/org/test-default/projects",
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { name: "P", folderId: a.id },
|
||||
})
|
||||
).json() as { projectId: string };
|
||||
|
||||
const moveRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/org/test-default/projects/${project.projectId}/folder`,
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { folderId: b.id },
|
||||
});
|
||||
expect(moveRes.statusCode).toBe(200);
|
||||
expect(moveRes.json()).toEqual({
|
||||
projectId: project.projectId,
|
||||
folderId: b.id,
|
||||
});
|
||||
|
||||
const refuse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/org/test-default/folders/${b.id}/archive`,
|
||||
headers: { cookie },
|
||||
});
|
||||
expect({ status: refuse.statusCode, body: refuse.json() }).toEqual({
|
||||
status: 403,
|
||||
body: expect.objectContaining({
|
||||
error: expect.objectContaining({ code: "forbidden" }),
|
||||
}),
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/org/test-default/projects/${project.projectId}/archive`,
|
||||
headers: { cookie },
|
||||
});
|
||||
const ok = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/org/test-default/folders/${b.id}/archive`,
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(ok.statusCode).toBe(200);
|
||||
expect(ok.json()).toEqual({ archived: true, folderId: b.id });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks cross-org project access by id", async () => {
|
||||
const token = await seedAdmin();
|
||||
await prisma.organization.create({
|
||||
data: { id: "org_other", slug: "other", name: "Other" },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: "p-other",
|
||||
organizationId: "org_other",
|
||||
name: "Other project",
|
||||
workspaceDir: "/tmp/other",
|
||||
},
|
||||
});
|
||||
const workspaceRoot = await tempWorkspaceRoot();
|
||||
const app = await buildApp(workspaceRoot);
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default/projects/p-other",
|
||||
headers: { cookie: sessionCookieHeader(token) },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Org members + teams + team-access API tests.
|
||||
*/
|
||||
import Fastify from "fastify";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { registerAdminPlugin } from "../../src/admin/plugin.js";
|
||||
import {
|
||||
mintSessionToken,
|
||||
sessionCookieHeader,
|
||||
} from "../../src/admin/routes/authRoutes.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
|
||||
const SESSION_SECRET = "integration-test-session-secret";
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({ logger: false });
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
sessionSecret: SESSION_SECRET,
|
||||
publicBaseUrl: "http://127.0.0.1:8788",
|
||||
feishuAppId: "cli_test",
|
||||
feishuAppSecret: "secret_test",
|
||||
projectWorkspaceRoot: "/tmp/cph-test-ws",
|
||||
cookieSecure: false,
|
||||
});
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seedOwner(): Promise<string> {
|
||||
await prisma.user.create({
|
||||
data: { id: "u-owner", feishuOpenId: "ou_owner", displayName: "Owner" },
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: "u-owner", role: "OWNER" },
|
||||
});
|
||||
return mintSessionToken({ userId: "u-owner", feishuOpenId: "ou_owner" }, SESSION_SECRET);
|
||||
}
|
||||
|
||||
describe("admin members + teams API", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("adds members and enforces last-OWNER protection", async () => {
|
||||
const token = await seedOwner();
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const cookie = sessionCookieHeader(token);
|
||||
|
||||
const add = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/org/test-default/members",
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { feishuOpenId: "ou_teacher", displayName: "Teacher", role: "ADMIN" },
|
||||
});
|
||||
expect(add.statusCode).toBe(201);
|
||||
const teacher = add.json() as { userId: string; role: string };
|
||||
expect(teacher.role).toBe("ADMIN");
|
||||
|
||||
const list = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default/members",
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect((list.json() as { members: unknown[] }).members).toHaveLength(2);
|
||||
|
||||
const refuse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/org/test-default/members/u-owner/revoke",
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(refuse.statusCode).toBe(403);
|
||||
expect(JSON.stringify(refuse.json())).toContain("last OWNER");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates team, adds member, grants project access, archives team", async () => {
|
||||
const token = await seedOwner();
|
||||
await prisma.user.create({
|
||||
data: { id: "u-m", feishuOpenId: "ou_m", displayName: "M" },
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: "u-m", role: "MEMBER" },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: "p1",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "P1",
|
||||
workspaceDir: "/tmp/p1",
|
||||
},
|
||||
});
|
||||
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const cookie = sessionCookieHeader(token);
|
||||
|
||||
const teamRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/org/test-default/teams",
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { slug: "math", name: "Math Team" },
|
||||
});
|
||||
expect(teamRes.statusCode).toBe(201);
|
||||
const team = teamRes.json() as { id: string };
|
||||
|
||||
const memberRes = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/org/test-default/teams/${team.id}/members`,
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { userId: "u-m" },
|
||||
});
|
||||
expect(memberRes.statusCode).toBe(201);
|
||||
|
||||
const grantRes = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/org/test-default/projects/p1/team-access",
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { teamId: team.id, role: "EDIT" },
|
||||
});
|
||||
expect(grantRes.statusCode).toBe(200);
|
||||
expect(grantRes.json()).toEqual(
|
||||
expect.objectContaining({ teamId: team.id, role: "EDIT" }),
|
||||
);
|
||||
|
||||
const listAccess = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default/projects/p1/team-access",
|
||||
headers: { cookie },
|
||||
});
|
||||
expect((listAccess.json() as { access: unknown[] }).access).toHaveLength(1);
|
||||
|
||||
const archive = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/org/test-default/teams/${team.id}/archive`,
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(archive.statusCode).toBe(200);
|
||||
expect(archive.json()).toEqual(
|
||||
expect.objectContaining({ archived: true, revokedGrants: 1 }),
|
||||
);
|
||||
|
||||
const after = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default/projects/p1/team-access",
|
||||
headers: { cookie },
|
||||
});
|
||||
expect((after.json() as { access: unknown[] }).access).toHaveLength(0);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
||||
import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
||||
@@ -9,6 +12,7 @@ import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||
type TestRunner = (req: RunRequest) => Promise<RunResult>;
|
||||
const workspaceRoots: string[] = [];
|
||||
|
||||
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
||||
return {
|
||||
@@ -102,6 +106,13 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
rt = mockFeishuRuntime();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (workspaceRoots.length > 0) {
|
||||
const root = workspaceRoots.pop();
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
||||
await seedProject("proj-1", "chat-1");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
@@ -142,6 +153,128 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(run.costSource).toBe("provider_reported");
|
||||
});
|
||||
|
||||
it("sends an onboarding card when an unbound chat mentions the bot", async () => {
|
||||
await seedOnboardingUser("u-onboard-card", "ou_onboard_card", "MEMBER");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-unbound-card", "@_user_1 开始项目", "ou_onboard_card"), rt);
|
||||
|
||||
expect(rt.sentCards).toHaveLength(1);
|
||||
expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目");
|
||||
const values = cardActionValues(rt.sentCards[0]);
|
||||
expect(values).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
project_onboarding: expect.objectContaining({
|
||||
action: "create_project_from_chat",
|
||||
organization_id: DEFAULT_ORG_ID,
|
||||
folder_id: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates and binds a project from the unbound-chat onboarding card", async () => {
|
||||
await seedOnboardingUser("u-onboard-create", "ou_onboard_create", "MEMBER");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
||||
});
|
||||
|
||||
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-create", {
|
||||
project_onboarding: {
|
||||
action: "create_project_from_chat",
|
||||
organization_id: DEFAULT_ORG_ID,
|
||||
},
|
||||
}, "ou_onboard_create"), rt);
|
||||
|
||||
const binding = await prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId: "chat-onboard-create", archivedAt: null },
|
||||
select: { projectId: true },
|
||||
});
|
||||
expect(binding).not.toBeNull();
|
||||
const grants = await prisma.permissionGrant.findMany({
|
||||
where: { resourceType: "PROJECT", resourceId: binding!.projectId, revokedAt: null },
|
||||
select: { principalType: true, principalId: true, role: true },
|
||||
});
|
||||
expect(grants).toEqual(expect.arrayContaining([
|
||||
{ principalType: "USER", principalId: "ou_onboard_create", role: "MANAGE" },
|
||||
{ principalType: "FEISHU_CHAT", principalId: "chat-onboard-create", role: "EDIT" },
|
||||
]));
|
||||
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已创建并绑定项目");
|
||||
});
|
||||
|
||||
it("binds an existing manageable project from the unbound-chat onboarding card", async () => {
|
||||
await seedOnboardingUser("u-onboard-bind", "ou_onboard_bind", "MEMBER");
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: "p-onboard-bind",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "可绑定项目",
|
||||
workspaceDir: join(await tempWorkspaceRoot(), "p-onboard-bind"),
|
||||
},
|
||||
});
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "p-onboard-bind",
|
||||
principalType: "USER",
|
||||
principalId: "ou_onboard_bind",
|
||||
role: "MANAGE",
|
||||
createdByUserId: "u-onboard-bind",
|
||||
},
|
||||
});
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-onboard-list", "@_user_1 绑定项目", "ou_onboard_bind"), rt);
|
||||
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
|
||||
project_onboarding: {
|
||||
action: "bind_project",
|
||||
organization_id: DEFAULT_ORG_ID,
|
||||
project_id: "p-onboard-bind",
|
||||
},
|
||||
});
|
||||
|
||||
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-list", {
|
||||
project_onboarding: {
|
||||
action: "bind_project",
|
||||
organization_id: DEFAULT_ORG_ID,
|
||||
project_id: "p-onboard-bind",
|
||||
},
|
||||
}, "ou_onboard_bind"), rt);
|
||||
|
||||
await expect(prisma.projectGroupBinding.findFirst({
|
||||
where: { projectId: "p-onboard-bind", chatId: "chat-onboard-list", archivedAt: null },
|
||||
})).resolves.not.toBeNull();
|
||||
const chatGrant = await prisma.permissionGrant.findFirst({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "p-onboard-bind",
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: "chat-onboard-list",
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { role: true },
|
||||
});
|
||||
expect(chatGrant?.role).toBe("EDIT");
|
||||
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目");
|
||||
});
|
||||
|
||||
it("batches quick text messages from the same chat and sender into one run", async () => {
|
||||
await seedProject("proj-1b", "chat-1b");
|
||||
const trigger = makeTriggerHandler({
|
||||
@@ -484,13 +617,13 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
||||
it("does not create a run for unbound chats and asks unknown users to log in", async () => {
|
||||
await seedProject("proj-4", "chat-4");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
|
||||
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt);
|
||||
|
||||
expect(rt.sentTexts).toHaveLength(0);
|
||||
expect(rt.sentTexts).toContain("请先登录并加入组织后,再绑定项目。");
|
||||
expect(rt.sentCards).toHaveLength(0);
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
@@ -973,6 +1106,56 @@ function makeInterruptEvent(chatId: string, runId: string, openId: string): Card
|
||||
};
|
||||
}
|
||||
|
||||
function makeOnboardingEvent(chatId: string, value: unknown, openId: string): CardActionEvent {
|
||||
return {
|
||||
operator: { open_id: openId },
|
||||
action: { value, tag: "button" },
|
||||
context: { open_chat_id: chatId, open_message_id: "card-message-1" },
|
||||
};
|
||||
}
|
||||
|
||||
async function seedOnboardingUser(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-trigger-onboarding-"));
|
||||
workspaceRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function cardActionValues(card: unknown): unknown[] {
|
||||
if (typeof card !== "object" || card === null || !("elements" in card)) return [];
|
||||
const elements = (card as { elements?: unknown }).elements;
|
||||
if (!Array.isArray(elements)) return [];
|
||||
return elements.flatMap((element) => {
|
||||
if (typeof element !== "object" || element === null || !("actions" in element)) return [];
|
||||
const actions = (element as { actions?: unknown }).actions;
|
||||
if (!Array.isArray(actions)) return [];
|
||||
return actions.map((action) => {
|
||||
if (typeof action !== "object" || action === null || !("value" in action)) return undefined;
|
||||
return (action as { value?: unknown }).value;
|
||||
}).filter((value): value is unknown => value !== undefined);
|
||||
});
|
||||
}
|
||||
|
||||
function cardHeaderTitle(card: unknown): string | undefined {
|
||||
if (typeof card !== "object" || card === null || !("header" in card)) return undefined;
|
||||
const header = (card as { header?: unknown }).header;
|
||||
if (typeof header !== "object" || header === null || !("title" in header)) return undefined;
|
||||
const title = (header as { title?: unknown }).title;
|
||||
if (typeof title !== "object" || title === null || !("content" in title)) return undefined;
|
||||
const content = (title as { content?: unknown }).content;
|
||||
return typeof content === "string" ? content : undefined;
|
||||
}
|
||||
|
||||
function cardHasInterruptedFooter(card: unknown): boolean {
|
||||
if (typeof card !== "object" || card === null) return false;
|
||||
if (!("elements" in card)) return false;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
exchangeCodeForUser,
|
||||
type FeishuOAuthConfig,
|
||||
} from "../../src/admin/auth/feishuOAuth.js";
|
||||
|
||||
describe("buildAuthorizeUrl", () => {
|
||||
it("includes client_id, redirect, state, and scope", () => {
|
||||
const url = buildAuthorizeUrl(
|
||||
{
|
||||
appId: "cli_test",
|
||||
appSecret: "secret",
|
||||
redirectUri: "http://localhost:8788/auth/feishu/callback",
|
||||
scope: "contact:user.base:readonly",
|
||||
},
|
||||
"state-token",
|
||||
);
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
"https://accounts.feishu.cn/open-apis/authen/v1/authorize",
|
||||
);
|
||||
expect(parsed.searchParams.get("client_id")).toBe("cli_test");
|
||||
expect(parsed.searchParams.get("response_type")).toBe("code");
|
||||
expect(parsed.searchParams.get("redirect_uri")).toBe(
|
||||
"http://localhost:8788/auth/feishu/callback",
|
||||
);
|
||||
expect(parsed.searchParams.get("state")).toBe("state-token");
|
||||
expect(parsed.searchParams.get("scope")).toBe("contact:user.base:readonly");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeCodeForUser", () => {
|
||||
it("exchanges code then loads user_info", async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/oauth/token")) {
|
||||
expect(init?.method).toBe("POST");
|
||||
const body = JSON.parse(String(init?.body)) as { code: string };
|
||||
expect(body.code).toBe("auth-code");
|
||||
return new Response(
|
||||
JSON.stringify({ code: 0, access_token: "u-token", token_type: "Bearer", expires_in: 7200 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url.includes("/user_info")) {
|
||||
expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer u-token");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: { open_id: "ou_alice", name: "Alice", avatar_url: "https://img/a.png" },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected url ${url}`);
|
||||
});
|
||||
|
||||
const config: FeishuOAuthConfig = {
|
||||
appId: "cli_test",
|
||||
appSecret: "secret",
|
||||
redirectUri: "http://localhost:8788/auth/feishu/callback",
|
||||
scope: "contact:user.base:readonly",
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
};
|
||||
|
||||
const user = await exchangeCodeForUser(config, "auth-code");
|
||||
expect(user).toEqual({
|
||||
openId: "ou_alice",
|
||||
displayName: "Alice",
|
||||
avatarUrl: "https://img/a.png",
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws on token exchange failure", async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ code: 20003, error: "invalid_grant", error_description: "bad code" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
exchangeCodeForUser(
|
||||
{
|
||||
appId: "cli",
|
||||
appSecret: "s",
|
||||
redirectUri: "http://localhost/cb",
|
||||
scope: "",
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
"bad",
|
||||
),
|
||||
).rejects.toThrow(/token exchange failed/);
|
||||
});
|
||||
});
|
||||
@@ -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" })),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
signOAuthState,
|
||||
signSession,
|
||||
verifyOAuthState,
|
||||
verifySession,
|
||||
} from "../../src/admin/auth/session.js";
|
||||
import { sanitizeReturnTo } from "../../src/admin/routes/authRoutes.js";
|
||||
|
||||
const SECRET = "test-session-secret-not-for-production";
|
||||
|
||||
describe("session cookie signing", () => {
|
||||
it("round-trips a valid session", () => {
|
||||
const token = signSession(
|
||||
{ userId: "u1", feishuOpenId: "ou_1" },
|
||||
SECRET,
|
||||
3600,
|
||||
1_700_000_000,
|
||||
);
|
||||
const payload = verifySession(token, SECRET, 1_700_000_100);
|
||||
expect(payload).toEqual({
|
||||
userId: "u1",
|
||||
feishuOpenId: "ou_1",
|
||||
iat: 1_700_000_000,
|
||||
exp: 1_700_003_600,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects tampered tokens", () => {
|
||||
const token = signSession({ userId: "u1", feishuOpenId: "ou_1" }, SECRET);
|
||||
const [body] = token.split(".");
|
||||
expect(verifySession(`${body}.deadbeef`, SECRET)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects expired sessions", () => {
|
||||
const token = signSession(
|
||||
{ userId: "u1", feishuOpenId: "ou_1" },
|
||||
SECRET,
|
||||
10,
|
||||
1_700_000_000,
|
||||
);
|
||||
expect(verifySession(token, SECRET, 1_700_000_011)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects wrong secret", () => {
|
||||
const token = signSession({ userId: "u1", feishuOpenId: "ou_1" }, SECRET);
|
||||
expect(verifySession(token, "other-secret")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth state signing", () => {
|
||||
it("round-trips state with returnTo", () => {
|
||||
const token = signOAuthState(
|
||||
{ nonce: "abc", returnTo: "/admin/org/acme" },
|
||||
SECRET,
|
||||
600,
|
||||
1_700_000_000,
|
||||
);
|
||||
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
|
||||
nonce: "abc",
|
||||
returnTo: "/admin/org/acme",
|
||||
exp: 1_700_000_600,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeReturnTo", () => {
|
||||
it("allows admin paths", () => {
|
||||
expect(sanitizeReturnTo("/admin/org/acme")).toBe("/admin/org/acme");
|
||||
});
|
||||
|
||||
it("blocks open redirects", () => {
|
||||
expect(sanitizeReturnTo("https://evil.example/")).toBe("/admin");
|
||||
expect(sanitizeReturnTo("//evil.example")).toBe("/admin");
|
||||
expect(sanitizeReturnTo("/api/me")).toBe("/admin");
|
||||
expect(sanitizeReturnTo("")).toBe("/admin");
|
||||
});
|
||||
});
|
||||
@@ -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