feat(hub): 真实飞书读取 + 状态卡片 + 权限 gate + 部署脚本

FeishuRead(ADR-0003): feishuContextTool 从 stub 换成真实 lark
  im.v1.message.get(单条 trigger/reply/status_card)+ list(thread 回复);
  ADR-0003 chat 授权不变式不变(handler 已 gate)。
StatusCard: sendCard + buildRunStatusCard——完成/出错发 interactive card
  (status 文案 + model 选择器 + 取消按钮带 runId),替代纯文本。
Permission(ADR-0004, project-wise): triggerAgent 查 PermissionGrant 对
  project 的 edit+ 能力(manage⊇edit 单调);sender open_id 当 principal
  (子类型学 OPEN,务实选择,permission.ts 标注);per-artifact OPEN;
  settings 组合规则 OPEN。无权限回 '无权限触发'。
Deploy: cph-hub.service systemd unit(ExecStartPre 跑 prisma migrate)+
  install_service.sh(idempotent,seed env)+ deploy_platform.sh
  (rsync + npm ci + build + restart + healthz)。
client.ts 抽 createLarkClient/startFeishuListenerWithClient,tools 与
  ws 共用同一 client。
tsc rc=0;prisma validate 通过;deploy 脚本 bash -n 通过。
This commit is contained in:
2026-07-06 23:44:07 +08:00
parent a4d52e1850
commit ce767e9cab
11 changed files with 433 additions and 30 deletions
+75
View File
@@ -0,0 +1,75 @@
/**
* Permission gate — ADR-0004 in code, project-scope.
*
* `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted
* by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec
* `Role.can_mono`). This module checks that for the run's requester against
* the project resource.
*
* Scope decision (project-wise): the resource is `project`, not
* `project_group` — the run and lock scope to the project (ADR-0002), so the
* capability is checked against the project. per-artifact permission is `OPEN`
* pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping).
*
* principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we
* use the sender's Feishu open_id as the principal string literal — a pragmatic
* choice for the first cut, not a spec decision. When a real principal model
* lands, this is the single seam to change.
*
* settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived
* capabilities from settings policy knobs but doesn't pin the composition
* rule). This gate implements the role half only; settings composition is
* deferred and clearly marked.
*/
import type { PrismaClient } from "@prisma/client";
import type { PermissionRole } from "@prisma/client";
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
export interface PermissionResult {
readonly allowed: boolean;
readonly reason: string;
}
/**
* Check whether `principal` may trigger an agent run on `projectId`.
*
* Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the
* caller replies with the reason. Never throws — a DB error is a deny with the
* error as reason, so the run is not started on a broken state.
*/
export async function canTriggerAgent(
prisma: PrismaClient,
projectId: string,
principal: string,
): Promise<PermissionResult> {
try {
// Look for an active (non-revoked) grant on the project resource for this
// principal, with a role that grants edit or higher.
const grant = await prisma.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
resourceId: projectId,
principal,
role: { in: ["EDIT", "MANAGE"] },
revokedAt: null,
},
select: { id: true, role: true },
});
if (grant !== null) {
return { allowed: true, reason: `granted by role ${grant.role}` };
}
return {
allowed: false,
reason: `no active edit+ grant for ${principal} on project ${projectId}`,
};
} catch (e) {
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
}
}
/// Whether a role grants edit capability (edit or manage). Exported for tests.
export function roleGrantsEdit(role: PermissionRole): boolean {
return EDIT_OR_HIGHER.has(role);
}