Compare commits

...

7 Commits

Author SHA1 Message Date
hongjr03 008c8bcd09 feat(hub): add org sessions and usage admin APIs
List project agent sessions/runs and aggregate AgentRun cost and token
usage by project and optional folder for org admins.
2026-07-10 00:55:43 +08:00
hongjr03 683d5674d1 feat(hub): add org members, teams, and project team-access APIs
Manage memberships with last-OWNER protection, team lifecycle (archive
revokes TEAM grants), and TEAM→PROJECT access under org admin auth.
2026-07-10 00:55:34 +08:00
hongjr03 1f8510a47f feat(hub): add org project explorer admin APIs
Expose folder/project tree, create/move/rename/archive, and Feishu
binding archive under /api/org/:orgSlug for OWNER/ADMIN sessions.
2026-07-10 00:55:26 +08:00
hongjr03 c4f052efa2 feat(hub): add Feishu OAuth session for org admin
Introduce signed cookie sessions, Feishu web OAuth, requireOrgRole
guards, and the first org admin APIs (summary + project settings).
2026-07-10 00:55:19 +08:00
hongjr03 87e3d3f990 feat: let Feishu onboarding create projects in folders 2026-07-10 00:35:18 +08:00
hongjr03 519ea8144f feat: add Feishu project onboarding card 2026-07-10 00:32:09 +08:00
hongjr03 34e07e229f feat: add org admin project onboarding foundation 2026-07-10 00:25:00 +08:00
42 changed files with 5183 additions and 46 deletions
+3
View File
@@ -8,6 +8,9 @@
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 - 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律 ## 纪律
+3
View File
@@ -8,6 +8,9 @@
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 - 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 必须归属 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. - Multi-open is natural: a teacher participates in multiple project groups.
- Normal group discussion does not occupy the project. - Normal group discussion does not occupy the project.
- "Exit project" should not be the normal action in the group workflow. - "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.
+18
View File
@@ -21,6 +21,9 @@ ANTHROPIC_API_KEY=""
# Agent run policy (optional). Defaults to 25. # Agent run policy (optional). Defaults to 25.
# HUB_AGENT_MAX_TURNS=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 (lark) bot credentials.
FEISHU_APP_ID="" FEISHU_APP_ID=""
FEISHU_APP_SECRET="" FEISHU_APP_SECRET=""
@@ -31,3 +34,18 @@ FEISHU_BOT_OPEN_ID=""
# Hub server port. Defaults to 8788. # Hub server port. Defaults to 8788.
PORT=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
View File
@@ -36,6 +36,8 @@ model Organization {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
memberships OrganizationMembership[] memberships OrganizationMembership[]
projectSettings OrganizationProjectSettings?
folders Folder[]
projects Project[] projects Project[]
teams Team[] teams Team[]
externalDirectoryConnections ExternalDirectoryConnection[] externalDirectoryConnections ExternalDirectoryConnection[]
@@ -73,6 +75,17 @@ enum OrganizationMemberRole {
MEMBER 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). /// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -222,9 +235,32 @@ model ExternalPrincipalMembership {
// --- Project & Feishu binding (ADR-0001) --------------------------------- // --- 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 { model Project {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
folderId String?
name String name String
workspaceDir String workspaceDir String
createdByUserId String? createdByUserId String?
@@ -232,37 +268,39 @@ model Project {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime? archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
groupBinding ProjectGroupBinding? createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBindings ProjectGroupBinding[]
agentSessions AgentSession[] agentSessions AgentSession[]
agentRuns AgentRun[] agentRuns AgentRun[]
agentLock ProjectAgentLock? agentLock ProjectAgentLock?
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit") auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges") fileChanges AgentFileChange[] @relation("projectFileChanges")
@@index([organizationId, archivedAt]) @@index([organizationId, archivedAt])
@@index([folderId, archivedAt])
@@index([archivedAt]) @@index([archivedAt])
} }
/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒ /// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat). /// (1:1). Historical archived bindings are retained for audit; partial unique
/// The "at most one binding per project" half is enforced by the 1:1 relation. /// indexes in migrations enforce one active binding per project and per chat.
/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled
/// here; archival is a future policy, not a current column.
model ProjectGroupBinding { model ProjectGroupBinding {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String @unique projectId String
chatId String @unique chatId String
createdByUserId String? createdByUserId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@index([chatId]) @@index([projectId, archivedAt])
@@index([chatId, archivedAt])
} }
// --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- // --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
+124
View File
@@ -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 };
}
+137
View File
@@ -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 } });
}
+116
View File
@@ -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);
}
+47
View File
@@ -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;
}
+47
View File
@@ -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,
});
}
+85
View File
@@ -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;
}
+293
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
+228
View File
@@ -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);
}
});
}
+104
View File
@@ -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;
}
+104
View File
@@ -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,
});
}
+80
View File
@@ -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);
}
});
}
+140
View File
@@ -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);
}
});
}
+154
View File
@@ -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
View File
@@ -15,6 +15,8 @@ import type { FastifyBaseLogger } from "fastify";
import { import {
sendText, sendText,
sendTextMessage, sendTextMessage,
sendCard,
patchCard,
reactToMessage, reactToMessage,
addReaction, addReaction,
removeReaction, removeReaction,
@@ -42,6 +44,19 @@ import { SenderNameCache } from "./senderCache.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js"; import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js"; import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js"; import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
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 { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js"; export type { ApprovalResult, PendingApproval } from "./approval.js";
@@ -57,6 +72,7 @@ interface TriggerDeps {
readonly authorizer?: PermissionAuthorizer | undefined; readonly authorizer?: PermissionAuthorizer | undefined;
readonly messageBatcherOptions?: MessageBatcherOptions | undefined; readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined; readonly triggerQueue?: TriggerQueue | undefined;
readonly projectWorkspaceRoot?: string | undefined;
} }
interface TriggerActor { interface TriggerActor {
@@ -521,6 +537,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return; 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. // Interrupt button on a live run card.
const runId = interruptRunFromValue(event.action.value); const runId = interruptRunFromValue(event.action.value);
if (runId !== null) { 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. */ /** Abort a live run after authorizing agent.cancel on its project. */
async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise<void> { async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise<void> {
const chatId = nonEmpty(event.context?.open_chat_id); 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"); deps.logger.warn({ runId }, "feishu interrupt: card action missing chat id");
return; return;
} }
const binding = await deps.prisma.projectGroupBinding.findUnique({ const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId }, where: { chatId, archivedAt: null },
select: { projectId: true }, select: { projectId: true },
}); });
if (binding === null) { if (binding === null) {
@@ -618,12 +713,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (chatId === "") return; if (chatId === "") return;
// ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group. // ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
const binding = await deps.prisma.projectGroupBinding.findUnique({ const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId }, where: { chatId, archivedAt: null },
select: { projectId: true }, select: { projectId: true },
}); });
if (binding === null) { if (binding === null) {
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project"); deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
await sendUnboundChatOnboardingCard(event, rt);
return; return;
} }
const projectId = binding.projectId; const projectId = binding.projectId;
@@ -743,6 +839,137 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await startAgentRun(runContext, cleanPrompt); 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 }); return Object.assign(onMessage, { onCardAction, approvalManager });
} }
@@ -793,6 +1020,10 @@ function nonEmpty(value: string | undefined): string | undefined {
return value === undefined || value === "" ? undefined : value; 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 { function sendOptionsForTriggerMessage(msg: MessageReceiveEvent["message"]): SendMessageOptions {
return { replyToMessageId: msg.message_id }; 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."; "Do not say a file is attached or sent unless that tool returns success.";
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`; 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"}`. */ /** Lark text-message content: `{"text":"@_user_1 do something"}`. */
const TextMessageContentSchema = z.object({ text: z.string() }); const TextMessageContentSchema = z.object({ text: z.string() });
+350
View File
@@ -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;
}
+213
View File
@@ -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;
}
+114
View File
@@ -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,
})),
};
}
+290
View File
@@ -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;
}
+203
View File
@@ -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,
},
};
}
+2 -2
View File
@@ -234,8 +234,8 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
return project.organizationId; return project.organizationId;
} }
case "PROJECT_GROUP": { case "PROJECT_GROUP": {
const binding = await this.prisma.projectGroupBinding.findUnique({ const binding = await this.prisma.projectGroupBinding.findFirst({
where: { chatId: resource.id }, where: { chatId: resource.id, archivedAt: null },
select: { project: { select: { organizationId: true } } }, select: { project: { select: { organizationId: true } } },
}); });
if (binding === null) { if (binding === null) {
+537
View File
@@ -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
View File
@@ -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` → * Wires the full trigger path: lark ws receives `im.message.receive_v1` →
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires * 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 * (ADR-0017), and releases the lock on finish. Feishu context reads inside the
* loop are ADR-0003-authorized (chat must match binding). * loop are ADR-0003-authorized (chat must match binding).
* *
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
*
* Env (see .env.example): * 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 "dotenv/config";
import Fastify from "fastify"; import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js"; import { prisma } from "./db.js";
import { createEnvRuntimeSettings } from "./settings/runtime.js"; import { createEnvRuntimeSettings } from "./settings/runtime.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
@@ -43,6 +47,9 @@ async function main(): Promise<void> {
const feishuAppId = requireEnv("FEISHU_APP_ID"); const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const port = Number(process.env["PORT"] ?? "8788"); const port = Number(process.env["PORT"] ?? "8788");
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
@@ -57,6 +64,15 @@ async function main(): Promise<void> {
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() })); app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
await registerAdminPlugin(app, {
prisma,
sessionSecret,
publicBaseUrl,
feishuAppId,
feishuAppSecret,
projectWorkspaceRoot,
});
// --- Feishu listener --- // --- Feishu listener ---
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true); const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (feishuListenerEnabled) { if (feishuListenerEnabled) {
@@ -69,7 +85,7 @@ async function main(): Promise<void> {
} }
}, 60_000); }, 60_000);
triggerQueuePurgeTimer.unref(); 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); startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
} else { } else {
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
+250
View File
@@ -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();
}
});
});
+224
View File
@@ -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();
}
});
});
+21
View File
@@ -44,6 +44,8 @@ export async function resetDb(): Promise<void> {
"AgentRun", "AgentRun",
"AgentSession", "AgentSession",
"ProjectGroupBinding", "ProjectGroupBinding",
"Folder",
"OrganizationProjectSettings",
"OrganizationMembership", "OrganizationMembership",
"PlatformRoleAssignment", "PlatformRoleAssignment",
"User", "User",
@@ -68,6 +70,25 @@ export async function seedTestOrganization(
name: "Test Default Organization", 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). */ /** 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;
}
+188 -5
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; import { mkdtemp, rm } from "node:fs/promises";
import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js"; 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 { InMemoryModelRegistry } from "../../src/agent/models.js";
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.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" }; const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
type TestRunner = (req: RunRequest) => Promise<RunResult>; type TestRunner = (req: RunRequest) => Promise<RunResult>;
const workspaceRoots: string[] = [];
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings { function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
return { return {
@@ -102,6 +106,13 @@ describe("trigger full lifecycle (integration)", () => {
rt = mockFeishuRuntime(); 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 () => { it("creates a run, acquires + releases the lock, sends status card", async () => {
await seedProject("proj-1", "chat-1"); await seedProject("proj-1", "chat-1");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 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"); 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 () => { it("batches quick text messages from the same chat and sender into one run", async () => {
await seedProject("proj-1b", "chat-1b"); await seedProject("proj-1b", "chat-1b");
const trigger = makeTriggerHandler({ 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"); await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
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); expect(rt.sentCards).toHaveLength(0);
const runs = await prisma.agentRun.findMany(); const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0); 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 { function cardHasInterruptedFooter(card: unknown): boolean {
if (typeof card !== "object" || card === null) return false; if (typeof card !== "object" || card === null) return false;
if (!("elements" in card)) return false; if (!("elements" in card)) return false;
+96
View File
@@ -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/);
});
});
+1 -1
View File
@@ -255,7 +255,7 @@ function mockPrisma(): PrismaClient {
create: vi.fn(async () => ({ id: "receipt-1" })), create: vi.fn(async () => ({ id: "receipt-1" })),
}, },
projectGroupBinding: { projectGroupBinding: {
findUnique: vi.fn(async () => ({ projectId: "project-1" })), findFirst: vi.fn(async () => ({ projectId: "project-1" })),
}, },
project: { project: {
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })), findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
+78
View File
@@ -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");
});
});
+2
View File
@@ -17,6 +17,8 @@ structure Identifiers where
OrganizationId : Type OrganizationId : Type
/-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/ /-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/
TeamId : Type TeamId : Type
/-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/
FolderId : Type
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关, /-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/ ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/
RunId : Type RunId : Type
+4 -1
View File
@@ -1,5 +1,6 @@
import Spec.System.ProjectGroup import Spec.System.ProjectGroup
import Spec.System.Organization import Spec.System.Organization
import Spec.System.ProjectWorkspace
import Spec.System.Run import Spec.System.Run
import Spec.System.Lock import Spec.System.Lock
import Spec.System.Memory import Spec.System.Memory
@@ -16,6 +17,8 @@ import Spec.System.Audit
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。 - `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
- `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org。 - `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org。
- `ProjectWorkspace` —— org 后台 project explorer:folder 是透明组织节点,project 仍是权限边界
(ADR-0021)。
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。 - `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。 - `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。 - `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。
@@ -26,5 +29,5 @@ import Spec.System.Audit
(ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。 (ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。
- `Audit` —— 有意从简(内容多为 plumbing,OPEN)。 - `Audit` —— 有意从简(内容多为 plumbing,OPEN)。
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020。 标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020, 0021
-/ -/
+11 -12
View File
@@ -6,14 +6,12 @@ import Spec.Prelude
ADR-0001 的核心:一个 project 对应一个**长生命周期**飞书项目群;群是协作空间,**不是锁 ADR-0001 的核心:一个 project 对应一个**长生命周期**飞书项目群;群是协作空间,**不是锁
owner**(锁归 `AgentRun`,见 `Lock` / ADR-0002),不是临时处理 session。群可在无 Claude owner**(锁归 `AgentRun`,见 `Lock` / ADR-0002),不是临时处理 session。群可在无 Claude
处理时保持开启;教师离群/静音与项目权限、与 Claude 生命周期相互独立。本模块钉死 处理时保持开启;教师离群/静音与项目权限、与 Claude 生命周期相互独立。本模块钉死
project↔group 的一对一绑定——likec4 画得出"project has group",画不出"恰好一个、且群 project↔group 的**active**一对一绑定——likec4 画得出"project has group",画不出"恰好一个、
不持锁"。 且群不持锁"。
**群失效态与绑定快照(`OPEN`, ADR-0001 Consequences):** 群解散/归档/不可达时, **绑定历史(`PINNED`, ADR-0021):** active binding 严格 1:1;实现可以保留 archived
`GroupBinding` 快照应变为 `none`(解绑、允许 rebind)还是保持 `some` 但指向失效 chat historical binding rows 供审计/纠错,但 `GroupBinding` 谓词只刻画当前 active 快照。
(悬挂、待管理员干预)未决策。ADR-0001 已点名 "rebinding or group archival needs 群解散/不可达的自动化处理仍为 `OPEN`;pilot 纠错由 org admin 显式归档绑定。
explicit product rules";本模块只刻画健康态 1:1 不变式,不臆造失效态语义。实现遇到
群解散必须 surface,不得默认任一方。
-/ -/
namespace Spec.System namespace Spec.System
@@ -27,13 +25,14 @@ structure ProjectGroup where
group";chat 标识见 `Identifiers.ChatId`)。 -/ group";chat 标识见 `Identifiers.ChatId`)。 -/
chat : I.ChatId chat : I.ChatId
/-- 项目↔群绑定表(`PINNED` 每项目至多一个群, ADR-0001)。`ProjectId → Option ChatId` 的 /-- 项目↔active 群绑定表(`PINNED` 每项目至多一个 active 群, ADR-0001/0021)。
结构本身即"一个 project 至多绑一个群"(由 `Option` 自带);良构补另一半——单射。 -/ `ProjectId → Option ChatId` 的结构本身即"一个 project 至多绑一个 active 群"(由 `Option`
自带);良构补另一半——单射。 -/
def GroupBinding := I.ProjectId Option I.ChatId def GroupBinding := I.ProjectId Option I.ChatId
/-- 绑定良构:**单射**——不同 project 不绑同一 chat(`PINNED` 1:1 的另一半, ADR-0001)。 /-- Active 绑定良构:**单射**——不同 project 不绑同一 active chat(`PINNED` 1:1 的另一半,
"每 project 至多一个群"由 `Option` 结构自带;这条钉死"每群至多属于一个 project"。群 ADR-0001/0021)。"每 project 至多一个群"由 `Option` 结构自带;这条钉死"每群至多属于一个
rebinding/archival 是生命周期事件(ADR-0001 Consequences),不在本快照不变式内。 -/ project"。archived historical bindings 不在本快照不变式内。 -/
def GroupBinding.WellFormed (b : GroupBinding I) : Prop := def GroupBinding.WellFormed (b : GroupBinding I) : Prop :=
p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂ p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂
+54
View File
@@ -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