Compare commits

..

3 Commits

16 changed files with 599 additions and 83 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+192 -54
View File
@@ -1,81 +1,219 @@
# para-26071100 飞书应用配置清单
# Educraft 组织接入与飞书应用配置指南
本文供 `para-26071100` 的飞书企业管理员操作。不要把 App Secret 粘贴到群聊、工单或本文档中;请通过约定的安全渠道交给平台部署人员
本文供准备接入 Educraft Alpha Silo 的学校、教培机构和组织管理员使用。完成本文后,请把末尾的“部署信息交付单”交给 Educraft 部署人员;我们会为组织创建独立的服务账号、数据库、运行目录和域名入口
## 1. 创建企业自建应用
> **安全提醒:** App Secret、模型 Provider Token 属于密钥,禁止粘贴到飞书群、普通云文档、工单正文或截图中。请只通过双方约定的安全渠道传递。
1. 打开飞书开放平台开发者后台。
2. 在目标企业下创建“企业自建应用”。
3. 应用名称可填写 `Educraft para-26071100`
4. 在“凭证与基础信息”记录:
- App ID(通常以 `cli_` 开头)
- App Secret
5. 添加并启用“机器人”能力。
## 1. 双方分别负责什么
Bot Open ID 不需要管理员手工寻找。平台部署人员会使用 App ID/App Secret 调用飞书 Bot Info API 获取,并在 bootstrap 时校验它确实属于这一个应用。
| 角色 | 负责事项 |
| --- | --- |
| 组织管理员 | 创建企业自建应用、启用机器人、开通最小权限、配置事件、回调和 OAuth 重定向 URL、发布应用、提供 OWNER 身份 |
| Educraft 部署人员 | 分配组织 slug 和域名、部署独立 Silo、加密保存应用及模型密钥、初始化 OWNER、联调和验收 |
| 试点 OWNER | 把机器人加入试点群、创建或绑定项目、组织首轮验收 |
## 2. 开通权限
## 2. 创建企业自建应用
在“权限管理”中搜索并申请下列能力。飞书控制台的中文名称可能随版本调整;如控制台同时显示 scope,可优先核对括号中的 scope
1. 打开[飞书开放平台开发者后台](https://open.feishu.cn/app)
2. 在目标企业下点击“创建企业自建应用”。应用名称建议填写“Educraft + 组织简称”。
3. 进入“凭证与基础信息”,记录 App ID 和 App Secret。
4. 进入“添加应用能力”,添加并启用“机器人”。
- 接收群聊中 @ 机器人的消息(`im:message.group_at_msg:readonly`
- 以应用身份发送消息(`im:message:send_as_bot`
- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`
- 获取与上传图片或文件资源(`im:resource`
- 添加、删除消息表情回复(`im:message.reactions:write_only`
- 获取用户基本信息(`contact:user.base:readonly``contact:user.basic_profile:readonly`
![凭证与基础信息页面;App Secret 默认以星号隐藏](assets/feishu-setup/01-credentials.png)
如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限
App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过安全渠道单独发送。Bot Open ID 不需要管理员手工查找;部署程序会用 App ID/App Secret 调用 Bot Info API 获取并校验归属
## 3. 配置事件与卡片回调
## 3. 开通最小权限
1. 进入“事件与回调”
2. 订阅方式选择“使用长连接接收事件”。
3. 添加事件 `im.message.receive_v1`(接收消息)。
4. 启用卡片交互回调 `card.action.trigger`,用于审批、中断运行和群聊建项目按钮。
5. 不需要填写公网 Event Callback URLHub 使用飞书长连接。
进入“权限管理”,点击“开通权限”,搜索并申请以下应用身份权限。控制台中文名称可能调整,请优先核对 scope
## 4. 配置 OAuth 回调
| 用途 | Scope |
| --- | --- |
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取与上传图片或文件 | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
| 通过手机号或邮箱查询 OWNER Open ID | `contact:user.id:readonly` |
域名 DNS 和 TLS 生效后,在安全设置/重定向 URL 中添加:
### 批量导入权限(推荐)
```text
https://para-26071100.educraft.paradigm-edu.net/auth/feishu/callback
在“权限管理”页面点击“批量处理 → 导入”,粘贴以下 JSON 后确认。导入只会新增本次列出的权限,不会删除或影响应用已经申请、开通的其他权限。
```json
{
"scopes": {
"tenant": [
"im:message.group_at_msg:readonly",
"im:message:send_as_bot",
"im:message:readonly",
"im:resource",
"im:message.reactions:write_only",
"contact:user.base:readonly",
"contact:user.basic_profile:readonly",
"contact:user.id:readonly"
],
"user": []
}
}
```
该回调用于 OWNER 登录受控的 Host Console。飞书群机器人长连接本身不依赖这个 URL
Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放在 `tenant`,不要为了省事把相同权限重复放进 `user`
## 5. 发布并安装应用
![权限管理入口与已开通权限列表](assets/feishu-setup/02-permissions.png)
1. 创建应用版本并提交企业管理员审核
2. 将应用可用范围至少包含试点 OWNER 和试点群成员。
3. 发布版本。
4. 将机器人加入准备试用的飞书群。
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限
## 6. 获取首位 OWNER 身份
## 4. 配置事件与卡片回调
平台 bootstrap 需要 OWNER 的飞书 Open ID 和显示名称。可通过飞书 API 调试台的用户信息接口查询;Open ID 通常以 `ou_` 开头。Union ID 可选,不影响首次部署
进入“事件与回调”
请把以下结果通过安全渠道交给平台部署人员:
1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。
2. 添加事件“接收消息” `im.message.receive_v1`
3. 在“回调配置”中同样选择长连接。
4. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。
![长连接与消息事件配置](assets/feishu-setup/03-events.png)
![卡片交互回调配置](assets/feishu-setup/04-callbacks.png)
这里不需要填写公网 Event Callback URL。Educraft Hub 使用飞书官方 SDK 的长连接模式。
## 5. 配置用户 OAuth 重定向 URL(必需)
普通群成员首次使用前,需要通过飞书 OAuth 建立其在本应用下的用户身份。进入“安全设置 → 重定向 URL”,添加组织专属 callback
```text
Organization: para-26071100
App ID: cli_...
App Secret: (安全渠道发送)
OWNER Open ID: ou_...
OWNER 显示名称:
OWNER Union ID: (可选)
试点群名称: (可选,便于验收)
https://<organization-slug>.educraft.paradigm-edu.net/auth/feishu/callback
```
## 7. 验收动作
例如组织 slug 为 `example-school`
平台通知部署完成后:
```text
https://example-school.educraft.paradigm-edu.net/auth/feishu/callback
```
1. OWNER 打开 Host Console,完成飞书 OAuth 登录。
2. 在试点群中 @机器人发送一条纯文本消息
3. 如果群尚未绑定项目,机器人应返回项目创建/绑定卡片。
4. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。
5. 再测试一个小文件附件,以及运行中断按钮。
![在安全设置中添加组织专属 OAuth 重定向 URL](assets/feishu-setup/05-security.png)
任何一步失败时,请保留发生时间、群名、消息截图和飞书 request/log ID;不要在截图中包含 App Secret 或 Provider token
必须使用 Educraft 部署人员最终确认的 slug;不要直接照抄示例。该 URL 用于 OAuth 返回并创建应用作用域下的飞书用户身份,不代表当前已经开放组织管理台
组织专属 OAuth 同时完成身份建立和入组:首次成功登录的用户会自动成为当前 Organization 的 `MEMBER`,回到群聊即可使用。`OWNER``ADMIN` 仍只能由部署人员或管理员显式授予;曾被移除的成员重新登录不会自动恢复资格。
## 6. 发布并安装应用
1. 进入“版本管理与发布”,点击“创建版本”。
2. 将应用可用范围至少覆盖试点 OWNER 和试点群成员。
3. 提交企业管理员审核并发布。
4. 发布成功后,将机器人加入准备试用的群。
![版本管理与发布页面](assets/feishu-setup/06-publish.png)
仅保存开发配置但未发布时,新增权限、事件和可用范围通常不会对试点用户生效。
## 7. 获取首位 OWNER 身份
首次部署必须指定一位组织 OWNER。OWNER 是 Educraft 组织内的初始管理员,不等同于飞书应用所有者;两者可以是同一个人,也可以不同。部署所需的 Open ID 必须由本次创建的企业自建应用查询,因为同一用户在不同应用下的 Open ID 不同,不能复用其他应用查到的值。
### 7.1 确认 OWNER
先确认哪一位企业成员将担任 OWNER。记录其飞书显示名称,并准备在飞书的成员选择器中按姓名找到本人。若企业内有同名成员,选择前须通过部门等信息核对身份。
### 7.2 开通查询权限和数据范围
确认应用已开通上文列出的用户基本信息和用户 ID 权限。如果使用上文的批量导入 JSON,这些权限已包含在内。
应用的通讯录数据范围还必须覆盖这位 OWNER。最小做法是把 OWNER 加入应用可用范围;不需要为此开放全企业通讯录。
### 7.3 在官方接口页面获取 Open ID
1. 打开飞书开放平台的[“获取单个用户信息”接口页面](https://open.feishu.cn/document/server-docs/contact-v3/user/get)。如果使用带 `appId` 参数的页面链接,可以直接进入对应应用;本文不提供固定 App ID,请在页面顶部选择本组织刚创建的企业自建应用,并核对 App ID 与交付单一致。
2. 找到路径参数 `user_id`,点击参数输入框旁的“获取”。
3. 在成员选择器中找到并选择 OWNER;如有同名成员,依据部门等信息确认本人。
4. ID 类型选择 `open_id`。将选择器返回的值填入 `user_id`,并保持查询参数 `user_id_type=open_id`
5. 以应用身份(`tenant_access_token`)调用接口,核对成功响应中 `data.user.name` 与 OWNER 本人一致。
6. 复制完整的 `data.user.open_id` 交给 Educraft 部署人员。Open ID 通常以 `ou_` 开头。
参数旁的“获取”是飞书文档调试台提供的成员选择功能,不是要求管理员预先知道 Open ID。不要复用其他应用查到的 Open ID;同一用户在不同应用下的 Open ID 不同。若无法选择成员或接口调用失败,依次检查:页面当前选择的 App ID、应用可用范围和通讯录数据范围是否覆盖 OWNER、用户基本信息与用户 ID 权限是否已开通并随应用版本发布。
### 7.4 核对并交付
交付前完成以下检查:
- 返回用户的姓名与 OWNER 本人一致;
- Open ID 来自本次组织的这一个 App ID;
- Open ID 完整复制,没有空格或省略号;
- 显示名称使用组织希望在 Educraft 中展示的姓名;
- Union ID 不是必填项,查不到可以留空。
最终向部署人员提供:
```text
OWNER Open IDou_...
OWNER 显示名称:
OWNER Union ID:(可选)
用于查询的 App IDcli_...
```
Open ID 和显示名称可以放在普通交付单中;不要把 App Secret 一起粘贴进去。
## 8. 部署信息交付单
请复制下面的模板填写。标注“安全渠道”的字段不要与普通字段放在同一条群消息或云文档中。
```text
【组织信息】
组织正式名称:
组织简称:
期望 organization slug:(小写字母、数字和连字符,例如 example-school
期望机器人显示名称:
【飞书应用】
App IDcli_...
App Secret:(通过安全渠道单独发送)
应用已发布:是 / 否
机器人能力已启用:是 / 否
消息事件和卡片回调已配置:是 / 否
OAuth 重定向 URL 已配置:是 / 否
【首位 OWNER】
OWNER Open IDou_...
OWNER 显示名称:
OWNER Union ID:(可选)
【试点范围】
试点群名称:(可选,用于验收定位)
初始 Team 名称:(可选;没有 Team 不影响首次部署)
预计试用人数:
【模型配置】
Provider 名称:(例如 OpenRouter
Provider Base URL
Provider Token:(通过安全渠道单独发送)
启用的模型 ID
```
Educraft 部署人员收到信息后,会回传最终 organization slug、访问域名、部署窗口和验收时间。若期望 slug 已被占用或不符合命名规则,会在部署前协调调整。
## 9. 上线验收
部署人员通知服务就绪后,由 OWNER 完成:
1. OWNER 在试点群中 @机器人发送一条纯文本消息
2. 如果群尚未绑定项目,确认机器人返回项目创建/绑定卡片。
3. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。
4. 选择一位非 OWNER 试点成员完成 OAuth 登录,确认其自动以 MEMBER 身份加入组织。
5. 该成员在同一群中 @机器人,确认能够进入已绑定项目。
6. 测试一个小文件附件、一次运行中断,以及一个需要生成文档的任务。
出现问题时,请保留发生时间、群名、消息截图和飞书 request/log ID。截图前确认其中不包含 App Secret、Provider Token 或其他密钥。
## 10. Alpha 阶段边界
- 每个组织运行在独立的系统用户、服务实例、数据库和持久化目录中。
- 组织的 role、system prompt、tools 和 skills 是运行时配置,不需要跟随版本发布。
- 同一项目同一时间只执行一个任务,避免并发修改同一个 workspace;组织级并发上限由部署配置决定。
- 当前由 Educraft 人工创建组织、OWNER、Provider Connection 和初始 Team,并通过服务器上的受控管理命令运维;组织管理台尚未开放。
- 非 OWNER 试点成员通过组织专属 OAuth 首次登录后自动成为 MEMBEROWNER/ADMIN 提权和被移除成员的恢复仍需人工操作。
- Alpha 不提供开放注册、自助密钥管理或跨组织资源共享。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.10",
"version": "0.0.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.10",
"version": "0.0.13",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.10",
"version": "0.0.13",
"private": true,
"type": "module",
"engines": {
+80 -11
View File
@@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { resolveActiveFeishuApplication } from "../../connections/feishuApplicationConnections.js";
import { upsertScopedFeishuIdentity } from "../../feishu/identityNamespace.js";
import { upsertScopedFeishuIdentityInTransaction } from "../../feishu/identityNamespace.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import {
buildAuthorizeUrl,
@@ -164,16 +164,54 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
let userId: string;
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
const identity = await upsertScopedFeishuIdentity(config.prisma, {
connectionId: statePayload.connectionId,
openId: feishuUser.openId,
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
displayName: feishuUser.displayName,
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
const connectionId = statePayload.connectionId;
const organizationId = statePayload.organizationId;
const identity = await config.prisma.$transaction(async (tx) => {
const resolved = await upsertScopedFeishuIdentityInTransaction(tx, {
connectionId,
expectedOrganizationId: organizationId,
openId: feishuUser.openId,
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
displayName: feishuUser.displayName,
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
});
const activeMembership = await tx.organizationMembership.findFirst({
where: {
organizationId,
userId: resolved.userId,
revokedAt: null,
},
select: { id: true },
});
if (activeMembership === null) {
const revokedMembership = await tx.organizationMembership.findFirst({
where: {
organizationId,
userId: resolved.userId,
revokedAt: { not: null },
},
select: { id: true },
});
if (revokedMembership === null) {
await tx.organizationMembership.create({
data: {
organizationId,
userId: resolved.userId,
role: "MEMBER",
},
});
await tx.auditEntry.create({
data: {
organizationId,
actorUserId: resolved.userId,
action: "organization_member.oauth_auto_joined",
metadata: { connectionId: resolved.connectionId, role: "MEMBER" },
},
});
}
}
return resolved;
});
if (identity.organizationId !== statePayload.organizationId) {
throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch");
}
userId = identity.userId;
setSessionCookie(reply, config, {
userId,
@@ -235,6 +273,34 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
return reply.status(204).send();
});
app.get("/auth/feishu/complete", async (request, reply) => {
const query = request.query as { org?: string };
const organizationName = typeof query.org === "string" && query.org.trim() !== ""
? query.org.trim()
: "当前组织";
return reply.type("text/html").send(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Educraft 登录成功</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:25rem;text-align:center}
.ok{font-size:3rem;margin:0 0 .5rem}.hint{color:#646a73;line-height:1.6}
</style>
</head>
<body>
<main class="card">
<p class="ok">✅</p>
<h1>登录并加入组织成功</h1>
<p>你已加入 ${escapeHtml(organizationName)}。</p>
<p class="hint">现在可以关闭本页面,返回飞书群再次 @机器人继续使用。</p>
</main>
</body>
</html>`);
});
app.get("/api/me", async (request, reply) => {
try {
const auth = await requireSession(request, reply, guardDeps);
@@ -363,10 +429,13 @@ async function resolvePostLoginRedirect(
revokedAt: null,
organization: { status: "ACTIVE" },
},
select: { organization: { select: { slug: true } } },
select: { organization: { select: { slug: true, name: true } } },
});
if (intended === null) return "/admin?error=not_an_active_org_member";
const orgRoot = `/admin/org/${intended.organization.slug}`;
if (returnTo === "/admin") {
return `/auth/feishu/complete?org=${encodeURIComponent(intended.organization.name)}`;
}
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
}
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
+68 -8
View File
@@ -85,6 +85,10 @@ interface TriggerDeps {
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined;
readonly projectWorkspaceRoot: string;
/** Public origin used to build the Silo Organization's Feishu OAuth entrypoint. */
readonly publicBaseUrl: string;
/** The single Organization this Alpha Silo is fail-closed to serve. */
readonly siloOrganizationId: string;
/** Alpha Silo policy: never acknowledge work into the process-local queue. */
readonly rejectWhenBusy?: boolean | undefined;
readonly resourceLimits?: {
@@ -133,6 +137,9 @@ export interface TriggerHandler {
export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const projectWorkspaceRoot = deps.projectWorkspaceRoot.trim();
if (projectWorkspaceRoot === "") throw new Error("projectWorkspaceRoot is required");
const publicBaseUrl = parsePublicBaseUrl(deps.publicBaseUrl);
const siloOrganizationId = deps.siloOrganizationId.trim();
if (siloOrganizationId === "") throw new Error("siloOrganizationId is required");
const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
const runAgent = deps.runAgent ?? defaultRunAgent;
const approvalManager = new ApprovalManager();
@@ -936,6 +943,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await sendText(rt, chatId, "无法识别发送者,拒绝触发。", sendOptionsForTriggerMessage(msg));
return;
}
const organizationResolution = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId);
if (organizationResolution.status === "error" && organizationResolution.reason !== "organization_unavailable") {
deps.logger.info(
{ projectId, senderOpenId, reason: organizationResolution.reason },
"feishu trigger: actor onboarding required before project authorization",
);
await sendText(rt, chatId, organizationResolution.message, sendOptionsForTriggerMessage(msg));
return;
}
const actor = { feishuOpenId: senderOpenId, chatId };
const triggerDecision = await authorizer.can({
actor,
@@ -1083,17 +1099,33 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
readonly organizationName: string;
readonly role: "OWNER" | "ADMIN" | "MEMBER";
}
| { readonly status: "error"; readonly message: string }
| {
readonly status: "error";
readonly reason: "identity_missing" | "membership_missing" | "organization_unavailable";
readonly message: string;
}
> {
const siloOrganization = await deps.prisma.organization.findFirst({
where: { id: siloOrganizationId, status: "ACTIVE" },
select: { id: true, slug: true },
});
if (siloOrganization === null) {
return {
status: "error",
reason: "organization_unavailable",
message: "组织当前不可用,请联系 Educraft 运维人员。",
};
}
const identity = await deps.prisma.feishuUserIdentity.findFirst({
where: {
openId: feishuOpenId,
connection: { status: "ACTIVE", organization: { status: "ACTIVE" } },
connection: { status: "ACTIVE", organizationId: siloOrganization.id },
},
select: {
user: { select: { organizationMemberships: {
where: {
revokedAt: null,
organizationId: siloOrganization.id,
organization: { status: "ACTIVE" },
},
select: {
@@ -1109,19 +1141,34 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
where: { feishuOpenId },
select: {
organizationMemberships: {
where: { revokedAt: null, organization: { status: "ACTIVE" } },
where: {
revokedAt: null,
organizationId: siloOrganization.id,
organization: { status: "ACTIVE" },
},
select: { role: true, organization: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" },
},
},
})
: null);
if (user === null) return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
if (user.organizationMemberships.length === 0) {
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" };
if (user === null) {
const loginUrl = new URL(
`/auth/feishu/${encodeURIComponent(siloOrganization.slug)}`,
publicBaseUrl,
).toString();
return {
status: "error",
reason: "identity_missing",
message: `请先通过飞书登录并加入组织:${loginUrl}\n完成后返回群聊重试。`,
};
}
if (user.organizationMemberships.length > 1) {
return { status: "error", message: "你属于多个组织。请先从组织后台选择项目绑定,或等待多组织选择入口。" };
if (user.organizationMemberships.length === 0) {
return {
status: "error",
reason: "membership_missing",
message: "你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
};
}
const membership = user.organizationMemberships[0]!;
return {
@@ -1184,6 +1231,19 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return Object.assign(onMessage, { onCardAction, approvalManager });
}
function parsePublicBaseUrl(raw: string): URL {
const value = raw.trim();
if (value === "") throw new Error("publicBaseUrl is required");
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("publicBaseUrl must use http or https");
}
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.pathname !== "/") {
throw new Error("publicBaseUrl must be an origin without credentials, path, query, or fragment");
}
return url;
}
interface SessionMetadata {
readonly claudeSessionId?: string | undefined;
}
+2
View File
@@ -160,6 +160,8 @@ export async function startHub(): Promise<void> {
settings: runtimeSettings,
logger: app.log,
projectWorkspaceRoot,
publicBaseUrl,
siloOrganizationId: siloOrganization.id,
rejectWhenBusy: true,
resourceLimits: { maxFilesPerMessage, maxBytesPerFile },
allowLegacyFeishuIdentity: false,
+86 -4
View File
@@ -267,9 +267,6 @@ describe("admin auth + org API guards", () => {
openId: "ou_scoped_user",
displayName: "Invited User",
});
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, role: "ADMIN" },
});
await seedTestOrganization("org_scoped_other", "scoped-other");
await prisma.organizationMembership.create({
data: { organizationId: "org_scoped_other", userId: identity.userId, role: "ADMIN" },
@@ -317,7 +314,7 @@ describe("admin auth + org API guards", () => {
expect(me.statusCode).toBe(200);
expect(me.json()).toMatchObject({
user: { id: identity.userId, displayName: "Scoped User" },
organizations: [expect.objectContaining({ slug: "test-default", role: "ADMIN" })],
organizations: [expect.objectContaining({ slug: "test-default", role: "MEMBER" })],
});
expect((me.json() as { organizations: unknown[] }).organizations).toHaveLength(1);
const crossOrganization = await app.inject({
@@ -331,6 +328,32 @@ describe("admin auth + org API guards", () => {
where: { id: identity.identityId },
select: { unionId: true },
})).resolves.toEqual({ unionId: "on_scoped_union" });
await expect(prisma.auditEntry.count({
where: {
organizationId: DEFAULT_ORG_ID,
actorUserId: identity.userId,
action: "organization_member.oauth_auto_joined",
},
})).resolves.toBe(1);
const defaultStart = await app.inject({ method: "GET", url: "/auth/feishu/test-default" });
const defaultAuthorize = new URL(String(defaultStart.headers.location));
const defaultState = defaultAuthorize.searchParams.get("state");
expect(defaultState).not.toBeNull();
const defaultCallback = await app.inject({
method: "GET",
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(defaultState!)}`,
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
});
expect(defaultCallback.statusCode).toBe(302);
expect(defaultCallback.headers.location).toBe(
"/auth/feishu/complete?org=Test%20Default%20Organization",
);
const complete = await app.inject({ method: "GET", url: defaultCallback.headers.location! });
expect(complete.statusCode).toBe(200);
expect(complete.headers["content-type"]).toContain("text/html");
expect(complete.body).toContain("登录并加入组织成功");
expect(complete.body).toContain("Test Default Organization");
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
@@ -340,6 +363,65 @@ describe("admin auth + org API guards", () => {
}
});
it("does not restore a revoked Organization membership during scoped OAuth", async () => {
await seedUser("revoked-owner", "legacy_revoked_owner", "OWNER");
const connections = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
const connection = await connections.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "revoked-owner",
appId: "cli_revoked_oauth",
appSecret: "revoked-oauth-secret",
botOpenId: "ou_revoked_bot",
});
const identity = await upsertScopedFeishuIdentity(prisma, {
connectionId: connection.id,
openId: "ou_revoked_user",
displayName: "Revoked User",
});
await prisma.organizationMembership.create({
data: {
organizationId: DEFAULT_ORG_ID,
userId: identity.userId,
role: "MEMBER",
revokedAt: new Date(),
},
});
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/oauth/token")) {
return Response.json({ code: 0, access_token: "revoked-user-token" });
}
if (url.includes("/user_info")) {
return Response.json({
code: 0,
data: { open_id: "ou_revoked_user", name: "Revoked User" },
});
}
throw new Error(`unexpected ${url}`);
});
const app = await buildApp(fetchImpl as unknown as typeof fetch);
try {
const start = await app.inject({ method: "GET", url: "/auth/feishu/test-default" });
const authorize = new URL(String(start.headers.location));
const state = authorize.searchParams.get("state");
expect(state).not.toBeNull();
const callback = await app.inject({
method: "GET",
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state!)}`,
headers: { cookie: cookiePair(start.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
});
expect(callback.statusCode).toBe(302);
await expect(prisma.organizationMembership.count({
where: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, revokedAt: null },
})).resolves.toBe(0);
await expect(prisma.auditEntry.count({
where: { action: "organization_member.oauth_auto_joined", actorUserId: identity.userId },
})).resolves.toBe(0);
} finally {
await app.close();
}
});
it("unknown org slug returns 404 for admin", async () => {
await seedUser("u-admin", "ou_admin", "ADMIN");
const app = await buildApp();
+151 -3
View File
@@ -3,7 +3,15 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { Readable } from "node:stream";
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
import {
DEFAULT_ORG_ID,
prisma,
resetDb,
mockFeishuRuntime,
seedProject,
seedTestOrganization,
silentLogger,
} from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js";
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
@@ -24,6 +32,8 @@ type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0],
function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
return makeProductionTriggerHandler({
projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: DEFAULT_ORG_ID,
allowLegacyFeishuIdentity: true,
...deps,
});
@@ -386,6 +396,18 @@ describe("trigger full lifecycle (integration)", () => {
role: "EDIT",
},
});
await prisma.user.createMany({
data: [
{ id: "u-speaker-alice", feishuOpenId: "ou_alice", displayName: "Alice" },
{ id: "u-speaker-bob", feishuOpenId: "ou_bob", displayName: "Bob" },
],
});
await prisma.organizationMembership.createMany({
data: [
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-alice", role: "MEMBER" },
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-bob", role: "MEMBER" },
],
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt);
@@ -626,6 +648,66 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs).toHaveLength(0);
});
it("gives an unknown user the scoped login URL in an already-bound chat", async () => {
await seedProject("proj-bound-unknown", "chat-bound-unknown");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
allowLegacyFeishuIdentity: false,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-bound-unknown", "@_user_1 写教案", "ou_bound_unknown"), rt);
expect(rt.sentTexts).toContain(
"请先通过飞书登录并加入组织:https://educraft.example.test/auth/feishu/test-default\n" +
"完成后返回群聊重试。",
);
expect(rt.sentTexts).not.toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0);
});
it("tells a logged-in non-member to contact the administrator in an already-bound chat", async () => {
await seedProject("proj-bound-non-member", "chat-bound-non-member");
await seedScopedIdentityWithoutMembership("bound-non-member", "ou_bound_non_member");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
allowLegacyFeishuIdentity: false,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-bound-non-member", "@_user_1 写教案", "ou_bound_non_member"), rt);
expect(rt.sentTexts).toContain(
"你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
);
expect(rt.sentTexts).not.toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0);
});
it("keeps generic project denial for an Organization member without project permission", async () => {
await seedProject("proj-bound-read-only", "chat-bound-read-only", { role: "READ" });
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-bound-read-only", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("无权限触发。");
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
expect(rt.sentTexts.join("\n")).not.toContain("尚未加入该组织");
expect(runAgentCalls).toHaveLength(0);
});
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"rejects triggers and resume commands when the organization is %s",
async (status) => {
@@ -922,18 +1004,62 @@ describe("trigger full lifecycle (integration)", () => {
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
});
it("does not create a run for unbound chats and asks unknown users to log in", async () => {
it("does not create a run for unbound chats and gives unknown users the scoped login URL", async () => {
await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt);
expect(rt.sentTexts).toContain("请先登录并加入组织后,再绑定项目。");
expect(rt.sentTexts).toContain(
"请先通过飞书登录并加入组织:https://educraft.example.test/auth/feishu/test-default\n" +
"完成后返回群聊重试。",
);
expect(rt.sentCards).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("distinguishes a scoped Feishu identity that has not joined the Silo Organization", async () => {
await seedScopedIdentityWithoutMembership("unbound-non-member", "ou_logged_in_not_member");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
allowLegacyFeishuIdentity: false,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_logged_in_not_member"), rt);
expect(rt.sentTexts).toContain(
"你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
);
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
await expect(prisma.agentRun.count()).resolves.toBe(0);
});
it("encodes the configured Organization slug in the OAuth login URL", async () => {
const encodedOrgId = "org_url_encoding";
await seedTestOrganization(encodedOrgId, "school east/数学?");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
siloOrganizationId: encodedOrgId,
publicBaseUrl: "https://school.example.test/",
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_unknown_encoded"), rt);
expect(rt.sentTexts.join("\n")).toContain(
"https://school.example.test/auth/feishu/school%20east%2F%E6%95%B0%E5%AD%A6%3F",
);
expect(rt.sentTexts.join("\n")).not.toContain("school east/数学?");
});
it("ignores messages without @bot mention", async () => {
await seedProject("proj-5", "chat-5");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
@@ -1430,6 +1556,28 @@ async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER
});
}
async function seedScopedIdentityWithoutMembership(id: string, openId: string): Promise<void> {
const connectionId = `feishu-connection-${id}`;
await prisma.organizationFeishuApplicationConnection.create({
data: {
id: connectionId,
organizationId: DEFAULT_ORG_ID,
appIdentityFingerprint: `fingerprint-${id}`,
status: "ACTIVE",
},
});
await prisma.user.create({
data: {
id: `user-${id}`,
displayName: "Logged in user",
feishuOpenId: `legacy-${openId}`,
feishuIdentities: {
create: { connectionId, openId },
},
},
});
}
async function tempWorkspaceRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-"));
workspaceRoots.push(root);
+2
View File
@@ -110,6 +110,8 @@ describe("Feishu approval cards", () => {
logger: silentLogger(),
authorizer: allowAllAuthorizer(),
projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: "org_test_default",
runAgent: async () => ({
status: "completed",
text: "",
+15
View File
@@ -103,6 +103,8 @@ async function triggerWithRunAgent(
logger: rt.logger,
authorizer: allowAllAuthorizer(),
projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: "org_test_default",
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
@@ -254,6 +256,19 @@ function mockPrisma(): PrismaClient {
const session = { id: "session-1", metadata: {} };
const client = {
organization: {
findFirst: vi.fn(async () => ({ id: "org_test_default", slug: "test-default" })),
},
feishuUserIdentity: {
findFirst: vi.fn(async () => ({
user: {
organizationMemberships: [{
role: "OWNER",
organization: { id: "org_test_default", name: "Test Organization" },
}],
},
})),
},
feishuEventReceipt: {
findUnique: vi.fn(async () => null),
create: vi.fn(async () => ({ id: "receipt-1" })),