Compare commits

..

1 Commits

Author SHA1 Message Date
sjfhsjfh 73e9d258d6 docs(spec): 重写 spec/ 与根 README 的语言与取舍
按新文风(简洁书面中文)重写 spec/ 全部 Lean doc 注释、spec/README,
并顺根 README。核心:讲清产品逻辑、去伪术语、去 ADR 黑话、DRY。

语言:砍钉死/留痕/实现侧/将就/脑补/刻意等伪术语;短句;不复述文件系统
能看到的东西;typst 考据移出 spec 指向 ADR。

内容取舍(动结构):
- System 层大改:删 can_mono 形式化定理、Capability 9 项枚举与
  requiredRole 映射、RunState 6 构造子;Audit.lean 删除并入 System 顶部。
  Hub 未建的部分一律 prose 占位,只留 Lock 的 owner=run 与 WellFormed。
- 澄清两个"检查":产品 checker(LLM 判不了合法性,checker 真跑工具补这块)
  vs 开发时 spec↔impl 一致性检查(无自动闸门)。Oracle 重新定位为
  "checker 得委托外部工具才能判的事实",不是"Lean 没写形式化"。
- spec/README 补取舍判据 checklist(自顶向下逐步细化、不在 Lean 里验证实现)。
- 根 README 去 DRY:删硬编码版本号、cache 路径细节;宪法第 3 条吸收
  "人/coding assistant 核对"修正;第 5 条与 spec/README 判据去重。

保留:Export/Render 执行语义、Info 的 raw→canonical 设计模式(产品语义,
只顺文风不砍结构);renderIgnoredSeverity(实现对齐依赖)。

lake build 通过(24 jobs)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 03:51:56 +08:00
149 changed files with 383 additions and 23674 deletions
-132
View File
@@ -1,132 +0,0 @@
name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half.
on:
push:
pull_request:
workflow_dispatch:
jobs:
hub-check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: paradigm
POSTGRES_PASSWORD: paradigm
POSTGRES_DB: cph_hub_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U paradigm -d cph_hub_test"
--health-interval 5s
--health-timeout 5s
--health-retries 20
defaults:
run:
working-directory: hub
steps:
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: hub/package-lock.json
- name: Install dependencies
run: npm ci
- name: Wait for Postgres
run: |
node <<'NODE'
const net = require("node:net");
const deadline = Date.now() + 60000;
function tryConnect() {
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 });
socket.once("connect", () => {
socket.end();
process.exit(0);
});
socket.once("error", () => {
socket.destroy();
if (Date.now() > deadline) {
console.error("Postgres did not become reachable at 127.0.0.1:5432");
process.exit(1);
}
setTimeout(tryConnect, 1000);
});
}
tryConnect();
NODE
# Prisma client must be generated before tsc can check imports from
# @prisma/client. Stub DATABASE_URL so prisma generate works.
- name: Generate Prisma client
run: DATABASE_URL="postgresql://stub:stub@127.0.0.1:5432/stub" npx prisma generate --schema prisma/schema.prisma
- name: Type-check
run: npx tsc -p tsconfig.json --noEmit
- name: Build
run: npm run build
- name: Validate Prisma schema
run: DATABASE_URL="postgresql://stub:stub@127.0.0.1:5432/stub" npx prisma validate --schema prisma/schema.prisma
- name: Install cph binary
run: |
cd ..
cargo install --path crates/cph-cli --locked
- name: Run unit tests
run: npx vitest run test/unit
# Integration tests need PostgreSQL + cph. cph is installed above.
# PostgreSQL is set up as a service container below.
- name: Run integration tests (mock provider, real prisma + cph)
run: |
npx prisma migrate deploy --schema prisma/schema.prisma
npx vitest run test/integration --exclude test/integration/real-model.test.ts
env:
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
- name: Smoke health endpoint
run: |
PORT=8878 \
NODE_ENV=production \
DATABASE_URL=postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test \
ANTHROPIC_AUTH_TOKEN=smoke \
FEISHU_APP_ID=cli_smoke \
FEISHU_APP_SECRET=smoke_secret \
FEISHU_BOT_OPEN_ID=ou_smoke \
HUB_FEISHU_LISTENER_ENABLED=false \
npm start &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT
for attempt in $(seq 1 30); do
if curl --fail --silent "http://127.0.0.1:8878/api/healthz" >/dev/null; then
exit 0
fi
if ! kill -0 "$pid" 2>/dev/null; then
echo "hub server exited before health check passed"
wait "$pid"
exit 1
fi
sleep 1
done
curl --fail --silent --show-error "http://127.0.0.1:8878/api/healthz" >/dev/null
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
# OPENROUTER_API_KEY when a branch should hit live OpenRouter.
- name: Run real-model integration tests (optional, needs secret)
run: npx vitest run test/integration/real-model.test.ts
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
RUN_REAL_MODEL_TESTS: ${{ vars.RUN_REAL_MODEL_TESTS }}
-3
View File
@@ -11,8 +11,5 @@
# regenerable, not for VCS. The embedded engine mounts cph-render directly.
render/vendor/local-packages/
# Node (hub/ TS workspace and any future JS package)
node_modules/
# OS / editor
.DS_Store
-25
View File
@@ -1,25 +0,0 @@
# AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
-5
View File
@@ -6,11 +6,6 @@
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律
+43 -43
View File
@@ -1,74 +1,74 @@
# curriculum-project-hub
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个结构化的工程文件;
LLM assistant 协助编辑它;一个 rule-based checker 校验其合法性并给出有用的诊断。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
LLM 提效,但判不了一节课合不合法(它不能真去跑 typst、不能可靠地断言数据合不合
schema);checker 真跑工具、给确定性诊断,补上这块。两者一起才是完整闭环。目标是把教研
从一次性的文档,变成可累积、可校验、可复用的资产。
## 安装 `cph` 命令行
这是一个 monorepo。组织方式本身就表达了一条原则:`spec/` 是上游的语义母本,其余部件是
向它对齐的实现。
当前版本 **0.0.2**。从源码安装(本仓库根目录):
## 安装 `cph`
从源码安装(本仓库根目录):
```sh
cargo install --path crates/cph-cli --locked
```
`render` 包已嵌入二进制(build.rs 编译期拷入 + `include_dir!`),所以装好后**无需任何环境变量、不依赖源码树**即可用。渲染包按版本解压到 per-user cache(`~/.cache/cph/render-<version>/` 或 macOS `~/Library/Caches/cph/...`);开发时可设 `CPH_RENDER_DIR` 指向 live `render/` 目录覆盖。
`render` 包已嵌入二进制,装好后无需任何环境变量、不依赖源码树即可用。开发时可设
`CPH_RENDER_DIR` 指向 live `render/` 目录覆盖。
```sh
cph --version # cph 0.0.2
cph check <工程目录> # 校验合法性(7 类诊断)
cph check <工程目录> # 校验合法性
cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF
cph completions zsh > ~/.zfunc/_cph # shell 补全(可选)
```
**版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
Shell 补全(可选):
```sh
cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
```
工程文件根放一个 `.cph-version` 文件,声明它面向的 cph 版本;`cph` 加载时比对自身版本,
不相容则拒绝(版本契约,ADR-0016)。
## 仓库布局
```
README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
(hub/ exporter/ …) ← 将来的其他部件,平级于 spec/。尚未创建
```
`spec/` 是 Lean 语义母本(自包含 Lean 工程,见 `spec/README.md`);`crates/` 是实现
(rule-based checker,向 spec 对齐,见 `crates/README.md`);`render/` 是 typst 渲染包;
`examples/` 是样例工程文件;`docs/adr/` 是架构决策记录,被 spec 引用。
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。
`spec/` 与实现部件物理分离、平级共存:谁是上游、谁向谁对齐,一眼可见。实现部件共用一个
仓库根的 cargo workspace,使基础 crate 能被未来部件复用。
## 宪法
这 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
1. **角色 —— Lean 是研发侧的上游参照。**
`spec/` 用 Lean 写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来。
1. **角色——Lean 是研发侧的上游参照。**
`spec/` 用 Lean 写,是开发者 coding assistant 共用的 spec 工具,用来沉淀产品各部件
的语义。它不进产品运行时——运行时那个 checker 用什么技术实现还没定,但它的语义先在
`spec/` 里固定下来。
2. **对齐机制 —— Lean 只做上游参照。**
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
2. **对齐机制——Lean 只做上游参照。**
不做 extract / codegen,不派生 conformance test,CI 里没有 spec→实现的 gate。实现对齐
spec,靠开发者 review 和 agent 巡逻 diff 这个人肉环节承载。(CI 里的 spec check 只验
spec 自身能否 type-check,即契约内部良构,不是 spec↔实现对齐检查。)
3. **资产性 —— 由 review 纪律承载,无机器兜底。**
这份仓库给的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档。
3. **资产性——由核对纪律承载,无机器兜底。**
这份仓库给的是精确、自洽、机器验内部良构的语义共识,不是实现正确性保证。spec 与
实现是否一致,没有自动闸门,要靠人工或 coding assistant 不定期(或每次改动后)核对;
坚持核对,它就是有用的参照,否则只会变成过期的文档。
4. **形态 —— 它是人机共识的契约。**
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞。
4. **形态——它是人机共识的契约。**
契约必须自包含:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——
type checker 会逼这份契约在结构上无洞。
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
5. **深度判据——只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:不写明,开发者与 agent 会不会各自做出不同
假设?会,就进契约;显然的东西、纯基础设施、普通 CRUD 字段,不进(写进去只稀释信噪比、
增加维护面)。深度上限不是 Lean 的表达力,而是你愿意在每次实现变更时手动回头同步的量。
进了之后钉到多细,见 `spec/README.md` 的取舍判据。
## CI
`.gitea/workflows/spec-check.yml`每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
每次 push / PR `spec/` 下跑 `lake build`,确保契约始终 type-check 通过。这是良构 gate,
见宪法第 2 条。
@@ -26,6 +26,4 @@ The group can stay open while no Claude processing is active. A teacher leaving
- Multi-open is natural: a teacher participates in multiple project groups.
- Normal group discussion does not occupy the project.
- "Exit project" should not be the normal action in the group workflow.
- 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.
- Project rebinding or group archival needs explicit product rules, separate from run cancellation.
@@ -1,67 +0,0 @@
# ADR 0017: Agent Runtime — Claude Code SDK via OpenRouter
## Status
Accepted.
## Context
The original ADR-0017 mandated a provider-agnostic agent layer: any
OpenAI-compatible model via OpenRouter, custom agent loop built on Vercel AI
SDK's `streamText`. The motivation was to avoid vendor lock-in.
In practice, the hand-rolled agent loop lacked capabilities that a production
agent needs: context compaction (long conversations), tool approval flows,
partial-message streaming, and a battle-tested multi-turn loop. Building these
ourselves meant re-implementing what the Claude Code SDK already provides.
The Feishu integration also proved costly to hand-roll — card schema quirks,
patch limitations, file support — each API quirk cost a round-trip.
## Decision
Adopt `@anthropic-ai/claude-agent-sdk` as the agent runtime, routed through
OpenRouter's **"Anthropic Skin"** (`https://openrouter.ai/api`).
OpenRouter exposes an Anthropic Messages API-compatible endpoint. Claude Code
SDK speaks its native protocol directly to OpenRouter — no proxy, no format
conversion. The SDK's `query()` owns the agent loop (tool dispatch, compaction,
streaming). Built-in tools (Read, Write, Bash, Glob, Grep) cover the entire
tool surface — no custom tools needed. `cph check` / `cph build` run via Bash.
The Hub `AgentSession` is provider/role/model-bound, and it must persist the
provider runtime cursor needed to continue a conversation. For Claude Code SDK,
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call as
`options.resume`. Role is part of the session binding because role prompts and
tool surfaces can differ even when the underlying model is the same; `/draft`
and `/review` must not resume the same Claude runtime cursor by accident.
Environment variables:
```
ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN=<OpenRouter API key>
ANTHROPIC_API_KEY="" # must be explicitly empty
```
Model routing: `ANTHROPIC_DEFAULT_SONNET_MODEL` etc. accept OpenRouter model
IDs (e.g. `z-ai/glm-4.7`, `anthropic/claude-sonnet-4-20250514`). This gives
limited OpenRouter-level model routing, but the runtime is still Claude Agent
SDK-shaped: non-Claude models are best-effort and may not support every
Claude Code feature.
## Consequences
- Provider-agnosticism is not fully preserved. OpenRouter can route to GLM,
Claude, GPT, etc., but the runtime protocol and agent loop remain Claude
Agent SDK-bound.
- The agent loop is Claude Code SDK's (compaction, tool approval, partial
message streaming) — not hand-rolled.
- Custom tools (read_file, write_file, cph_check, cph_build) are replaced by
the SDK's built-in Read/Write/Bash/Glob/Grep.
- The SSE event format matches Claude-to-IM's expected protocol natively.
- Per-run model selection works via role→model mapping; models are OpenRouter
IDs, not Anthropic-only aliases.
- OpenRouter recommends setting Anthropic as the top-priority provider for
Claude Code; non-Anthropic models may have reduced compatibility with some
SDK features (e.g. thinking blocks).
@@ -1,166 +0,0 @@
# ADR 0018: The Agent Execution Surface Is Bounded By The Project Workspace
## Status
Accepted, and implemented. The mechanism is settled: the Claude Code SDK's
built-in sandbox (bubblewrap on Linux, seatbelt on macOS) confines the agent
process and its Bash subprocesses at the OS level. `bypassPermissions`
remains (headless server — no interactive prompts), but the sandbox is the
hard boundary, not the permission-prompt layer. Constitution rule 4
divergence (ADR-0017's unbounded tools) is resolved.
Builds on **ADR-0001** (project group as collaboration space), **ADR-0002**
(project lock scoped to `run_id`), **ADR-0004** (permission grants govern
`triggerAgent`), **ADR-0007** (the engineering file is a directory tree on
disk), and **ADR-0017** (Claude Code SDK as the agent runtime).
## Context
ADR-0001/0002/0004 together cover *collaboration governance* up to the
moment a teacher `@Claude`s: who may trigger an `AgentRun`, that the run
holds a project-scoped lock, that the group is a long-lived space. They stop
at `triggerAgent`. What the agent may do *after* the trigger — which files it
may read or write, what commands it may run — is not named in any ADR, not
modeled in `spec/`, and not enforced by any contract-level invariant.
The gap is not theoretical. ADR-0017 adopted the Claude Code SDK with
`allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"]` and
`permissionMode: "bypassPermissions"`. Under that configuration the SDK's
built-in tools operate over the whole host filesystem with no confinement, and
`Bash` executes arbitrary shell with no approval gate. The Hub's
`hub/src/agent/workspace.ts` was *intended* to be "the agent's only file
surface" (its module doc says so) and ships a `confine()` path validator that
rejects `..` and absolute escapes. But ADR-0017's cutover left those custom
tools unwired — the SDK's built-in tools replaced them — so `confine()` is
dead code and the boundary it was meant to enforce does not exist at runtime.
Compounding this, the systemd unit's default `SERVICE_USER` is `root`
(`hub/deploy/install_service.sh`), so the unbounded agent runs as root.
This is a real divergence point by the constitution's test: "if unwritten,
will the developer and the agent make different assumptions?" They already
have. ADR-0017 assumed the boundary was an implementation concern; the
`workspace.ts` author assumed it was a contract. The next person to touch the
runner has nothing telling them the boundary must be enforced, and ADR-0017
silently overrode the one that existed.
## Decision
### The agent execution surface is bounded by the run's project workspace
During an `AgentRun`, every file operation the agent performs — read, write,
glob, grep target — must fall **within the run's project workspace directory**
(the on-disk engineering-file tree of ADR-0007). Operations whose target path
escapes that tree are refused. This is a platform core invariant, pinned in
`Spec.System.AgentSurface`.
The boundary is **per-run, anchored to the run's project**, the same scope as
the Lock (ADR-0002). This keeps the two invariants composable:
- `Lock` (ADR-0002) bounds **concurrency** — who may be modifying the project.
- `AgentSurface` (this ADR) bounds **blast radius** — what the agent may touch.
Both scope to `run × project`. Acquiring the lock and entering the surface
happen together at run start; releasing the lock and leaving the surface
happen together at run termination.
### Shell execution is subject to the same boundary, by effect
A shell command the agent runs executes with the workspace as its working
directory (already true for the `cph` subprocess, ADR-0016) and its **file
effects** — files read, written, created, or deleted — are subject to the same
workspace boundary. The agent may invoke **named external tools** whose
binaries live outside the workspace (notably `cph` via `CPH_BIN`); the
boundary governs *file effects*, not *binary location*. Arbitrary
`cat /etc/shadow` or `curl … > /etc/...` is out of bounds regardless of how it
is invoked.
### The Hub process runs as a non-privileged service user
The systemd unit must not run as `root`. The default in
`hub/deploy/install_service.sh` changes from `root` to a dedicated unprivileged
user. This is a deployment invariant, recorded here because the unbounded-agent
risk is compounded by root; it is enforced in the unit, not in `spec/` (it is
not a semantic divergence between developer and agent — it is operational
discipline).
### The mechanism: SDK sandbox (OS-level, per agent process)
The boundary is enforced by the Claude Code SDK's built-in sandbox
(bubblewrap on Linux, seatbelt on macOS), configured via `query()` options in
`hub/src/agent/runner.ts`:
- `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed.
- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes confined to the
workspace (the ADR-0007 directory tree). Reads outside are allowed by
default (the agent may read system files for context), but sensitive host
paths are denied via `denyRead` (`/etc`, `/root`, `~/.ssh`, `~/.aws`, …).
- `sandbox.filesystem.denyRead: SENSITIVE_READ_PATHS` — defense-in-depth;
extends via `CPH_SANDBOX_EXTRA_DENY_READ` for deployment-specific secrets.
- Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the
sandbox is the hard boundary, not the permission-prompt layer. This is
subprocess-level, not process-level: the Hub host process is not sandboxed
(it manages DB, Feishu ws, logs), only the Claude Code agent process and its
children. This keeps the sandbox config surface small and lets future
external CLIs (Gitea, Lark) run inside the sandbox by giving their binaries
read access, without widening the Hub's own filesystem surface.
The contract pins the **invariant** (operations stay in the workspace), not
the **mechanism**. The SDK sandbox is the current mechanism; switching to a
different one (tool wrappers, container) would not be a spec change as long
as it upholds `AgentFileOp.Authorized`.
## Consequences
- `Spec.System.AgentSurface` gains an `AgentFileOp` structure and an
`Authorized` predicate mirroring `Memory.McpReadRequest.Authorized`: the op
carries a `run` and a `path`; `Authorized` holds iff the path falls within
the run's workspace, via platform-provided `runWorkspace` and `pathWithin`
(representations `OPEN`).
- `hub/src/agent/workspace.ts`'s `confine()` intent is now contract-backed.
Re-wiring it (or replacing it with an OS sandbox) is **mandatory**, not a
preference. Its current dead-code status under ADR-0017 is a divergence to
be addressed, not silently retained.
- ADR-0017's `bypassPermissions` + unrestricted built-in tools is a
**divergence from this ADR** and is surfaced as such (constitution rule 4).
The reconciliation — restrict the SDK tool set, wrap Bash, or sandbox at the
OS level — is the open mechanism question above. Whichever is chosen must
uphold the `AgentSurface` invariant.
- Inbound file/image delivery (`hub/src/feishu/trigger.ts`) lands files under
`${workspaceDir}/.cph/inbox/`, which is inside the boundary — consistent, no
change to the delivery path.
- The `cph` subprocess (ADR-0016) already runs with `cwd = workspaceDir`
(`hub/src/agent/cph.ts`), so it is inside the boundary by construction.
## Open Questions / Deferred
- **Network egress.** Decided: network is **open** in the initial
production deployment. The sandbox confines filesystem writes to the
workspace and denies reads of sensitive host paths, but does not restrict
outbound network. Rationale: future Gitea/Lark CLI integration needs
unconstrained network egress; `cph build` may fetch typst packages. The
exfiltration risk (agent `curl`s workspace content out) is accepted as a
tradeoff for CLI integration simplicity. This is revisitable — a network
allowlist (`sandbox.network.allowedDomains`) can be added without a spec
change if the threat model tightens.
- **Sensitive read path coverage.** `SENSITIVE_READ_PATHS` in `runner.ts`
covers `/etc`, `/root`, `/var/log`, `/proc`, `/sys`, and common
credential dirs (`~/.ssh`, `~/.aws`, `~/.config/gcloud`, `~/.gnupg`).
Deployments with additional secret locations extend via
`CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated). Whether this set should be
spec-pinned rather than implementation-chosen is `OPEN` — it's plumbing, not
a semantic divergence, so currently left out of `spec/`.
- **Shell command vocabulary.** Whether the agent's Bash is restricted to a
whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose
*effects* are then confined, is a policy choice left to implementation. The
invariant governs effects either way; current choice is arbitrary commands
(sandbox confines effects).
- **Per-user rate limiting and token budgets.** Orthogonal to the surface
boundary (this ADR is about *what* the agent may touch, not *how often* it
may run). Deferred to a separate concern.
- **Inbound file size / MIME limits.** The inbox lives inside the boundary,
but unbounded file size is a DoS vector distinct from the boundary
invariant. Deferred.
@@ -1,129 +0,0 @@
# ADR 0019: Resolve Actors to Principal Sets for Team Permissions
## Status
Accepted.
## Context
ADR-0004 deliberately chose a Feishu Docs-like permission model:
`grant(resource, principal, role)` plus separate resource settings. The first
Hub implementation stored `principal` as an opaque string and the Feishu trigger
used the sender's `open_id` directly. That was enough for individual teacher
grants, but not for production collaboration:
- a curriculum team needs one grant to apply to many teachers;
- Feishu departments, user groups, and project chats should be grantable
principals;
- role-specific agent grants (`/review`, `/draft`) must compose with project
permissions consistently;
- audit/debug output must explain which principal and grant allowed or denied a
run.
## Decision
Introduce typed principals and resolve every actor to a principal set before
authorization.
### Principal types
`PrincipalType` is:
- `USER`: a Feishu user, identified by `feishuOpenId`.
- `TEAM`: a Hub-managed teacher team.
- `FEISHU_CHAT`: a Feishu chat/group.
- `FEISHU_DEPARTMENT`: a Feishu department.
- `FEISHU_USER_GROUP`: a Feishu user group.
- `APP`: an integration or bot principal.
`PermissionGrant` and `RoleTriggerGrant` store `principalType` and
`principalId`. Legacy opaque principal strings are backfilled as
`USER/<old principal>`.
### Team membership
Hub teams are first-class. A user can be a direct active member of a team. A
team can also be bound to external Feishu principals: chat, department, or user
group. If an actor resolves to any bound external principal, the actor also
resolves to that Hub team.
Membership changes are effective immediately because authorization resolves the
principal set at request time.
### External Feishu principal sync
Feishu department, user group, and chat memberships are not scattered through
callers. They are synchronized into `ExternalPrincipalMembership` rows and then
read by `PrincipalResolver`. The Feishu message context can also contribute the
current `FEISHU_CHAT` principal directly because receiving a group event is
already contextual proof that the actor is speaking in that group.
### Grant merging
Authorization evaluates all active grants matching any resolved principal for
the requested resource. There is no explicit deny in this ADR. Among matching
grants, the highest role wins:
```text
READ < EDIT < MANAGE
```
Action thresholds:
- project read requires `READ`.
- project edit requires `EDIT`.
- collaborator management requires `MANAGE`.
- agent trigger requires `EDIT`.
- normal agent cancel requires `MANAGE`.
`PermissionSettings` never grants access beyond grants. Settings only constrain
an already-granted capability. For `agentTrigger`:
- missing setting or `ROLE` means role-derived grants decide;
- `MANAGE_ONLY` raises the threshold to `MANAGE`;
- `DISABLED` denies the action.
Other settings keep their existing string storage, but the same rule applies:
settings are policy constraints, not positive grants.
### Role-trigger composition
Role-trigger grants are a second gate after the project-level `agent.trigger`
gate:
1. The actor must pass project `agent.trigger` for the project.
2. If no `RoleTriggerGrant` row has ever existed for `(projectId, roleId)`,
the role is open for backward compatibility.
3. Once any row exists for `(projectId, roleId)`, including a revoked row, the
role is configured. The actor must match an active role grant by any resolved
principal.
This keeps `/review` restrictions orthogonal to ordinary edit access while
preventing a fully revoked role from silently reopening.
### Module seam
Callers use a single authorization module:
```ts
authorizer.can({
actor,
action,
resource,
roleId,
})
```
The caller does not know how users, teams, Feishu departments, user groups, or
chats expand. `PrincipalResolver` owns that implementation, and
`PermissionAuthorizer` owns grant/settings/role-trigger composition.
## Consequences
- Teacher teams become production-grade principals rather than UI sugar.
- Feishu external organization concepts can be synchronized and granted without
leaking Feishu-specific checks into the trigger path.
- A single audit decision can report actor, principal set, matched grant, and
matched role grant.
- Backward compatibility is explicit: old principal strings become user
principals, and unconfigured role grants remain open.
@@ -1,92 +0,0 @@
# ADR 0020: Organization Is The SaaS Tenant Root
## Status
Accepted.
## Context
The Hub is moving from a single-organization deployment assumption toward a
SaaS shape. The existing permission model already supports team principals via
`PermissionGrant(PROJECT, TEAM, role)`, but without a tenant root the data model
cannot safely answer:
- which customer owns a project;
- which organization a team belongs to;
- whether a team grant crosses customer boundaries;
- which external directory sync produced a Feishu department/user-group
membership;
- where future org admin, billing, audit, and platform break-glass controls
attach.
Retrofitting this after projects, teams, runs, and audit records grow would
force tenant assumptions into every caller.
## Decision
Introduce `Organization` as the SaaS tenant root.
Customer-side collaboration data is organization-scoped:
```text
Organization
OrganizationMembership
Team
TeamMembership
Project
ExternalDirectoryConnection
PermissionGrant
```
`User` remains global. A user can belong to multiple organizations through
`OrganizationMembership`. Project-level authorization still uses
`PermissionGrant` and `PermissionAuthorizer`; the authorizer derives the
organization from the requested resource, then resolves teams and external
principals in that organization scope.
`Team` belongs to exactly one organization. `Project` belongs to exactly one
organization. A `PermissionGrant` that grants a `TEAM` principal on a `PROJECT`
resource is valid only when the team and project are in the same organization.
This invariant is pinned in `Spec.System.Organization`.
External directory membership is scoped through `ExternalDirectoryConnection`:
```text
ExternalDirectoryConnection(
organizationId,
provider,
providerTenantId,
source
)
```
`ExternalPrincipalMembership` points at a connection rather than carrying a
free-floating `source` string. `source` names a sync pipeline; it is not the
tenant identity.
The SaaS platform control plane is separate from customer project permissions.
`READ/EDIT/MANAGE` remains the customer-side project permission lattice.
Platform staff access, tenant suspension, billing, and break-glass access are a
separate control plane and must not be modeled as project `MANAGE`.
## Consequences
- Existing single-org data is backfilled into a default organization during
migration.
- `Team.slug` becomes organization-scoped instead of globally unique.
- Actor resolution is organization-aware: direct teams and synchronized external
principals only expand inside the resource's organization.
- Direct `USER` grants remain possible because a global user can collaborate
across organizations when explicitly granted.
- Future org admin UI can be built on this model without changing the
authorization core.
## Open Questions / Deferred
- Full platform admin UI, billing, limits, and tenant suspension workflows are
deferred.
- Whether direct `USER` project grants should require active
`OrganizationMembership` is deferred; explicit direct grants remain allowed
for now.
- Provider-specific directory semantics beyond Feishu are deferred, but the
connection model leaves room for them.
@@ -1,102 +0,0 @@
# 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.
-1
View File
@@ -1 +0,0 @@
Hello, World!
-51
View File
@@ -1,51 +0,0 @@
# Hub runtime configuration. Copy to .env and fill in.
# PostgreSQL connection string.
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# Claude Code SDK auth via OpenRouter's Anthropic Skin.
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key (https://openrouter.ai/keys)
# ANTHROPIC_API_KEY must be explicitly empty to avoid auth conflicts.
ANTHROPIC_BASE_URL="https://openrouter.ai/api"
ANTHROPIC_AUTH_TOKEN=""
ANTHROPIC_API_KEY=""
# Model override (optional). Defaults to anthropic/claude-sonnet-5.
# Use an OpenRouter model slug, for example:
# ANTHROPIC_DEFAULT_SONNET_MODEL="~anthropic/claude-sonnet-latest"
# To use GLM:
# ANTHROPIC_DEFAULT_SONNET_MODEL="z-ai/glm-4.7"
# ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7"
# ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6"
# Agent run policy (optional). Defaults to 25.
# HUB_AGENT_MAX_TURNS=25
# System-managed root for project workspaces created from org admin / Feishu onboarding.
HUB_PROJECT_WORKSPACE_ROOT="./data/project-workspaces"
# Feishu (lark) bot credentials.
FEISHU_APP_ID=""
FEISHU_APP_SECRET=""
FEISHU_BOT_OPEN_ID=""
# Path to the `cph` binary (ADR-0016). Defaults to `cph` on PATH.
# CPH_BIN="/usr/local/bin/cph"
# Hub server port. Defaults to 8788.
PORT=8788
# --- Org admin web (ADR-0021) ---------------------------------------------
# Public base URL of this Hub (no trailing slash). Used for Feishu OAuth
# redirect_uri = ${HUB_PUBLIC_BASE_URL}/auth/feishu/callback
# Configure the same callback URL in the Feishu developer console under
# Security Settings → Redirect URLs.
HUB_PUBLIC_BASE_URL="http://127.0.0.1:8788"
# HMAC secret for signed session + OAuth state cookies. Generate with:
# openssl rand -base64 32
HUB_SESSION_SECRET=""
# Optional OAuth scope (space-separated). Default: contact:user.base:readonly
# Apply matching scopes in the Feishu developer console.
# HUB_OAUTH_SCOPE="contact:user.base:readonly"
-6
View File
@@ -1,6 +0,0 @@
node_modules/
dist/
*.log
.env
.env.*
!.env.example
-29
View File
@@ -1,29 +0,0 @@
[Unit]
Description=Curriculum Project Hub (Feishu agent + cph)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
WorkingDirectory=__HUB_DIR__
EnvironmentFile=__ENV_FILE__
# Run prisma migrations before each start, then launch the Hub.
ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma
ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js
Restart=on-failure
RestartSec=5
# Graceful shutdown: lark ws close + in-flight runs.
KillSignal=SIGTERM
TimeoutStopSec=30
# ADR-0018: the agent sandbox (bubblewrap) must be available on the host —
# failIfUnavailable makes the Hub refuse to run unsandboxed. Install bwrap.
AssertPathExists=/usr/bin/bwrap
# Hardening: no new privileges. ProtectSystem/ProtectHome are NOT set here —
# they would break cph's render-cache extraction (~/.cache/cph/) and the
# bwrap user-namespace setup. The OS-level sandbox (ADR-0018) is the hard
# boundary; systemd hardening is kept minimal to avoid interfering with it.
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# Deploy the Hub to a host: rsync the repo, install deps, build, restart service.
#
# Configure these env vars (e.g. in CI secrets):
# PLATFORM_DEPLOY_HOST required
# PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user)
# PLATFORM_DEPLOY_USER optional, defaults to deploy
# PLATFORM_DEPLOY_PORT optional, defaults to 22
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, bubblewrap
# (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE.
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and
# writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh.
set -euo pipefail
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
PORT="${PLATFORM_DEPLOY_PORT:-22}"
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
HEALTH_URL="${PLATFORM_DEPLOY_HEALTH_URL:-http://127.0.0.1:8788/api/healthz}"
HUB_DIR="$BASE/hub"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new)
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'"
# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host).
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps + build on the host.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run build"
# 3. Ensure the service is installed (idempotent), then restart.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
set -euo pipefail
if [ \"\$(id -u)\" = \"0\" ]; then
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
systemctl restart cph-hub.service
systemctl is-active --quiet cph-hub.service
else
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
sudo -n systemctl restart cph-hub.service
sudo -n systemctl is-active --quiet cph-hub.service
fi
for attempt in \$(seq 1 30); do
if curl --fail --silent '$HEALTH_URL' >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
curl --fail --silent --show-error '$HEALTH_URL' >/dev/null
"
echo "Deployed cph-hub to $HOST ($HUB_DIR)"
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
# Install the cph-hub systemd service on a host. Idempotent.
#
# Prerequisites already on the host: Node.js, npm, PostgreSQL, systemd.
# The Hub repo is expected at HUB_DIR (default /srv/curriculum-project-hub/hub)
# with `npm ci` + `npm run build` already run by deploy_platform.sh.
#
# Usage:
# sudo BASE=/srv/curriculum-project-hub bash deploy/install_service.sh
set -euo pipefail
BASE="${BASE:-/srv/curriculum-project-hub}"
HUB_DIR="${HUB_DIR:-$BASE/hub}"
SERVICE_USER="${SERVICE_USER:-cph-hub}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8788}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -z "$NODE_BIN" ]; then
echo "node must be on PATH, or set NODE_BIN." >&2
exit 1
fi
if [ ! -d "$HUB_DIR" ]; then
echo "HUB_DIR not found: $HUB_DIR (set HUB_DIR or clone the repo there)." >&2
exit 1
fi
if [ ! -f "$HUB_DIR/dist/server.js" ]; then
echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2
exit 1
fi
if [ ! -f "$SCRIPT_DIR/cph-hub.service" ]; then
echo "service template missing: $SCRIPT_DIR/cph-hub.service" >&2
exit 1
fi
# Seed the env file if absent. Secrets stay on the server, never in the repo.
if [ ! -f "$ENV_FILE" ]; then
install -d -m 0700 "$(dirname "$ENV_FILE")"
cat >"$ENV_FILE" <<EOF
NODE_ENV=production
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
# Claude Code SDK auth via OpenRouter's Anthropic Skin (ADR-0017).
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key; ANTHROPIC_API_KEY must be empty.
ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN=
ANTHROPIC_API_KEY=
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_BOT_OPEN_ID=
PORT=$PORT
HOST=$HOST
EOF
chmod 0600 "$ENV_FILE"
echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run."
exit 0
fi
# Render the unit with concrete paths.
UNIT=/etc/systemd/system/cph-hub.service
TMP_UNIT="$(mktemp)"
sed \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__BASE_DIR__|$BASE|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
install -m 0644 "$TMP_UNIT" "$UNIT"
rm -f "$TMP_UNIT"
systemctl daemon-reload
systemctl enable cph-hub.service
echo "Installed cph-hub.service. Start with: systemctl start cph-hub"
echo "Check health: curl http://127.0.0.1:$PORT/api/healthz"
-4713
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
{
"name": "@paradigm/hub",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.66.1",
"@prisma/client": "^6.19.3",
"ai": "^7.0.16",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"zod": "^4.4.3"
},
"devDependencies": {
"prisma": "^6.19.3",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^4.1.10"
},
"description": "Curriculum Project Hub — Feishu-group collaboration + Claude Code SDK agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
"scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "npm run prisma:migrate && node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit",
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
"deploy": "bash deploy/deploy_platform.sh",
"test": "vitest run",
"test:watch": "vitest"
}
}
@@ -1,271 +0,0 @@
-- CreateEnum
CREATE TYPE "PlatformRole" AS ENUM ('ADMIN', 'TEACHER');
-- CreateEnum
CREATE TYPE "AgentRunStatus" AS ENUM ('ACTIVE', 'WAITING_FOR_USER', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELED');
-- CreateEnum
CREATE TYPE "AgentEntrypoint" AS ENUM ('FEISHU', 'WEB', 'CLI');
-- CreateEnum
CREATE TYPE "PermissionRole" AS ENUM ('READ', 'EDIT', 'MANAGE');
-- CreateEnum
CREATE TYPE "PermissionResourceType" AS ENUM ('PROJECT', 'ARTIFACT', 'PROJECT_GROUP');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"feishuOpenId" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlatformRoleAssignment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "PlatformRole" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "PlatformRoleAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"workspaceDir" TEXT NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectGroupBinding" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectGroupBinding_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentSession" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"model" TEXT NOT NULL,
"title" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "AgentSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentRun" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"sessionId" TEXT,
"requestedByUserId" TEXT,
"entrypoint" "AgentEntrypoint" NOT NULL,
"status" "AgentRunStatus" NOT NULL DEFAULT 'ACTIVE',
"prompt" TEXT NOT NULL,
"model" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"summary" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"metadata" JSONB NOT NULL,
"error" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AgentRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectAgentLock" (
"projectId" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"holderUserId" TEXT,
"acquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
CONSTRAINT "ProjectAgentLock_pkey" PRIMARY KEY ("projectId")
);
-- CreateTable
CREATE TABLE "PermissionGrant" (
"id" TEXT NOT NULL,
"resourceType" "PermissionResourceType" NOT NULL,
"resourceId" TEXT NOT NULL,
"principal" TEXT NOT NULL,
"role" "PermissionRole" NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "PermissionGrant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PermissionSettings" (
"id" TEXT NOT NULL,
"resourceType" "PermissionResourceType" NOT NULL,
"resourceId" TEXT NOT NULL,
"externalShare" TEXT NOT NULL,
"comment" TEXT NOT NULL,
"copyDownload" TEXT NOT NULL,
"collaboratorMgmt" TEXT NOT NULL,
"agentTrigger" TEXT NOT NULL,
"agentCancel" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PermissionSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditEntry" (
"id" TEXT NOT NULL,
"runId" TEXT,
"actorUserId" TEXT,
"action" TEXT NOT NULL,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_feishuOpenId_key" ON "User"("feishuOpenId");
-- CreateIndex
CREATE INDEX "PlatformRoleAssignment_userId_revokedAt_idx" ON "PlatformRoleAssignment"("userId", "revokedAt");
-- CreateIndex
CREATE INDEX "PlatformRoleAssignment_role_revokedAt_idx" ON "PlatformRoleAssignment"("role", "revokedAt");
-- CreateIndex
CREATE INDEX "Project_archivedAt_idx" ON "Project"("archivedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectGroupBinding_projectId_key" ON "ProjectGroupBinding"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectGroupBinding_chatId_key" ON "ProjectGroupBinding"("chatId");
-- CreateIndex
CREATE INDEX "ProjectGroupBinding_chatId_idx" ON "ProjectGroupBinding"("chatId");
-- CreateIndex
CREATE INDEX "AgentSession_projectId_archivedAt_idx" ON "AgentSession"("projectId", "archivedAt");
-- CreateIndex
CREATE INDEX "AgentSession_provider_model_idx" ON "AgentSession"("provider", "model");
-- CreateIndex
CREATE INDEX "AgentSession_updatedAt_idx" ON "AgentSession"("updatedAt");
-- CreateIndex
CREATE INDEX "AgentRun_projectId_status_idx" ON "AgentRun"("projectId", "status");
-- CreateIndex
CREATE INDEX "AgentRun_sessionId_idx" ON "AgentRun"("sessionId");
-- CreateIndex
CREATE INDEX "AgentRun_requestedByUserId_idx" ON "AgentRun"("requestedByUserId");
-- CreateIndex
CREATE INDEX "AgentRun_updatedAt_idx" ON "AgentRun"("updatedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectAgentLock_runId_key" ON "ProjectAgentLock"("runId");
-- CreateIndex
CREATE INDEX "ProjectAgentLock_expiresAt_idx" ON "ProjectAgentLock"("expiresAt");
-- CreateIndex
CREATE INDEX "PermissionGrant_resourceType_resourceId_revokedAt_idx" ON "PermissionGrant"("resourceType", "resourceId", "revokedAt");
-- CreateIndex
CREATE INDEX "PermissionGrant_principal_revokedAt_idx" ON "PermissionGrant"("principal", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "PermissionGrant_resourceType_resourceId_principal_role_revo_key" ON "PermissionGrant"("resourceType", "resourceId", "principal", "role", "revokedAt");
-- CreateIndex
CREATE INDEX "PermissionSettings_resourceType_resourceId_idx" ON "PermissionSettings"("resourceType", "resourceId");
-- CreateIndex
CREATE UNIQUE INDEX "PermissionSettings_resourceType_resourceId_key" ON "PermissionSettings"("resourceType", "resourceId");
-- CreateIndex
CREATE INDEX "AuditEntry_runId_idx" ON "AuditEntry"("runId");
-- CreateIndex
CREATE INDEX "AuditEntry_actorUserId_idx" ON "AuditEntry"("actorUserId");
-- CreateIndex
CREATE INDEX "AuditEntry_createdAt_idx" ON "AuditEntry"("createdAt");
-- AddForeignKey
ALTER TABLE "PlatformRoleAssignment" ADD CONSTRAINT "PlatformRoleAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_requestedByUserId_fkey" FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PermissionSettings" ADD CONSTRAINT "PermissionSettings_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,27 +0,0 @@
-- CreateTable
CREATE TABLE "RoleTriggerGrant" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"principal" TEXT NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "RoleTriggerGrant_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RoleTriggerGrant_projectId_roleId_revokedAt_idx" ON "RoleTriggerGrant"("projectId", "roleId", "revokedAt");
-- CreateIndex
CREATE INDEX "RoleTriggerGrant_principal_revokedAt_idx" ON "RoleTriggerGrant"("principal", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key" ON "RoleTriggerGrant"("projectId", "roleId", "principal", "revokedAt");
-- AddForeignKey
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,23 +0,0 @@
-- FeishuEventReceipt: idempotency for inbound ws events (at-least-once redelivery).
CREATE TABLE "FeishuEventReceipt" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"messageId" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FeishuEventReceipt_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "FeishuEventReceipt_eventId_key" ON "FeishuEventReceipt"("eventId");
CREATE INDEX "FeishuEventReceipt_eventType_idx" ON "FeishuEventReceipt"("eventType");
CREATE INDEX "FeishuEventReceipt_messageId_idx" ON "FeishuEventReceipt"("messageId");
CREATE INDEX "FeishuEventReceipt_receivedAt_idx" ON "FeishuEventReceipt"("receivedAt");
-- AuditEntry: add projectId so audit points before/without a run (permission
-- denials, slash commands) are locatable to a project.
ALTER TABLE "AuditEntry" ADD COLUMN "projectId" TEXT;
CREATE INDEX "AuditEntry_projectId_createdAt_idx" ON "AuditEntry"("projectId", "createdAt");
ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,45 +0,0 @@
-- AgentMessage: per-message record of the agent loop (queryable projection
-- alongside the transcript JSONL file, which remains the agent's read-back memory).
CREATE TABLE "AgentMessage" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"runId" TEXT,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachments" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgentMessage_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AgentMessage_sessionId_createdAt_idx" ON "AgentMessage"("sessionId", "createdAt");
CREATE INDEX "AgentMessage_runId_idx" ON "AgentMessage"("runId");
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AgentFileChange: file change captured during a run (write/rename/delete).
CREATE TABLE "AgentFileChange" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"path" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"beforeHash" TEXT,
"afterHash" TEXT,
"diff" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgentFileChange_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AgentFileChange_runId_idx" ON "AgentFileChange"("runId");
CREATE INDEX "AgentFileChange_projectId_idx" ON "AgentFileChange"("projectId");
CREATE INDEX "AgentFileChange_path_idx" ON "AgentFileChange"("path");
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ProjectAgentLock: add heartbeatAt for liveness (A-5).
ALTER TABLE "ProjectAgentLock" ADD COLUMN "heartbeatAt" TIMESTAMP(3);
@@ -1,143 +0,0 @@
-- ADR-0019: typed principals, Hub teams, and external Feishu principal sync.
CREATE TYPE "PrincipalType" AS ENUM (
'USER',
'TEAM',
'FEISHU_CHAT',
'FEISHU_DEPARTMENT',
'FEISHU_USER_GROUP',
'APP'
);
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Team_slug_key" ON "Team"("slug");
CREATE INDEX "Team_archivedAt_idx" ON "Team"("archivedAt");
CREATE TABLE "TeamMembership" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "TeamMembership_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "TeamMembership_teamId_revokedAt_idx" ON "TeamMembership"("teamId", "revokedAt");
CREATE INDEX "TeamMembership_userId_revokedAt_idx" ON "TeamMembership"("userId", "revokedAt");
CREATE UNIQUE INDEX "TeamMembership_teamId_userId_revokedAt_key" ON "TeamMembership"("teamId", "userId", "revokedAt");
CREATE UNIQUE INDEX "TeamMembership_active_key" ON "TeamMembership"("teamId", "userId") WHERE "revokedAt" IS NULL;
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "TeamExternalBinding" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"principalType" "PrincipalType" NOT NULL,
"principalId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "TeamExternalBinding_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "TeamExternalBinding_principalType_principalId_revokedAt_idx" ON "TeamExternalBinding"("principalType", "principalId", "revokedAt");
CREATE INDEX "TeamExternalBinding_teamId_revokedAt_idx" ON "TeamExternalBinding"("teamId", "revokedAt");
CREATE UNIQUE INDEX "TeamExternalBinding_teamId_principalType_principalId_revokedAt_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId", "revokedAt");
CREATE UNIQUE INDEX "TeamExternalBinding_active_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
ALTER TABLE "TeamExternalBinding" ADD CONSTRAINT "TeamExternalBinding_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "ExternalPrincipalMembership" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"principalType" "PrincipalType" NOT NULL,
"principalId" TEXT NOT NULL,
"source" TEXT NOT NULL,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ExternalPrincipalMembership_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ExternalPrincipalMembership_userId_revokedAt_idx" ON "ExternalPrincipalMembership"("userId", "revokedAt");
CREATE INDEX "ExternalPrincipalMembership_principalType_principalId_revokedAt_idx" ON "ExternalPrincipalMembership"("principalType", "principalId", "revokedAt");
CREATE INDEX "ExternalPrincipalMembership_source_syncedAt_idx" ON "ExternalPrincipalMembership"("source", "syncedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_userId_principalType_principalId_source_revokedAt_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source", "revokedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_active_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source") WHERE "revokedAt" IS NULL;
ALTER TABLE "ExternalPrincipalMembership" ADD CONSTRAINT "ExternalPrincipalMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- PermissionGrant: backfill legacy opaque principal strings as USER principals.
ALTER TABLE "PermissionGrant" DROP CONSTRAINT IF EXISTS "PermissionGrant_resourceId_fkey";
DROP INDEX IF EXISTS "PermissionGrant_resourceType_resourceId_principal_role_revo_key";
DROP INDEX IF EXISTS "PermissionGrant_principal_revokedAt_idx";
ALTER TABLE "PermissionGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
ALTER TABLE "PermissionGrant" ADD COLUMN "principalId" TEXT;
UPDATE "PermissionGrant" SET "principalId" = "principal";
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalId" SET NOT NULL;
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalType" DROP DEFAULT;
ALTER TABLE "PermissionGrant" DROP COLUMN "principal";
WITH ranked_permission_grants AS (
SELECT
"id",
row_number() OVER (
PARTITION BY "resourceType", "resourceId", "principalType", "principalId", "role"
ORDER BY "createdAt", "id"
) AS rn
FROM "PermissionGrant"
WHERE "revokedAt" IS NULL
)
DELETE FROM "PermissionGrant"
WHERE "id" IN (
SELECT "id" FROM ranked_permission_grants WHERE rn > 1
);
CREATE INDEX "PermissionGrant_principalType_principalId_revokedAt_idx" ON "PermissionGrant"("principalType", "principalId", "revokedAt");
CREATE UNIQUE INDEX "PermissionGrant_active_key" ON "PermissionGrant"("resourceType", "resourceId", "principalType", "principalId", "role") WHERE "revokedAt" IS NULL;
-- PermissionSettings resource is polymorphic; do not FK resourceId to Project.
ALTER TABLE "PermissionSettings" DROP CONSTRAINT IF EXISTS "PermissionSettings_resourceId_fkey";
-- RoleTriggerGrant: same backfill as PermissionGrant.
DROP INDEX IF EXISTS "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key";
DROP INDEX IF EXISTS "RoleTriggerGrant_principal_revokedAt_idx";
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalId" TEXT;
UPDATE "RoleTriggerGrant" SET "principalId" = "principal";
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalId" SET NOT NULL;
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalType" DROP DEFAULT;
ALTER TABLE "RoleTriggerGrant" DROP COLUMN "principal";
WITH ranked_role_trigger_grants AS (
SELECT
"id",
row_number() OVER (
PARTITION BY "projectId", "roleId", "principalType", "principalId"
ORDER BY "createdAt", "id"
) AS rn
FROM "RoleTriggerGrant"
WHERE "revokedAt" IS NULL
)
DELETE FROM "RoleTriggerGrant"
WHERE "id" IN (
SELECT "id" FROM ranked_role_trigger_grants WHERE rn > 1
);
CREATE INDEX "RoleTriggerGrant_principalType_principalId_revokedAt_idx" ON "RoleTriggerGrant"("principalType", "principalId", "revokedAt");
CREATE UNIQUE INDEX "RoleTriggerGrant_active_key" ON "RoleTriggerGrant"("projectId", "roleId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
@@ -1,9 +0,0 @@
-- ADR-0017 refinement: Hub sessions are provider/role/model-bound.
-- Existing sessions predate role-aware binding; treat them as draft sessions.
ALTER TABLE "AgentSession" ADD COLUMN "roleId" TEXT NOT NULL DEFAULT 'draft';
ALTER TABLE "AgentSession" ALTER COLUMN "roleId" DROP DEFAULT;
CREATE INDEX "AgentSession_provider_roleId_model_idx" ON "AgentSession"("provider", "roleId", "model");
CREATE INDEX "AgentSession_projectId_provider_roleId_model_archivedAt_idx" ON "AgentSession"("projectId", "provider", "roleId", "model", "archivedAt");
DROP INDEX "AgentSession_provider_model_idx";
@@ -1,6 +0,0 @@
-- Record real provider-reported run cost for /cost reporting.
-- No backfill: pre-existing test runs remain cost-unrecorded by design.
ALTER TABLE "AgentRun" ADD COLUMN "costUsd" DECIMAL(18, 8);
ALTER TABLE "AgentRun" ADD COLUMN "costSource" TEXT;
CREATE INDEX "AgentRun_projectId_finishedAt_idx" ON "AgentRun"("projectId", "finishedAt");
@@ -1,145 +0,0 @@
-- ADR-0020: organization is the SaaS tenant root.
-- Backfill all existing data into one default organization so old databases
-- upgrade without a manual bootstrap step.
CREATE TYPE "OrganizationStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'ARCHIVED');
CREATE TYPE "OrganizationMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" "OrganizationStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
CREATE INDEX "Organization_status_idx" ON "Organization"("status");
INSERT INTO "Organization" ("id", "slug", "name", "status")
VALUES ('org_default', 'legacy-default', 'Legacy Default Organization', 'ACTIVE');
CREATE TABLE "OrganizationMembership" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "OrganizationMembership_pkey" PRIMARY KEY ("id")
);
INSERT INTO "OrganizationMembership" ("id", "organizationId", "userId", "role")
SELECT
'orgmem_default_' || md5("User"."id"),
'org_default',
"User"."id",
CASE
WHEN EXISTS (
SELECT 1 FROM "PlatformRoleAssignment"
WHERE "PlatformRoleAssignment"."userId" = "User"."id"
AND "PlatformRoleAssignment"."role" = 'ADMIN'
AND "PlatformRoleAssignment"."revokedAt" IS NULL
)
THEN 'OWNER'::"OrganizationMemberRole"
ELSE 'MEMBER'::"OrganizationMemberRole"
END
FROM "User";
CREATE INDEX "OrganizationMembership_organizationId_revokedAt_idx" ON "OrganizationMembership"("organizationId", "revokedAt");
CREATE INDEX "OrganizationMembership_userId_revokedAt_idx" ON "OrganizationMembership"("userId", "revokedAt");
CREATE UNIQUE INDEX "OrganizationMembership_organizationId_userId_revokedAt_key" ON "OrganizationMembership"("organizationId", "userId", "revokedAt");
CREATE UNIQUE INDEX "OrganizationMembership_active_key" ON "OrganizationMembership"("organizationId", "userId") WHERE "revokedAt" IS NULL;
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT;
UPDATE "Project" SET "organizationId" = 'org_default';
ALTER TABLE "Project" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX "Project_organizationId_archivedAt_idx" ON "Project"("organizationId", "archivedAt");
ALTER TABLE "Team" ADD COLUMN "organizationId" TEXT;
UPDATE "Team" SET "organizationId" = 'org_default';
ALTER TABLE "Team" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "Team" ADD CONSTRAINT "Team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DROP INDEX IF EXISTS "Team_slug_key";
CREATE INDEX "Team_organizationId_archivedAt_idx" ON "Team"("organizationId", "archivedAt");
CREATE INDEX "Team_organizationId_slug_archivedAt_idx" ON "Team"("organizationId", "slug", "archivedAt");
CREATE UNIQUE INDEX "Team_active_slug_key" ON "Team"("organizationId", "slug") WHERE "archivedAt" IS NULL;
CREATE TABLE "ExternalDirectoryConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerTenantId" TEXT,
"source" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ExternalDirectoryConnection_pkey" PRIMARY KEY ("id")
);
INSERT INTO "ExternalDirectoryConnection" ("id", "organizationId", "provider", "source")
SELECT DISTINCT
'extconn_default_' || md5("source"),
'org_default',
'FEISHU',
"source"
FROM "ExternalPrincipalMembership";
CREATE UNIQUE INDEX "ExternalDirectoryConnection_organizationId_provider_source_key" ON "ExternalDirectoryConnection"("organizationId", "provider", "source");
CREATE INDEX "ExternalDirectoryConnection_organizationId_revokedAt_idx" ON "ExternalDirectoryConnection"("organizationId", "revokedAt");
CREATE INDEX "ExternalDirectoryConnection_provider_providerTenantId_idx" ON "ExternalDirectoryConnection"("provider", "providerTenantId");
ALTER TABLE "ExternalDirectoryConnection" ADD CONSTRAINT "ExternalDirectoryConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DROP INDEX IF EXISTS "ExternalPrincipalMembership_userId_principalType_principalId_source_revokedAt_key";
DROP INDEX IF EXISTS "ExternalPrincipalMembership_active_key";
DROP INDEX IF EXISTS "ExternalPrincipalMembership_source_syncedAt_idx";
ALTER TABLE "ExternalPrincipalMembership" ADD COLUMN "connectionId" TEXT;
UPDATE "ExternalPrincipalMembership"
SET "connectionId" = 'extconn_default_' || md5("source");
ALTER TABLE "ExternalPrincipalMembership" ALTER COLUMN "connectionId" SET NOT NULL;
ALTER TABLE "ExternalPrincipalMembership" DROP COLUMN "source";
ALTER TABLE "ExternalPrincipalMembership" ADD CONSTRAINT "ExternalPrincipalMembership_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "ExternalDirectoryConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX "ExternalPrincipalMembership_connectionId_revokedAt_idx" ON "ExternalPrincipalMembership"("connectionId", "revokedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_userId_principalType_principalId_connectionId_revokedAt_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "connectionId", "revokedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_active_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "connectionId") WHERE "revokedAt" IS NULL;
-- A principal should have one active role on a resource. Keep the strongest
-- grant and revoke lower active grants to preserve history without ambiguity.
DROP INDEX IF EXISTS "PermissionGrant_active_key";
WITH ranked_permission_grants AS (
SELECT
"id",
row_number() OVER (
PARTITION BY "resourceType", "resourceId", "principalType", "principalId"
ORDER BY
CASE "role"
WHEN 'MANAGE' THEN 3
WHEN 'EDIT' THEN 2
WHEN 'READ' THEN 1
END DESC,
"createdAt" ASC,
"id" ASC
) AS rn
FROM "PermissionGrant"
WHERE "revokedAt" IS NULL
)
UPDATE "PermissionGrant"
SET "revokedAt" = CURRENT_TIMESTAMP
WHERE "id" IN (
SELECT "id" FROM ranked_permission_grants WHERE rn > 1
);
CREATE UNIQUE INDEX "PermissionGrant_active_principal_key" ON "PermissionGrant"("resourceType", "resourceId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
@@ -1,92 +0,0 @@
-- 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;
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
-581
View File
@@ -1,581 +0,0 @@
// Prisma schema for Curriculum Project Hub.
//
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// legacy teaching-material-host-service schema, each deliberate:
//
// - AgentSession is provider/model-bound. Provider runtime cursors such as
// Claude SDK `session_id` live in `metadata`, so switching model still means
// a new Hub session while same-session runs can resume provider context.
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
// distinct from platform UserRole (admin/teacher) — legacy conflated them.
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
// user/chat targets into one binding table.
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
// completeness OPEN — add states without a schema migration war).
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --- Platform identity ---------------------------------------------------
/// ADR-0020: SaaS tenant root. Every project and team belongs to exactly one
/// organization; platform operator access remains outside project roles.
model Organization {
id String @id @default(cuid())
slug String @unique
name String
status OrganizationStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships OrganizationMembership[]
projectSettings OrganizationProjectSettings?
folders Folder[]
projects Project[]
teams Team[]
externalDirectoryConnections ExternalDirectoryConnection[]
@@index([status])
}
enum OrganizationStatus {
ACTIVE
SUSPENDED
ARCHIVED
}
/// Org-scoped platform role. Distinct from project PermissionRole and from
/// global PlatformRoleAssignment, which is reserved for SaaS/platform control.
model OrganizationMembership {
id String @id @default(cuid())
organizationId String
userId String
role OrganizationMemberRole @default(MEMBER)
createdAt DateTime @default(now())
revokedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId, revokedAt])
@@index([organizationId, revokedAt])
@@index([userId, revokedAt])
}
enum OrganizationMemberRole {
OWNER
ADMIN
MEMBER
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
organizationId String @id
membersCanCreateProjects Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
model User {
id String @id @default(cuid())
feishuOpenId String @unique
displayName String
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
platformRoles PlatformRoleAssignment[]
organizationMemberships OrganizationMembership[]
createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
}
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
/// `admin` is the only override path for force-release (spec RequiresAdmin).
model PlatformRoleAssignment {
id String @id @default(cuid())
userId String
role PlatformRole
createdAt DateTime @default(now())
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt])
@@index([role, revokedAt])
}
enum PlatformRole {
ADMIN
TEACHER
}
/// ADR-0019: typed principals for permission grants and actor resolution.
/// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external
/// principals by their Feishu ids.
enum PrincipalType {
USER
TEAM
FEISHU_CHAT
FEISHU_DEPARTMENT
FEISHU_USER_GROUP
APP
}
/// Hub-managed teacher team. A team is a first-class permission principal.
model Team {
id String @id @default(cuid())
organizationId String
slug String
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
memberships TeamMembership[]
externalBindings TeamExternalBinding[]
@@index([organizationId, archivedAt])
@@index([organizationId, slug, archivedAt])
}
/// Direct Hub team membership. External Feishu groups can also map to teams via
/// TeamExternalBinding; both sources are resolved at authorization time.
model TeamMembership {
id String @id @default(cuid())
teamId String
userId String
createdAt DateTime @default(now())
revokedAt DateTime?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([teamId, userId, revokedAt])
@@index([teamId, revokedAt])
@@index([userId, revokedAt])
}
/// Bind a Hub team to an external Feishu principal. When an actor resolves to
/// the external principal, the actor also resolves to this team.
model TeamExternalBinding {
id String @id @default(cuid())
teamId String
principalType PrincipalType
principalId String
createdAt DateTime @default(now())
revokedAt DateTime?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, principalType, principalId, revokedAt])
@@index([principalType, principalId, revokedAt])
@@index([teamId, revokedAt])
}
/// Locally synchronized Feishu external principal membership.
model ExternalDirectoryConnection {
id String @id @default(cuid())
organizationId String
provider String
providerTenantId String?
source String
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
revokedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
memberships ExternalPrincipalMembership[]
@@unique([organizationId, provider, source])
@@index([organizationId, revokedAt])
@@index([provider, providerTenantId])
}
/// Locally synchronized Feishu external principal membership, scoped through
/// an organization-owned ExternalDirectoryConnection.
model ExternalPrincipalMembership {
id String @id @default(cuid())
userId String
connectionId String
principalType PrincipalType
principalId String
syncedAt DateTime @default(now())
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
connection ExternalDirectoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
@@unique([userId, principalType, principalId, connectionId, revokedAt])
@@index([userId, revokedAt])
@@index([connectionId, revokedAt])
@@index([principalType, principalId, revokedAt])
}
// --- Project & Feishu binding (ADR-0001) ---------------------------------
/// ADR-0021: transparent project explorer folder. Folders are org-scoped
/// navigation/aggregation nodes, not permission resources; project grants stay
/// attached to PROJECT resources.
model Folder {
id String @id @default(cuid())
organizationId String
parentId String?
name String
sortKey String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent Folder? @relation("folderTree", fields: [parentId], references: [id], onDelete: Restrict)
children Folder[] @relation("folderTree")
projects Project[]
@@index([organizationId, parentId, archivedAt])
@@index([organizationId, parentId, sortKey])
}
model Project {
id String @id @default(cuid())
organizationId String
folderId String?
name String
workspaceDir String
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBindings ProjectGroupBinding[]
agentSessions AgentSession[]
agentRuns AgentRun[]
agentLock ProjectAgentLock?
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
@@index([organizationId, archivedAt])
@@index([folderId, archivedAt])
@@index([archivedAt])
}
/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
/// (1:1). Historical archived bindings are retained for audit; partial unique
/// indexes in migrations enforce one active binding per project and per chat.
model ProjectGroupBinding {
id String @id @default(cuid())
projectId String
chatId String
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@index([projectId, archivedAt])
@@index([chatId, archivedAt])
}
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
/// ADR-0017: session is provider/role/model-bound. `provider` + `roleId` +
/// `model` capture the binding; provider-specific runtime cursors live in
/// `metadata` (e.g. metadata.claudeSessionId). Same provider+role+model ⇒
/// reuse across runs (ADR-0002); a switch ⇒ new Hub session.
model AgentSession {
id String @id @default(cuid())
projectId String
provider String
roleId String
model String
title String?
metadata Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
runs AgentRun[]
messages AgentMessage[] @relation("sessionMessages")
@@index([projectId, archivedAt])
@@index([provider, roleId, model])
@@index([projectId, provider, roleId, model, archivedAt])
@@index([updatedAt])
}
/// spec RunState: active/waitingForUser/completed/failed/timedOut/canceled.
/// Enum completeness OPEN (Run.lean:12) — adding a state is a value add, not a
/// spec breach. DB mirrors the current enum.
enum AgentRunStatus {
ACTIVE
WAITING_FOR_USER
COMPLETED
FAILED
TIMED_OUT
CANCELED
}
enum AgentEntrypoint {
FEISHU
WEB
CLI
}
model AgentRun {
id String @id @default(cuid())
projectId String
sessionId String?
requestedByUserId String?
entrypoint AgentEntrypoint
status AgentRunStatus @default(ACTIVE)
prompt String
model String
provider String
summary String?
inputTokens Int?
outputTokens Int?
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
costUsd Decimal? @db.Decimal(18, 8)
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
costSource String?
metadata Json
error String?
startedAt DateTime @default(now())
finishedAt DateTime?
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
projectLock ProjectAgentLock?
messages AgentMessage[] @relation("runMessages")
fileChanges AgentFileChange[] @relation("runFileChanges")
@@index([projectId, status])
@@index([projectId, finishedAt])
@@index([sessionId])
@@index([requestedByUserId])
@@index([updatedAt])
}
/// ADR-0002: lock owner = run_id (not session/user/chat). `projectId @id` ⇒
/// at most one lock per project (LockTable exclusivity). `runId @unique` ⇒
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
/// app-level invariant checked on read/write, not a DB constraint.
model ProjectAgentLock {
projectId String @id
runId String @unique
holderUserId String?
acquiredAt DateTime @default(now())
heartbeatAt DateTime?
expiresAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
@@index([expiresAt])
}
// --- Permission grants & settings (ADR-0004) ----------------------------
/// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice).
/// Distinct from PlatformRole. Force-release is admin-only, outside this
/// lattice (spec RequiresAdmin).
enum PermissionRole {
READ
EDIT
MANAGE
}
/// ADR-0004 resource_type: project | artifact | project_group. The resource
/// id's meaning is determined by its type (artifact id semantics align to the
/// Courseware half; OPEN here).
enum PermissionResourceType {
PROJECT
ARTIFACT
PROJECT_GROUP
}
/// ADR-0004 PermissionGrant: resource × principal × role.
/// ADR-0019 replaces the old opaque `principal` with typed
/// `principalType/principalId` so user/team/Feishu principals compose through
/// the same authorization path.
model PermissionGrant {
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
principalType PrincipalType
principalId String
role PermissionRole
createdByUserId String?
createdAt DateTime @default(now())
revokedAt DateTime?
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@index([resourceType, resourceId, revokedAt])
@@index([principalType, principalId, revokedAt])
}
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
/// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
model PermissionSettings {
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
externalShare String
comment String
copyDownload String
collaboratorMgmt String
agentTrigger String
agentCancel String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([resourceType, resourceId])
@@index([resourceType, resourceId])
}
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
/// /review vs /draft). Two gates in series — both must pass.
///
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
/// data, not a code enum). ADR-0019 makes the principal typed. A project-scoped
/// row grants the role on that project;
/// the compound unique covers "one active grant per (project, principal, role)".
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
model RoleTriggerGrant {
id String @id @default(cuid())
projectId String
roleId String
principalType PrincipalType
principalId String
createdByUserId String?
createdAt DateTime @default(now())
revokedAt DateTime?
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@index([projectId, roleId, revokedAt])
@@index([principalType, principalId, revokedAt])
}
// --- Audit (ADR Audit, content OPEN) -------------------------------------
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
model AuditEntry {
id String @id @default(cuid())
runId String?
projectId String?
actorUserId String?
action String
metadata Json
createdAt DateTime @default(now())
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
@@index([runId])
@@index([projectId, createdAt])
@@index([actorUserId])
@@index([createdAt])
}
// --- Feishu event dedup --------------------------------------------------
/// Idempotency receipt for inbound Feishu ws events. The lark ws client may
/// redeliver an event (at-least-once); without dedup a duplicate would spawn a
/// second AgentRun — the lock would refuse it, but a FAILED run row + a busy
/// reply would still leak. `eventId @unique` makes the second insert a no-op
/// signal: the handler checks existence before processing.
model FeishuEventReceipt {
id String @id @default(cuid())
eventId String @unique
eventType String
messageId String?
receivedAt DateTime @default(now())
@@index([eventType])
@@index([messageId])
@@index([receivedAt])
}
// --- Structured run history (ADR-0003 Memory) ----------------------------
/// Per-message record of the agent loop. The transcript JSONL file remains the
/// agent's own read-back memory (runner loads it to seed the next run); these
/// rows are the queryable/auditable projection for admin APIs. ADR-0003:
/// session messages ≠ Feishu group chat history.
model AgentMessage {
id String @id @default(cuid())
sessionId String
runId String?
role String
content String
attachments Json
createdAt DateTime @default(now())
session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade)
run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull)
@@index([sessionId, createdAt])
@@index([runId])
}
/// File change captured during a run (writeFile/rename/delete in workspace).
/// before/after hash + diff for audit and admin review. Linked to the run
/// that produced it; the tool's execute closure records via a sink the runner
/// injects into ToolContext (A-3 file-change capture).
model AgentFileChange {
id String @id @default(cuid())
runId String
projectId String
path String
operation String
beforeHash String?
afterHash String?
diff String?
metadata Json
createdAt DateTime @default(now())
run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade)
project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade)
@@index([runId])
@@index([projectId])
@@index([path])
}
-124
View File
@@ -1,124 +0,0 @@
/**
* 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
@@ -1,137 +0,0 @@
/**
* 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
@@ -1,116 +0,0 @@
/**
* 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
@@ -1,47 +0,0 @@
/**
* 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
@@ -1,47 +0,0 @@
/**
* 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
@@ -1,85 +0,0 @@
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
@@ -1,293 +0,0 @@
/**
* 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
@@ -1,228 +0,0 @@
/**
* 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
@@ -1,104 +0,0 @@
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
@@ -1,104 +0,0 @@
/**
* 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
@@ -1,80 +0,0 @@
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
@@ -1,140 +0,0 @@
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);
}
});
}
-23
View File
@@ -1,23 +0,0 @@
export function formatRunCostLine(costUsd: number | undefined): string {
return costUsd === undefined
? "本次成本: 未记录"
: `本次成本: ${formatUsd(costUsd)}`;
}
export function formatInteger(value: number): string {
return new Intl.NumberFormat("en-US").format(value);
}
export function formatUsd(value: number): string {
const digits = value > 0 && value < 0.01 ? 4 : 2;
return `$${value.toFixed(digits)}`;
}
export function decimalToNumberOrNull(value: unknown): number | null {
if (value === null || value === undefined) return null;
const numberValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numberValue)) {
throw new Error("invalid cost value");
}
return numberValue;
}
-105
View File
@@ -1,105 +0,0 @@
/**
* `cph` subprocess tools - the Hub<->Courseware coupling point.
*
* The Hub agent does not parse engineering-file models in-process; it calls the
* stable `cph` CLI (ADR-0016 version contract). `cph check` validates a
* project; `cph build` renders a target artifact. Version compatibility is the
* CLI's responsibility - it refuses incompatible `.cph-version` files with a
* `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the
* subprocess and returns its stdout/stderr/exit to the model.
*
* The `cph` binary path is configurable (env `CPH_BIN`, default `cph` on
* PATH). All subprocesses run with `cwd = ctx.workspaceDir` so paths in
* diagnostics are relative to the project root.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { tool } from "ai";
import { z } from "zod";
import type { ToolContext, ToolDefinition } from "./tools.js";
const execFileAsync = promisify(execFile);
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
const DEFAULT_TIMEOUT_MS = 120_000;
interface ExecResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
}
async function runCph(args: readonly string[], workspaceDir: string): Promise<ExecResult> {
try {
const { stdout, stderr } = await execFileAsync(CPH_BIN, args, {
cwd: workspaceDir,
timeout: DEFAULT_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
return { exitCode: 0, stdout, stderr };
} catch (e) {
// execFile rejects on non-zero exit; the error carries stdout/stderr/code.
const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number | string };
if (err.code === "ENOENT") {
return {
exitCode: 127,
stdout: "",
stderr: `cph binary not found at "${CPH_BIN}" (set CPH_BIN env or install cph on PATH)`,
};
}
return {
exitCode: typeof err.code === "number" ? err.code : 1,
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
};
}
}
/// `cph check <dir>` - validate a curriculum engineering file. Returns the
/// checker's diagnostics (stdout) for the model to act on. Non-zero exit means
/// the file has error diagnostics (ADR-0010); the output is still returned,
/// not thrown - the model needs to see the diagnostics to fix them.
export function cphCheckTool(ctx: ToolContext): ToolDefinition {
return tool({
description:
"Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version...). Non-zero exit means error diagnostics present - read the output to fix them.",
inputSchema: z.object({
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
}),
execute: async (args): Promise<string> => {
const target = args.path ?? ".";
const res = await runCph(["check", target], ctx.workspaceDir);
return JSON.stringify({
exitCode: res.exitCode,
stdout: res.stdout,
stderr: res.stderr,
});
},
});
}
/// `cph build <dir> --target <T> -o <out>` - render a target artifact. The
/// target (student/teacher/...) determines the build (ADR-0009). Output path
/// is relative to the workspace unless absolute.
export function cphBuildTool(ctx: ToolContext): ToolDefinition {
return tool({
description:
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
inputSchema: z.object({
target: z.string().describe("Build target, e.g. 'student', 'teacher'."),
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
output: z.string().describe("Output file path (relative to workspace or absolute).").optional(),
}),
execute: async (args): Promise<string> => {
const target = args.path ?? ".";
const out = args.output ?? `build/${args.target}.pdf`;
const res = await runCph(["build", target, "--target", args.target, "-o", out], ctx.workspaceDir);
return JSON.stringify({
exitCode: res.exitCode,
stdout: res.stdout,
stderr: res.stderr,
output: out,
});
},
});
}
-5
View File
@@ -1,5 +0,0 @@
export {
createDefaultModelRegistry,
defaultSonnetModel,
type Env,
} from "../settings/runtime.js";
-109
View File
@@ -1,109 +0,0 @@
/**
* Per-run model selection & role-based routing.
*
* ADR-0017 consequence: role-based model routing (different run kinds default
* to different models) is a **product/admin configuration** concern, not a spec
* invariant. This registry is the seam for that config; the concrete policy
* (which models are admin-enabled, which role maps to which default) is `OPEN`
* here — decided by admin settings + ADR, not hard-coded.
*/
/**
* A named role preset — the full per-run agent bundle. Roles are **data**, not
* a code enum: admin/teachers define them (ADR-0017: role-based routing is
* product config, not a spec invariant). `roleId` is an opaque string and
* doubles as the slash-command name (`/draft ...`) — the registry holds the
* role set, so new roles are added by configuration, not by editing code.
*
* A role bundles everything that distinguishes one agent persona from another:
* model, system prompt, and the tool surface (files / cph / feishu / skills /
* mcps). Per-run tool whitelisting is enforced by the Claude SDK runner and
* the cph_hub MCP server builder; security does not rely on the system prompt.
*/
import { assertSupportedRoleTools } from "./roleTools.js";
export interface RoleEntry {
readonly id: string;
/** Human label for the teacher-side switcher UX. */
readonly label: string;
/** Default model id for runs under this role, if a routing rule is set. */
readonly defaultModel: string | undefined;
/**
* System prompt seeding the agent's persona/instructions. Prepended to the
* run's messages only at session start; Claude SDK resume restores later
* turns from the provider session. `undefined` ⇒ no system prompt (bare run).
*/
readonly systemPrompt?: string | undefined;
/**
* Tool names this role may use (whitelist). `undefined` ⇒ the full registered
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
* Invalid names fail fast when settings are loaded or the run is set up.
*/
readonly tools?: readonly string[] | undefined;
}
/** A model the admin has enabled for use by the Hub. */
export interface ModelEntry {
readonly id: string;
/** Human label for the teacher-side switcher UX. */
readonly label: string;
/** True when the model is known to support tool use reliably. */
readonly toolCapable: boolean;
}
export interface ModelRegistry {
/** All models admin-enabled for this Hub instance. */
listModels(): readonly ModelEntry[];
/** All role presets currently configured (admin/teacher-defined). */
listRoles(): readonly RoleEntry[];
/** The role preset with the given id, if any. */
role(id: string): RoleEntry | undefined;
/** Resolve a model id: explicit request → role default → first enabled. */
resolve(requestedModel: string | undefined, roleId: string): string;
}
/**
* A simple in-memory registry. Real wiring reads from admin settings / DB; this
* is the skeleton seam. Both the model list and the role set are pluggable data
* — adding a role is a config change, not a code change.
*/
export class InMemoryModelRegistry implements ModelRegistry {
private readonly models: readonly ModelEntry[];
private readonly roles: Map<string, RoleEntry>;
constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) {
this.models = models;
for (const role of roles) {
if (role.tools !== undefined) assertSupportedRoleTools(role.tools);
}
this.roles = new Map(roles.map((r) => [r.id, r]));
}
listModels(): readonly ModelEntry[] {
return this.models;
}
listRoles(): readonly RoleEntry[] {
return [...this.roles.values()];
}
role(id: string): RoleEntry | undefined {
return this.roles.get(id);
}
/**
* Resolve a model id for a run. Priority: teacher's explicit request → the
* role preset's default → the first admin-enabled model. `roleId` is a string
* so unknown roles degrade gracefully to the fallback rather than throwing.
*/
resolve(requestedModel: string | undefined, roleId: string): string {
if (requestedModel !== undefined && this.models.some((m) => m.id === requestedModel)) {
return requestedModel;
}
const role = this.roles.get(roleId);
if (role?.defaultModel !== undefined) return role.defaultModel;
const first = this.models[0];
if (first === undefined) throw new Error("no models enabled for this Hub");
return first.id;
}
}
-110
View File
@@ -1,110 +0,0 @@
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = ["Read", "Write", "Bash", "Glob", "Grep"] as const;
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
export const CPH_HUB_MCP_TOOL_IDS = ["send_file", "feishu_read_context", "request_approval"] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
export interface ClaudeSdkToolConfig {
readonly tools: readonly string[];
readonly allowedTools: readonly string[];
}
const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
["read_file", ["Read"]],
["write_file", ["Write"]],
["list_files", ["Glob"]],
["search_files", ["Grep"]],
["bash", ["Bash"]],
// ADR-0017 replaced cph custom tools with Bash commands. Granting either
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
// would need a separate command-policy layer.
["cph_check", ["Bash"]],
["cph_build", ["Bash"]],
["Read", ["Read"]],
["Write", ["Write"]],
["Bash", ["Bash"]],
["Glob", ["Glob"]],
["Grep", ["Grep"]],
]);
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
["send_file", "send_file"],
["feishu_read_context", "feishu_read_context"],
["request_approval", "request_approval"],
["mcp__cph_hub__send_file", "send_file"],
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
["mcp__cph_hub__request_approval", "request_approval"],
]);
const SUPPORTED_ROLE_TOOLS = new Set([
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(),
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(),
]);
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
if (roleTools === undefined) {
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
return {
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
allowedTools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS, ...mcpTools],
};
}
const builtIns: string[] = [];
const allowedTools: string[] = [];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS.get(roleTool) ?? []) {
pushUnique(builtIns, tool);
pushUnique(allowedTools, tool);
}
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
if (mcpTool !== undefined) {
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
}
}
return { tools: builtIns, allowedTools };
}
export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
const tools: CphHubMcpToolId[] = [];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
if (mcpTool !== undefined) pushUnique(tools, mcpTool);
}
return tools;
}
export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
if (roleTools === undefined) return true;
for (const configured of roleTools) {
assertSupportedRoleTool(configured);
if (configured === roleTool) return true;
if (ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(configured) === roleTool) return true;
}
return false;
}
export function assertSupportedRoleTools(roleTools: readonly string[]): void {
for (const roleTool of roleTools) assertSupportedRoleTool(roleTool);
}
function claudeMcpToolName(tool: CphHubMcpToolId): string {
return `mcp__${CPH_HUB_MCP_SERVER_NAME}__${tool}`;
}
function assertSupportedRoleTool(roleTool: string): void {
if (!SUPPORTED_ROLE_TOOLS.has(roleTool)) {
throw new Error(`unknown role tool id: ${roleTool}`);
}
}
function pushUnique<T>(items: T[], item: T): void {
if (!items.includes(item)) items.push(item);
}
-324
View File
@@ -1,324 +0,0 @@
/**
* Claude Code SDK agent runner.
*
* Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK
* owns tool dispatch, context compaction, streaming — we just map its events
* to the Feishu streaming callback.
*
* Built-in tools (Read, Write, Bash, Glob, Grep) cover our entire tool surface:
* - read_file → Read
* - write_file → Write
* - list_files → Glob
* - cph_check / cph_build → Bash (`cph check .`, `cph build . --target student`)
* No custom tools needed — the SDK's Bash tool runs cph directly.
*
* ADR-0017 update: this drops provider-agnosticism. Claude Code SDK is
* Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
* message streaming, and a battle-tested agent loop — capabilities we'd have
* to build ourselves with a generic provider.
*
* ADR-0018: the agent execution surface is bounded by the run's workspace.
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
* Code process and its subprocesses at the OS level — writes are confined to
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox
* can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized`
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant.
*/
import { homedir } from "node:os";
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js";
export interface ProjectContext {
readonly projectId: string;
readonly boundChatId: string;
readonly workspaceDir: string;
}
export type StreamEvent =
| { readonly type: "text-delta"; readonly text: string }
| { readonly type: "thinking-delta"; readonly text: string }
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
| { readonly type: "finish" };
export type StreamCallback = (event: StreamEvent) => void;
export interface RunRequest {
readonly prompt: string;
readonly model: string | undefined;
readonly project: ProjectContext;
readonly systemPrompt: string | undefined;
readonly providerEnv?: Record<string, string | undefined> | undefined;
readonly resumeSessionId?: string | undefined;
/**
* RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids
* (for example read_file/cph_check/send_file), mapped to Claude SDK tool
* names at run setup. `undefined` means the full supported tool surface; []
* means no tools.
*/
readonly tools?: readonly string[] | undefined;
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
readonly maxTurns?: number;
readonly runId: string;
readonly sessionId: string;
readonly prisma: PrismaClient;
readonly onStream?: StreamCallback;
/**
* Abort controller for the run. Aborting the signal interrupts the SDK query
* (it stops the agent loop and cleans up); the run resolves with status
* "interrupted". The caller owns the controller and triggers abort.
*/
readonly abortController?: AbortController | undefined;
}
export type RunStatus = "completed" | "length" | "failed" | "interrupted";
export interface RunResult {
readonly status: RunStatus;
readonly text: string;
readonly usage: { inputTokens: number; outputTokens: number };
/** Provider- or gateway-reported cost for this run, in USD. */
readonly costUsd?: number | undefined;
readonly numTurns: number;
readonly sdkSessionId?: string | undefined;
readonly error?: string;
}
const DEFAULT_MAX_TURNS = 25;
/**
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
* confines writes to the workspace; these deny reads of credentials/secrets
* the process could otherwise see (read-only mounts are still readable). The
* workspace itself is never in this list — it's writable by `allowWrite`.
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
* with additional secret locations.
*/
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
const home = homedir();
const base = [
"/etc",
"/root",
"/var/log",
"/proc",
"/sys",
`${home}/.ssh`,
`${home}/.aws`,
`${home}/.config/gcloud`,
`${home}/.gnupg`,
];
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
return extra !== undefined && extra !== ""
? [...base, ...extra.split(":").filter((p) => p !== "")]
: base;
})();
export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
// Lock heartbeat: refresh on each step. Best-effort.
const heartbeat = async () => {
try {
await req.prisma.projectAgentLock.update({
where: { runId: req.runId },
data: { heartbeatAt: new Date() },
});
} catch { /* best-effort */ }
};
let fullText = "";
let usage = { inputTokens: 0, outputTokens: 0 };
let costUsd: number | undefined;
let numTurns = 0;
let sdkSessionId: string | undefined;
let error: string | undefined;
try {
await persistAgentMessage(req, "user", req.prompt);
const toolConfig = claudeSdkToolConfigForRole(req.tools);
const options: Parameters<typeof query>[0]["options"] = {
cwd: req.project.workspaceDir,
tools: [...toolConfig.tools],
allowedTools: [...toolConfig.allowedTools],
maxTurns: cap,
includePartialMessages: true,
// ADR-0018: bypass interactive prompts (headless server); the sandbox
// below is the hard boundary, not the permission-prompt layer.
permissionMode: "bypassPermissions" as const,
allowDangerouslySkipPermissions: true,
// ADR-0018: OS-level sandbox confines the agent process + its Bash
// subprocesses. Writes confined to the workspace; sensitive host paths
// denied reads; network open. failIfUnavailable hard-fails if the
// sandbox can't start — never run unsandboxed.
sandbox: {
enabled: true,
failIfUnavailable: true,
autoAllowBashIfSandboxed: true,
filesystem: {
allowWrite: [req.project.workspaceDir],
denyRead: [...SENSITIVE_READ_PATHS],
},
},
};
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model;
if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv };
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
if (req.abortController !== undefined) options.abortController = req.abortController;
const conversation = query({
prompt: req.prompt,
options,
});
// Track tool start timestamps for duration calculation
const toolStartTimestamps = new Map<string, number>();
for await (const message of conversation) {
switch (message.type) {
case "stream_event": {
const evt = (message as SDKPartialAssistantMessage).event;
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
onStream?.({ type: "text-delta", text: evt.delta.text });
}
if (evt.type === "content_block_delta" && evt.delta.type === "thinking_delta") {
onStream?.({ type: "thinking-delta", text: evt.delta.thinking });
}
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
const toolUseId = evt.content_block.id;
toolStartTimestamps.set(toolUseId, Date.now());
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
}
if (evt.type === "content_block_stop") {
// Tool end is attributed from the assistant message below.
}
break;
}
case "assistant": {
const msg = (message as SDKAssistantMessage).message;
await heartbeat();
numTurns++;
let assistantText = "";
for (const block of msg.content) {
if (block.type === "text" && typeof block.text === "string") {
fullText += block.text;
assistantText += block.text;
}
if (block.type === "tool_use") {
const durationMs = toolStartTimestamps.has(block.id)
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
: undefined;
onStream?.({
type: "tool-end",
toolName: block.name,
toolUseId: block.id,
input: block.input,
...(durationMs !== undefined ? { durationMs } : {}),
});
}
if (block.type === "thinking" && typeof block.thinking === "string") {
// Complete thinking block from the assistant message.
// Stream deltas already delivered this; no separate event needed.
}
}
await persistAgentMessage(req, "assistant", assistantText);
if (msg.usage !== undefined) {
usage.inputTokens += msg.usage.input_tokens ?? 0;
usage.outputTokens += msg.usage.output_tokens ?? 0;
}
break;
}
case "user": {
// User messages carry tool_result blocks — what each tool returned.
const msg = (message as SDKUserMessage).message;
const content = msg.content;
if (!Array.isArray(content)) break;
for (const block of content) {
if (typeof block !== "object" || block === null || block.type !== "tool_result") continue;
// Narrowed to BetaToolResultBlockParam by discriminant + field presence
if (!("tool_use_id" in block)) continue;
const toolUseId = block.tool_use_id;
const isError = block.is_error === true;
const resultText = extractToolResultText(block.content);
const durationMs = toolStartTimestamps.get(toolUseId);
onStream?.({
type: "tool-result",
toolUseId,
toolName: toolUseId,
result: resultText,
isError,
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
});
}
break;
}
case "result": {
const result = message as SDKResultMessage;
sdkSessionId = result.session_id;
costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined;
if (result.subtype !== "success") {
error = `result_${result.subtype}`;
}
break;
}
}
}
onStream?.({ type: "finish" });
return {
status: error !== undefined ? "failed" : "completed",
text: fullText,
usage,
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(error !== undefined ? { error } : {}),
};
} catch (e) {
const aborted = req.abortController?.signal.aborted === true;
return {
status: aborted ? "interrupted" : "failed",
text: fullText,
usage,
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
};
}
}
async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
if (content === "") return;
try {
await req.prisma.agentMessage.create({
data: {
sessionId: req.sessionId,
runId: req.runId,
role,
content,
attachments: [],
},
});
} catch {
// Best-effort projection: a history write failure must not break the run.
}
}
function extractToolResultText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
parts.push(part.text);
}
}
return parts.join("\n");
}
-119
View File
@@ -1,119 +0,0 @@
/**
* Tool registry - the agent's narrow, project-scoped tool surface.
*
* Unlike a general "Claude Code", the Hub agent operates on a curriculum
* engineering-file tree (ADR-0007) and reads Feishu context on demand
* (ADR-0003). Its tools are therefore bounded:
*
* - file-tree read/write against the project workspace dir,
* - `cph check` / `cph build` subprocess calls (the Courseware-side checker,
* coupled only via the stable CLI contract ADR-0016),
* - Feishu context reads, which must honor ADR-0003's authorization invariant.
*
* The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool
* construction and per-role whitelisting (ADR-0017).
*/
import { tool, type Tool } from "ai";
import { z } from "zod";
/** Per-run execution context closed over by every tool execute function. */
export interface ToolContext {
readonly runId: string;
readonly projectId: string;
/** ADR-0001: the chat this project is bound to. Feishu reads must stay in it. */
readonly boundChatId: string;
/** Absolute path to the project's curriculum engineering-file workspace. */
readonly workspaceDir: string;
/** Optional sink for file-change capture (A-3). The writeFile tool calls
* this with before/after hash when it modifies a file; the runner injects
* a collector that flushes to AgentFileChange at run end. Undefined when
* the runner doesn't wire capture (e.g. unit tests). */
readonly onFileChange?: (change: { path: string; operation: "CREATE" | "UPDATE" | "DELETE" | "RENAME"; beforeHash: string | null; afterHash: string | null; diff: string | null }) => void;
}
export type ToolDefinition = Tool;
export type ToolFactory = (ctx: ToolContext) => ToolDefinition;
export class ToolRegistry {
private readonly factories = new Map<string, ToolFactory>();
register(name: string, factory: ToolFactory): void {
if (this.factories.has(name)) {
throw new Error(`duplicate tool: ${name}`);
}
this.factories.set(name, factory);
}
/**
* Build the AI SDK `tools` record. If `names` is given, include only those
* tools (per-role whitelist, ADR-0017). Names not registered are silently
* dropped because role config may name tools a Hub instance does not provide.
*/
build(ctx: ToolContext, names: readonly string[] | undefined): Record<string, ToolDefinition> {
const built: Record<string, ToolDefinition> = {};
const selected = names ?? [...this.factories.keys()];
for (const name of selected) {
const factory = this.factories.get(name);
if (factory !== undefined) {
built[name] = factory(ctx);
}
}
return built;
}
}
/**
* Thrown when a Feishu context read targets a chat other than the run's project's
* bound chat. This is the runtime enforcement of ADR-0003's
* `McpReadRequest.Authorized`: "Claude cannot pass arbitrary chat ids".
*/
export class UnauthorizedChatRead extends Error {
constructor(
readonly requestedChatId: string,
readonly boundChatId: string,
) {
super(
`refused Feishu context read: requested chat ${requestedChatId} is not the run's project's bound chat ${boundChatId} (ADR-0003)`,
);
this.name = "UnauthorizedChatRead";
}
}
/** Schema for the Feishu context read tool's arguments. */
export interface FeishuContextArgs {
readonly chat_id: string;
readonly anchor: "trigger_message" | "status_card" | "reply" | "thread";
readonly id: string;
}
/**
* Build the Feishu context-read tool factory. The execute closure enforces
* ADR-0003's invariant before any API call: the requested `chat_id` must equal
* `ctx.boundChatId`.
*
* The actual Feishu API call (read message / reply chain / thread / recent
* group messages) is injected as `read`, so the invariant check is separable
* from the transport. In tests `read` can be a stub; in production it wraps
* `@larksuiteoapi/node-sdk`.
*/
export function feishuContextTool(
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
rt: unknown,
): ToolFactory {
return (ctx) =>
tool({
description:
"Read on-demand Feishu context (a trigger message, status card, reply, or thread) for the current project's bound chat. The chat id must match the project binding.",
inputSchema: z.object({
chat_id: z.string().describe("The Feishu chat id to read from."),
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
id: z.string().describe("The anchor id (message id or run id)."),
}),
execute: async (args): Promise<string> => {
if (args.chat_id !== ctx.boundChatId) {
throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId);
}
return read(args, ctx, rt);
},
});
}
-64
View File
@@ -1,64 +0,0 @@
/**
* Session transcript — append-only JSONL file in the workspace.
*
* Each line is one AI SDK {@link ModelMessage}, JSON-encoded. The transcript lives at
* `workspaceDir/.cph/sessions/<sessionId>.jsonl`, following the same "workspace
* is a directory tree" principle as the engineering file itself (ADR-0007).
*
* This mirrors how Claude Code stores its transcript (a `.jsonl` file per
* session). The agent can read its own history via `read_file` — no special
* "load history" code path. ADR-0002's lock guarantees one run per project at a
* time, so the transcript is never concurrently written.
*
* ADR-0003: this stores **agent session messages** (prompt + response + tool
* calls produced by the Hub), NOT Feishu group chat history. The two are
* distinct; storing session messages does not conflict with "the Hub avoids
* becoming a full chat history store."
*/
import { readFile, mkdir, appendFile } from "node:fs/promises";
import { join, dirname } from "node:path";
import type { ModelMessage } from "ai";
/// Subdirectory within a workspace where session transcripts live.
export const TRANSCRIPT_DIR = ".cph/sessions";
/** Path for a session's transcript file within a workspace. */
export function transcriptPath(workspaceDir: string, sessionId: string): string {
return join(workspaceDir, TRANSCRIPT_DIR, `${sessionId}.jsonl`);
}
/**
* Read prior messages from a transcript file. Returns an empty array if the
* file doesn't exist yet (first run in this session). Corrupt lines are
* skipped — a partially-written last line (crash mid-append) won't break the
* next run.
*/
export async function readTranscript(path: string): Promise<ModelMessage[]> {
let content: string;
try {
content = await readFile(path, "utf8");
} catch {
return [];
}
const messages: ModelMessage[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") continue;
try {
messages.push(JSON.parse(trimmed) as ModelMessage);
} catch {
// Skip corrupt line — likely a partial write from a crash.
}
}
return messages;
}
/**
* Append messages to a transcript file. Creates the parent directory if needed.
* Each message is written as one JSON line.
*/
export async function appendTranscript(path: string, messages: readonly ModelMessage[]): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
await appendFile(path, lines, "utf8");
}
-138
View File
@@ -1,138 +0,0 @@
/**
* Workspace file tools - bounded read/write/list against the project's
* curriculum engineering-file tree (ADR-0007 directory tree).
*
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
* requested relative path against the workspace root and rejects any path that
* escapes it (via `..` or absolute paths). This is the agent's only file
* surface; the Hub agent is narrower than a general coding agent.
*/
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join, resolve, relative, isAbsolute } from "node:path";
import { tool } from "ai";
import { z } from "zod";
import type { ToolContext, ToolDefinition } from "./tools.js";
/** Thrown when a requested path escapes the project workspace root. */
export class PathEscape extends Error {
constructor(readonly requested: string, readonly workspaceDir: string) {
super(`path escapes workspace: ${requested} (root ${workspaceDir})`);
this.name = "PathEscape";
}
}
/** Resolve a tool-supplied path against the workspace root, rejecting escapes. */
function confine(requestedPath: string, workspaceDir: string): string {
if (isAbsolute(requestedPath)) {
// Allow absolute paths only if they're already inside the workspace.
const rel = relative(workspaceDir, requestedPath);
if (rel.startsWith("..") || rel === "") {
throw new PathEscape(requestedPath, workspaceDir);
}
return requestedPath;
}
const resolved = resolve(workspaceDir, requestedPath);
const rel = relative(workspaceDir, resolved);
if (rel.startsWith("..")) {
throw new PathEscape(requestedPath, workspaceDir);
}
return resolved;
}
export function readFileTool(ctx: ToolContext): ToolDefinition {
return tool({
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
inputSchema: z.object({
path: z.string().describe("Relative path within the workspace."),
}),
execute: async (args): Promise<string> => {
try {
const full = confine(args.path, ctx.workspaceDir);
return await readFile(full, "utf8");
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
},
});
}
export function writeFileTool(ctx: ToolContext): ToolDefinition {
return tool({
description:
"Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
inputSchema: z.object({
path: z.string().describe("Relative path within the workspace."),
content: z.string().describe("The full file content to write."),
}),
execute: async (args): Promise<string> => {
try {
const full = confine(args.path, ctx.workspaceDir);
// A-3 file-change capture: compute before/after hashes around the write.
// A hashing failure must not block the write — skip the sink in that case.
let beforeHash: string | null = null;
let afterHash: string | null = null;
let operation: "CREATE" | "UPDATE" | null = null;
try {
let oldContent: string | null;
try {
oldContent = await readFile(full, "utf8");
} catch (e) {
if (e !== null && typeof e === "object" && "code" in e && e.code === "ENOENT") {
oldContent = null;
} else {
throw e;
}
}
afterHash = createHash("sha256").update(args.content).digest("hex");
beforeHash = oldContent === null ? null : createHash("sha256").update(oldContent).digest("hex");
if (beforeHash === null) {
operation = "CREATE";
} else if (beforeHash === afterHash) {
operation = null;
} else {
operation = "UPDATE";
}
} catch {
operation = null;
}
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, args.content, "utf8");
if (operation !== null && ctx.onFileChange !== undefined) {
ctx.onFileChange({ path: args.path, operation, beforeHash, afterHash, diff: null });
}
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
},
});
}
interface Entry {
readonly name: string;
readonly kind: "file" | "dir";
}
export function listFilesTool(ctx: ToolContext): ToolDefinition {
return tool({
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
inputSchema: z.object({
path: z.string().describe("Relative path within the workspace; defaults to root.").optional(),
}),
execute: async (args): Promise<string> => {
const sub = args.path ?? ".";
try {
const full = confine(sub, ctx.workspaceDir);
const entries = await readdir(full, { withFileTypes: true });
const result: Entry[] = entries.map((e) => ({
name: e.name,
kind: e.isDirectory() ? "dir" : "file",
}));
return JSON.stringify(result);
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
},
});
}
-59
View File
@@ -1,59 +0,0 @@
/**
* Audit write path — A-8. The `AuditEntry` table existed but had no writer;
* callers (trigger path, permission denials, slash commands) had no way to
* record what happened. This module is that single write seam.
*
* ADR reference: spec Audit. The skeleton pins only the `runId` relation;
* event type / actor / timestamp / details are content-OPEN. This helper
* exposes `action` (event type) + `actorUserId` (actor) + `metadata`
* (details) + `projectId` (locates audit points that don't have a run, e.g.
* permission denials) + `runId` (loose string ref, not a Prisma relation —
* see schema). `createdAt` is `@default(now())` so we don't pass it.
*
* Best-effort by design: an audit failure must never break the caller's path
* (a trigger failing because the audit log is down would be a spec breach —
* audit is observability, not control). Errors are swallowed here. There's no
* logger in this layer yet; a future logger can be threaded in without
* changing the signature.
*/
import type { PrismaClient, Prisma } from "@prisma/client";
export interface AuditInput {
readonly runId?: string | undefined;
readonly projectId?: string | undefined;
readonly actorUserId?: string | undefined;
readonly action: string;
readonly metadata: Record<string, unknown>;
}
/**
* Write one audit entry. Best-effort: any Prisma error is swallowed so the
* caller's path (trigger, permission gate, slash command) is unaffected.
*
* Optional fields are only included when defined — under
* `exactOptionalPropertyTypes`, assigning `undefined` to an optional key in a
* Prisma `create` payload is a type error (and would also write an explicit
* NULL rather than omit the column). Conditional assignment keeps the payload
* honest. The data object is typed as `AuditEntryUncheckedCreateInput` (the
* direct-FK variant) so it matches one branch of Prisma's `create` input
* union exactly — the relation-connect branch has no `projectId`/`runId`
* columns and would otherwise reject these fields.
*/
export async function writeAudit(
prisma: PrismaClient,
entry: AuditInput,
): Promise<void> {
const data: Prisma.AuditEntryUncheckedCreateInput = {
action: entry.action,
metadata: entry.metadata as Prisma.InputJsonValue,
};
if (entry.runId !== undefined) data.runId = entry.runId;
if (entry.projectId !== undefined) data.projectId = entry.projectId;
if (entry.actorUserId !== undefined) data.actorUserId = entry.actorUserId;
try {
await prisma.auditEntry.create({ data });
} catch {
// Swallow: audit is best-effort and must not break the caller's path.
}
}
-21
View File
@@ -1,21 +0,0 @@
/**
* Prisma client singleton.
*
* Prisma's client is a connection-pooled datasource; constructing it per-query
* exhausts connections. This module exports one shared instance per process.
* Hot-reload (tsx watch) can re-import this module — the global guard prevents
* a second PrismaClient from being created.
*/
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { __hubPrisma?: PrismaClient };
export const prisma: PrismaClient =
globalForPrisma.__hubPrisma ??
new PrismaClient({
log: process.env["HUB_PRISMA_LOG"] !== undefined ? ["query", "error", "warn"] : ["error", "warn"],
});
if (process.env["NODE_ENV"] !== "production") {
globalForPrisma.__hubPrisma = prisma;
}
-54
View File
@@ -1,54 +0,0 @@
export interface ApprovalResult {
readonly action: string;
readonly resolvedBy: string;
}
export interface PendingApproval {
readonly messageId: string;
readonly chatId: string;
readonly resolve: (result: ApprovalResult) => void;
}
export interface ApprovalManagerOptions {
readonly timeoutMs?: number;
}
export class ApprovalManager {
private readonly timeoutMs: number;
private pending = new Map<string, PendingApproval>();
constructor(options: ApprovalManagerOptions = {}) {
this.timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
}
register(messageId: string, chatId: string): Promise<ApprovalResult> {
return new Promise<ApprovalResult>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pending.delete(messageId);
reject(new Error(`approval timed out after ${this.timeoutMs}ms`));
}, this.timeoutMs);
const unref = (timeout as { unref?: () => void }).unref;
if (unref !== undefined) unref.call(timeout);
this.pending.set(messageId, {
messageId,
chatId,
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
});
});
}
resolve(messageId: string, action: string, resolvedBy: string): void {
const pending = this.pending.get(messageId);
if (pending === undefined) return;
this.pending.delete(messageId);
pending.resolve({ action, resolvedBy });
}
hasPending(messageId: string): boolean {
return this.pending.has(messageId);
}
}
-408
View File
@@ -1,408 +0,0 @@
/**
* Feishu interactive card builder for agent run output.
*
* Produces card JSON with:
* 1. A collapsible tool-use panel (tool steps with status, params, results)
* 2. A collapsible reasoning panel (thinking text)
* 3. The streaming/final answer text (markdown)
*
* Adapted from openclaw-lark's builder.ts, simplified for our
* message.patch-based approach (no CardKit 2.0 streaming_mode).
* Uses JSON 1.0 card format with `collapsible_panel` and `markdown` tags.
*/
import type { ToolUseTraceStep } from "./trace-store.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FeishuCard {
readonly config: Record<string, unknown>;
readonly elements: ReadonlyArray<unknown>;
}
export type CardPhase = "thinking" | "streaming" | "complete";
const STEP_INDENT = "0px 0px 0px 22px";
const MAX_TEXT_LENGTH = 7500; // Feishu card content limit ~30k; leave room for panels
// ---------------------------------------------------------------------------
// Tool name → icon mapping
// ---------------------------------------------------------------------------
const TOOL_ICONS: Record<string, string> = {
read: "doc-filled",
write: "edit-filled",
bash: "terminal-filled",
glob: "search-filled",
grep: "search-filled",
edit: "edit-filled",
send_file: "send-filled",
request_approval: "thumb-up-filled",
feishu_read_context: "search-filled",
};
function toolIcon(toolName: string): string {
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
return TOOL_ICONS[normalized] ?? "tool-filled";
}
// ---------------------------------------------------------------------------
// Card builder
// ---------------------------------------------------------------------------
export function buildAgentCard(params: {
phase: CardPhase;
text: string;
reasoningText: string | undefined;
toolUseSteps: ToolUseTraceStep[];
toolUseElapsedMs: number | undefined;
isError: boolean | undefined;
interrupted: boolean | undefined;
runId: string | undefined;
}): Record<string, unknown> {
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const elements: unknown[] = [];
// Tool-use panel (always present if there are steps)
if (toolUseSteps.length > 0) {
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
} else if (phase === "thinking" || (phase === "streaming" && text === "")) {
elements.push(buildPendingToolUsePanel());
}
// Reasoning panel
if (reasoningText !== undefined && reasoningText !== "") {
if (phase === "streaming" && text === "") {
// Still thinking: show reasoning inline
elements.push({
tag: "markdown",
content: `\u{1F4AD} \u601d\u8003\u4E2D...\n\n${reasoningText}`,
text_size: "notation",
});
} else if (phase === "complete") {
// Collapsed reasoning panel in complete state
elements.push(buildReasoningPanel(reasoningText));
}
}
// Main text content
if (text !== "") {
elements.push({
tag: "markdown",
content: truncateText(text, MAX_TEXT_LENGTH),
});
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
elements.push({
tag: "markdown",
content: "\u{1F4AD} \u601d\u8003\u4E2D...",
text_size: "notation",
});
}
// Interrupt button while the run is live. The button carries the run id in
// its action value; the card action handler aborts the run. Feishu's native
// `confirm` dialog is the "are you sure" step — the action only fires after
// the user confirms, so no second round-trip is needed.
if (phase !== "complete" && params.runId !== undefined) {
elements.push(buildInterruptAction(params.runId));
}
// Footer for complete state
if (phase === "complete") {
const footerParts: string[] = [];
if (interrupted === true) footerParts.push("\u{1F6D1} \u5DF2\u4E2D\u65AD");
else if (isError === true) footerParts.push("\u274C \u5931\u8D25");
else footerParts.push("\u2705 \u5B8C\u6210");
if (toolUseElapsedMs !== undefined) {
footerParts.push(formatElapsed(toolUseElapsedMs));
}
elements.push({
tag: "div",
text: {
tag: "plain_text",
content: footerParts.join(" \u00B7 "),
text_size: "notation",
text_color: "grey",
},
});
}
return {
config: { wide_screen_mode: true, update_multi: true },
elements,
};
}
/** Action row with a single danger "interrupt" button for a live run. */
function buildInterruptAction(runId: string): unknown {
return {
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "\u{1F6D1} \u4E2D\u65AD" },
type: "danger",
value: { interrupt_run: runId },
confirm: {
title: { tag: "plain_text", content: "\u786E\u8BA4\u4E2D\u65AD" },
text: {
tag: "plain_text",
content: "\u786E\u5B9A\u8981\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u5417\uFF1F\u4E2D\u65AD\u540E\u65E0\u6CD5\u6062\u590D\uFF0C\u5DF2\u4EA7\u51FA\u7684\u5185\u5BB9\u4F1A\u4FDD\u7559\u3002",
},
},
},
],
};
}
// ---------------------------------------------------------------------------
// Tool-use panel
// ---------------------------------------------------------------------------
function buildPendingToolUsePanel(): unknown {
return {
tag: "collapsible_panel",
expanded: false,
header: {
title: {
tag: "plain_text",
content: "\u{1F6E0}\uFE0F \u7B49\u5F85\u5DE5\u5177\u6267\u884C",
text_color: "grey",
text_size: "notation",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
color: "grey",
size: "16px 16px",
},
icon_position: "right",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "4px",
padding: "8px 8px 8px 8px",
elements: [],
};
}
function buildToolUsePanel(steps: ToolUseTraceStep[], elapsedMs?: number, expanded = false): unknown {
const parts: string[] = ["\u{1F6E0}\uFE0F \u5DE5\u5177\u6267\u884C"];
if (steps.length > 0) {
parts.push(`${steps.length} \u6B65`);
}
if (elapsedMs !== undefined && elapsedMs > 0) {
parts.push(`(${formatElapsed(elapsedMs)})`);
}
const stepElements = steps.flatMap((step) => buildToolStepElements(step));
return {
tag: "collapsible_panel",
expanded,
header: {
title: {
tag: "plain_text",
content: parts.join(" \u00B7 "),
text_color: "grey",
text_size: "notation",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
color: "grey",
size: "16px 16px",
},
icon_position: "right",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "4px",
padding: "8px 8px 8px 8px",
elements: stepElements,
};
}
function buildToolStepElements(step: ToolUseTraceStep): unknown[] {
const elements: unknown[] = [buildToolStepTitle(step)];
const detailElement = buildToolStepDetail(step);
if (detailElement !== undefined) {
elements.push(detailElement);
}
const outputElement = buildToolStepOutput(step);
if (outputElement !== undefined) {
elements.push(outputElement);
}
return elements;
}
function buildToolStepTitle(step: ToolUseTraceStep): unknown {
const status = formatStepStatus(step.status);
return {
tag: "div",
icon: {
tag: "standard_icon",
token: toolIcon(step.toolName),
color: "grey",
},
text: {
tag: "lark_md",
content: `**${escapeMarkdown(step.toolName)}** \u00B7 <font color='${status.color}'>${status.label}</font>`,
text_size: "notation",
},
};
}
function buildToolStepDetail(step: ToolUseTraceStep): unknown | undefined {
const detail = extractStepDetail(step);
if (detail === undefined) return undefined;
return {
tag: "div",
margin: STEP_INDENT,
text: {
tag: "plain_text",
content: detail,
text_color: "grey",
text_size: "notation",
},
};
}
function buildToolStepOutput(step: ToolUseTraceStep): unknown | undefined {
const content = buildStepOutputMarkdown(step);
if (content === undefined) return undefined;
return {
tag: "div",
margin: STEP_INDENT,
text: {
tag: "lark_md",
content,
text_size: "notation",
},
};
}
function buildStepOutputMarkdown(step: ToolUseTraceStep): string | undefined {
const lines: string[] = [];
if (step.error !== undefined) {
lines.push("**\u9519\u8BEF**");
lines.push(formatCodeBlock(step.error, "text"));
} else if (step.result !== undefined && step.result !== "") {
const resultStr = typeof step.result === "string" ? step.result : JSON.stringify(step.result, null, 2);
lines.push("**\u7ED3\u679C**");
lines.push(formatCodeBlock(resultStr, typeof step.result === "string" ? "text" : "json"));
}
if (lines.length === 0) return undefined;
return lines.join("\n");
}
function extractStepDetail(step: ToolUseTraceStep): string | undefined {
if (step.input === undefined || step.input === null) return undefined;
if (typeof step.input === "string") return truncateText(step.input, 200);
const input = step.input as Record<string, unknown>;
// Extract the most relevant field based on tool name
const toolLower = step.toolName.toLowerCase().replace(/^mcp_/, "");
if (toolLower === "bash" && typeof input.command === "string") {
return truncateText(input.command, 200);
}
if (toolLower === "read" && typeof input.file_path === "string") {
return input.file_path;
}
if (toolLower === "write" && typeof input.file_path === "string") {
return input.file_path;
}
if (toolLower === "edit" && typeof input.file_path === "string") {
return input.file_path;
}
if (toolLower === "glob" && typeof input.pattern === "string") {
return input.pattern;
}
if (toolLower === "grep" && typeof input.pattern === "string") {
return input.pattern;
}
// Fallback: show keys
const keys = Object.keys(input);
if (keys.length === 0) return undefined;
return keys.slice(0, 4).join(", ");
}
// ---------------------------------------------------------------------------
// Reasoning panel
// ---------------------------------------------------------------------------
function buildReasoningPanel(reasoningText: string): unknown {
return {
tag: "collapsible_panel",
expanded: false,
header: {
title: {
tag: "markdown",
content: "\u{1F4AD} \u601D\u8003",
text_color: "grey",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
size: "16px 16px",
},
icon_position: "follow_text",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "8px",
padding: "8px 8px 8px 8px",
elements: [
{
tag: "markdown",
content: truncateText(reasoningText, MAX_TEXT_LENGTH),
text_size: "notation",
},
],
};
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
function formatStepStatus(status: ToolUseTraceStep["status"]): { label: string; color: string } {
switch (status) {
case "running":
return { label: "\u8FD0\u884C\u4E2D", color: "turquoise" };
case "error":
return { label: "\u5931\u8D25", color: "red" };
case "success":
default:
return { label: "\u6210\u529F", color: "green" };
}
}
function formatCodeBlock(content: string, language: "json" | "text"): string {
const normalized = content.replace(/\r\n/g, "\n").trim();
const fence = "`".repeat(Math.max(3, longestBacktickRun(normalized) + 1));
return `${fence}${language}\n${normalized}\n${fence}`;
}
function longestBacktickRun(value: string): number {
const matches = value.match(/`+/g);
if (matches === null) return 0;
return matches.reduce((max, run) => Math.max(max, run.length), 0);
}
function escapeMarkdown(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
}
function truncateText(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
}
function formatElapsed(ms: number): string {
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(1)} s`;
}
-239
View File
@@ -1,239 +0,0 @@
/**
* Streaming card controller for agent runs.
*
* Replaces PatchableTextStream for the agent-run use case. Manages a single
* Feishu interactive card through the full run lifecycle:
*
* thinking → tool calls → streaming text → complete
*
* The card is rebuilt from scratch on each flush (throttled at 400ms) using
* the trace store for tool steps + accumulated reasoning/text. This is
* simpler than CardKit 2.0 element-level streaming and works with the
* existing message.patch API.
*
* Lifecycle:
* 1. appendText(delta) — streaming answer text
* 2. appendReasoning(delta) — streaming thinking text
* 3. onToolStart(name, id) — register a tool step in the trace store
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
* 5. finish(finalText) — flush + transition to complete card
* 6. fail(errorText) — flush + transition to error card
*/
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
import { sendCard, patchCard, sendText } from "../client.js";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
import {
startToolUseTraceRun,
clearToolUseTraceRun,
recordToolUseStart,
recordToolUseEnd,
getToolUseTraceSteps,
} from "./trace-store.js";
import { buildAgentCard, type CardPhase } from "./builder.js";
export interface StreamingCardSink {
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
readonly patch: (messageId: string, card: Record<string, unknown>) => Promise<void>;
}
export interface StreamingCardOptions {
readonly runId: string;
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly sendOptions?: SendMessageOptions | undefined;
readonly patchIntervalMs: number | undefined;
readonly maxMessageLength: number | undefined;
}
const DEFAULT_PATCH_INTERVAL_MS = 400;
export class StreamingAgentCard {
private currentMessageId: string | null = null;
private text = "";
private reasoningText = "";
private runStartedAt = Date.now();
private toolUseElapsedMs: number | undefined;
private flushChain: Promise<void> = Promise.resolve();
private flushScheduled = false;
private lastPatchAt = 0;
private interrupted = false;
private readonly runId: string;
private readonly rt: FeishuRuntime;
private readonly chatId: string;
private readonly sendOptions: SendMessageOptions | undefined;
private readonly patchIntervalMs: number;
private readonly maxMessageLength: number;
constructor(options: StreamingCardOptions) {
this.runId = options.runId;
this.rt = options.rt;
this.chatId = options.chatId;
this.sendOptions = options.sendOptions;
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
startToolUseTraceRun(this.runId);
}
// --- Public API ---
appendText(delta: string): void {
if (delta === "") return;
this.text += delta;
this.scheduleFlush();
}
appendReasoning(delta: string): void {
if (delta === "") return;
this.reasoningText += delta;
this.scheduleFlush();
}
onToolStart(toolName: string, toolUseId: string | undefined): void {
recordToolUseStart({ runId: this.runId, toolName, toolUseId, input: undefined });
this.scheduleFlush();
}
onToolEnd(params: {
toolName: string;
toolUseId: string | undefined;
input: unknown;
result: unknown;
error: string | undefined;
durationMs: number | undefined;
}): void {
recordToolUseEnd({ runId: this.runId, ...params });
this.scheduleFlush();
}
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): Promise<void> {
await this.flushChain;
this.interrupted = options.interrupted === true;
const footerText = options.footerText ?? "";
const fallbackWithFooter = appendFooter(fallbackText, footerText);
try {
let updated = true;
if (this.text.length > 0) {
this.text = appendFooter(this.text, footerText);
updated = await this.flushCard("complete", this.text);
} else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
// No streaming text was sent. If we never created a card, send one now.
this.text = fallbackWithFooter;
updated = await this.flushCard("complete", this.text);
} else if (this.currentMessageId !== null) {
// Patch the existing card with the final text.
updated = await this.flushCard("complete", fallbackWithFooter);
}
if (!updated && this.interrupted) {
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
}
} finally {
clearToolUseTraceRun(this.runId);
}
}
async fail(errorText: string): Promise<void> {
await this.flushChain;
try {
this.text = errorText;
await this.flushCard("complete", errorText, true);
} finally {
clearToolUseTraceRun(this.runId);
}
}
// --- Internal flush logic ---
private scheduleFlush(): void {
if (this.flushScheduled) return;
this.flushScheduled = true;
this.flushChain = this.flushChain.then(async () => {
this.flushScheduled = false;
await this.flush();
});
}
private async flush(): Promise<boolean> {
if (this.currentMessageId === null) {
const updated = await this.flushCard(this.currentPhase(), this.text);
this.lastPatchAt = Date.now();
return updated;
}
const now = Date.now();
if (now - this.lastPatchAt < this.patchIntervalMs) return true;
this.lastPatchAt = now;
return this.flushCard(this.currentPhase(), this.text);
}
private async flushCard(phase: CardPhase, text: string, isError = false): Promise<boolean> {
const chunks = splitAtBoundary(text, this.maxMessageLength);
const firstChunk = chunks[0];
if (firstChunk === undefined) return true;
const toolUseSteps = getToolUseTraceSteps(this.runId);
const card = buildAgentCard({
phase,
text: firstChunk,
reasoningText: this.reasoningText || undefined,
toolUseSteps,
toolUseElapsedMs: this.toolUseElapsedMs,
isError,
interrupted: this.interrupted,
runId: this.runId,
});
if (this.currentMessageId === null) {
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
let updated = this.currentMessageId !== null;
// Send overflow chunks as new messages (rare for agent output)
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
}
return updated;
} else {
let updated = await patchCard(this.rt, this.currentMessageId, card);
// For overflow, create new messages
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
}
return updated;
}
}
private currentPhase(): CardPhase {
if (this.text.length > 0) return "streaming";
if (this.reasoningText.length > 0) return "streaming";
return "thinking";
}
}
function appendFooter(text: string, footerText: string): string {
if (footerText === "") return text;
if (text === "") return footerText;
return `${text.trimEnd()}\n\n${footerText}`;
}
-225
View File
@@ -1,225 +0,0 @@
/**
* In-memory tool-use trace store, adapted from openclaw-lark's
* tool-use-trace-store. One trace per agent run; the card builder
* reads steps from here to render the tool-use panel.
*
* The store is per-run (not per-session) because each run has its
* own card lifecycle. Steps are capped to prevent unbounded memory.
*/
export interface ToolUseTraceStep {
readonly id: string;
readonly seq: number;
readonly toolName: string;
readonly toolUseId: string | undefined;
readonly input: unknown;
readonly result: unknown;
readonly error: string | undefined;
readonly durationMs: number | undefined;
readonly status: "running" | "success" | "error";
readonly startedAt: number;
readonly finishedAt: number | undefined;
}
interface RunTraceState {
nextSeq: number;
updatedAt: number;
steps: ToolUseTraceStep[];
}
const MAX_STEPS = 64;
const STEP_RUNNING_TIMEOUT_MS = 5 * 60 * 1000;
const RUN_TTL_MS = 30 * 60 * 1000;
const RESULT_LIMIT = 2048;
const GENERIC_LIMIT = 512;
const runs = new Map<string, RunTraceState>();
export function startToolUseTraceRun(runId: string): void {
pruneStaleRuns();
runs.set(runId, { nextSeq: 1, updatedAt: Date.now(), steps: [] });
}
export function clearToolUseTraceRun(runId: string): void {
runs.delete(runId);
}
export function recordToolUseStart(params: {
runId: string;
toolName: string;
toolUseId: string | undefined;
input: unknown;
}): void {
const state = runs.get(params.runId);
if (state === undefined) return;
if (state.steps.length >= MAX_STEPS) {
state.steps.splice(0, state.steps.length - MAX_STEPS + 1);
}
const now = Date.now();
state.steps.push({
id: `${state.nextSeq}`,
seq: state.nextSeq,
toolName: params.toolName,
toolUseId: params.toolUseId,
input: sanitizeTraceValue(params.input),
result: undefined,
error: undefined,
durationMs: undefined,
status: "running",
startedAt: now,
finishedAt: undefined,
});
state.nextSeq += 1;
state.updatedAt = now;
}
export function recordToolUseEnd(params: {
runId: string;
toolName: string;
toolUseId: string | undefined;
result: unknown;
error: string | undefined;
durationMs: number | undefined;
}): void {
const state = runs.get(params.runId);
if (state === undefined) return;
const now = Date.now();
const pendingIndex = findPendingStepIndex(state.steps, params.toolUseId, params.toolName);
if (pendingIndex >= 0) {
const step = state.steps[pendingIndex];
if (step !== undefined) {
(state.steps as Array<ToolUseTraceStep>)[pendingIndex] = {
...step,
status: params.error !== undefined ? "error" : "success",
result: sanitizeTraceValue(params.result),
error: params.error !== undefined ? truncate(params.error, 160) : undefined,
durationMs: params.durationMs,
finishedAt: now,
};
state.updatedAt = now;
return;
}
}
// No matching pending step — record as already-completed
state.steps.push({
id: `${state.nextSeq}`,
seq: state.nextSeq,
toolName: params.toolName,
toolUseId: params.toolUseId,
input: undefined,
result: sanitizeTraceValue(params.result),
error: params.error !== undefined ? truncate(params.error, 160) : undefined,
durationMs: params.durationMs,
status: params.error !== undefined ? "error" : "success",
startedAt: now,
finishedAt: now,
});
state.nextSeq += 1;
state.updatedAt = now;
}
export function getToolUseTraceSteps(runId: string): ToolUseTraceStep[] {
const state = runs.get(runId);
if (state === undefined) return [];
if (Date.now() - state.updatedAt > RUN_TTL_MS) {
runs.delete(runId);
return [];
}
const now = Date.now();
return state.steps.map((step) => {
if (step.status === "running" && now - step.startedAt > STEP_RUNNING_TIMEOUT_MS) {
return { ...step, status: "error" as const, error: "timed out", finishedAt: now };
}
return { ...step };
});
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function findPendingStepIndex(
steps: ToolUseTraceStep[],
toolUseId: string | undefined,
toolName: string | undefined,
): number {
if (toolUseId !== undefined) {
for (let i = steps.length - 1; i >= 0; i -= 1) {
const step = steps[i];
if (step !== undefined && step.status === "running" && step.toolUseId === toolUseId) {
return i;
}
}
}
if (toolName !== undefined) {
for (let i = steps.length - 1; i >= 0; i -= 1) {
const step = steps[i];
if (step !== undefined && step.status === "running" && step.toolName === toolName) {
return i;
}
}
}
return -1;
}
function pruneStaleRuns(): void {
const now = Date.now();
for (const [runId, state] of runs) {
if (now - state.updatedAt > RUN_TTL_MS) {
runs.delete(runId);
}
}
}
function truncate(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
}
/**
* Sanitize a tool input or result value for display.
* Truncates strings, redacts sensitive keys, limits depth.
*/
function sanitizeTraceValue(
value: unknown,
depth = 0,
context: { source: "input" | "result" | undefined; key: string | undefined } = { source: undefined, key: undefined },
): unknown {
if (value === null || value === undefined) return undefined;
if (typeof value === "string") {
const limit = context.source === "result" ? RESULT_LIMIT : GENERIC_LIMIT;
return truncate(value, limit);
}
if (typeof value === "number" || typeof value === "boolean") return value;
if (depth >= 2) return "[truncated]";
if (Array.isArray(value)) {
return value.slice(0, 8).map((item) => sanitizeTraceValue(item, depth + 1, { source: context.source, key: undefined }));
}
if (typeof value === "object") {
const input = value as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const [key, entryValue] of Object.entries(input).slice(0, 12)) {
output[key] = isSensitiveKey(key)
? "[redacted]"
: sanitizeTraceValue(entryValue, depth + 1, { source: context.source, key });
}
return output;
}
return truncate(String(value), 180);
}
const SENSITIVE_KEY_RE =
/token|secret|password|api[_-]?key|authorization|cookie|credential|bearer|session[_-]?id|client[_-]?secret|access[_-]?key/i;
function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEY_RE.test(key);
}
-902
View File
@@ -1,902 +0,0 @@
/**
* Feishu lark client + long-connection event dispatcher.
*
* Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
* message sending, streaming patch via interactive cards, emoji
* reactions, file download/upload, and the card action callback.
*/
import * as lark from "@larksuiteoapi/node-sdk";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
import type { SenderNameCache } from "./senderCache.js";
export interface FeishuConfig {
readonly appId: string;
readonly appSecret: string;
readonly botOpenId: string;
}
export interface FeishuRuntime {
readonly client: lark.Client;
readonly logger: FastifyBaseLogger;
}
export interface OutboundPayload {
readonly msgType: string;
readonly content: string;
}
export interface SendMessageOptions {
readonly replyToMessageId?: string | undefined;
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
baseDelayMs?: number;
shouldRetry?: (error: unknown) => boolean;
} = {},
): Promise<T> {
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
const baseDelayMs = options.baseDelayMs ?? 1000;
const shouldRetry = options.shouldRetry ?? (() => true);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts || !shouldRetry(error)) {
throw error;
}
const delay = baseDelayMs * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("withRetry reached an unreachable state");
}
/** Raw `im.message.receive_v1` event payload. */
export interface MessageReceiveEvent {
/** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */
readonly event_id?: string;
readonly event_type?: string;
readonly header?: { readonly event_id?: string; readonly event_type?: string };
readonly message: {
readonly message_id: string;
readonly root_id?: string;
readonly parent_id?: string;
readonly thread_id?: string;
readonly create_time?: string;
readonly update_time?: string;
readonly chat_id: string;
readonly chat_type: string;
readonly message_type: string;
readonly content: string;
readonly mentions?: ReadonlyArray<{
readonly key: string;
readonly id: { readonly open_id?: string };
readonly name: string;
}>;
};
readonly sender: {
readonly sender_id: { readonly open_id?: string };
readonly sender_type: string;
};
}
/** Card action trigger event (button clicks, select changes). */
export interface CardActionEvent {
readonly operator: { readonly open_id?: string };
readonly action: {
readonly value?: unknown;
readonly tag?: string;
readonly option?: string;
};
readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
readonly token?: string;
}
interface ContactBasicUser {
readonly name?: string | undefined;
readonly i18n_name?: {
readonly zh_cn?: string | undefined;
readonly en_us?: string | undefined;
readonly ja_jp?: string | undefined;
} | undefined;
}
type ContactUserBasicBatchResponse = {
readonly data?: {
readonly users?: readonly ContactBasicUser[] | undefined;
} | undefined;
} | null;
// --- Message helpers ---
export function buildOutboundPayload(content: string): OutboundPayload {
if (containsMarkdownTable(content)) {
return { msgType: "text", content: JSON.stringify({ text: content }) };
}
if (containsMarkdownSyntax(content)) {
return {
msgType: "post",
content: JSON.stringify({ zh_cn: { content: _buildMarkdownPostRows(content) } }),
};
}
return { msgType: "text", content: JSON.stringify({ text: content }) };
}
export function _buildMarkdownPostRows(content: string): unknown[][] {
if (!content.includes("```")) {
return [[{ tag: "md", text: content }]];
}
const rows: unknown[][] = [];
const codeBlockPattern = /```[\s\S]*?```/g;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = codeBlockPattern.exec(content)) !== null) {
if (match.index > cursor) {
rows.push([{ tag: "md", text: content.slice(cursor, match.index) }]);
}
rows.push([{ tag: "md", text: match[0] }]);
cursor = match.index + match[0].length;
}
if (rows.length === 0) {
return [[{ tag: "md", text: content }]];
}
if (cursor < content.length) {
rows.push([{ tag: "md", text: content.slice(cursor) }]);
}
return rows;
}
export function _stripMarkdownToPlainText(text: string): string {
return text
.replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1")
.replace(/`([^`\n]+)`/g, "$1")
.replace(/!\[([^\]\n]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]\n]+)\]\([^)]+\)/g, "$1")
.replace(/^ {0,3}#{1,6}\s+/gm, "")
.replace(/^ {0,3}>\s?/gm, "")
.replace(/^(\s*)[-*+]\s+/gm, "$1")
.replace(/^(\s*)\d+[.)]\s+/gm, "$1")
.replace(/\*\*([^*\n]+)\*\*/g, "$1")
.replace(/__([^_\n]+)__/g, "$1")
.replace(/~~([^~\n]+)~~/g, "$1")
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2")
.replace(/(^|[^_])_([^_\n]+)_/g, "$1$2");
}
export function parsePostMessage(content: string): { text: string; imageKeys: string[]; fileKeys: string[] } {
const parsed = parseJsonObject(content);
const root = parsed === null ? null : postRoot(parsed);
const locale = root === null ? null : selectPostLocale(root);
if (locale === null) {
return { text: "", imageKeys: [], fileKeys: [] };
}
const imageKeys: string[] = [];
const fileKeys: string[] = [];
const rows = Array.isArray(locale["content"]) ? locale["content"] : [];
const body = rows
.map((row) => {
const elements = Array.isArray(row) ? row : [row];
return elements.map((element) => renderPostElement(element, imageKeys, fileKeys)).join("");
})
.join("\n");
const title = stringField(locale, "title");
const text = title === undefined || title === "" ? body : body === "" ? `# ${title}` : `# ${title}\n\n${body}`;
return { text, imageKeys, fileKeys };
}
/** Send a text message using the best Feishu message type for the content. */
export async function sendTextMessage(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
const payload = buildOutboundPayload(text);
try {
return await sendMessagePayload(rt, chatId, payload, options);
} catch (e) {
if (payload.msgType === "post" && isPostContentFormatError(e)) {
try {
return await sendMessagePayload(
rt,
chatId,
{ msgType: "text", content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }) },
options,
);
} catch {
return null;
}
}
return null;
}
}
/** Send long text as multiple Feishu messages. Returns the last successful message_id. */
export async function sendLongText(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
let lastMessageId: string | null = null;
for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) {
const messageId = await sendTextMessage(rt, chatId, chunk, options);
if (messageId !== null) {
lastMessageId = messageId;
}
}
return lastMessageId;
}
/** Send a plain text message (fire-and-forget). */
export async function sendText(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<void> {
await sendTextMessage(rt, chatId, text, options);
}
/** Send an interactive card message (always patchable). Used for streaming. */
export async function sendInteractiveCardMessage(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
const payload = buildOutboundPayload(text);
const card = buildInteractiveCardFromOutboundPayload(payload);
try {
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch {
return null;
}
}
/** Patch a text-as-card message in-place (streaming updates). */
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
const payload = buildOutboundPayload(text);
const card = buildInteractiveCardFromOutboundPayload(payload);
const client = rt.client as unknown as {
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
};
try {
await client.im.v1.message.patch({
path: { message_id: messageId },
params: { msg_type: "interactive" },
data: { content: JSON.stringify(card) },
});
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchTextMessage failed");
}
}
/** Send a raw interactive card (arbitrary JSON). Returns message_id on success. */
export async function sendCard(
rt: FeishuRuntime,
chatId: string,
card: Record<string, unknown>,
options?: SendMessageOptions,
): Promise<string | null> {
try {
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch {
return null;
}
}
/** Patch an interactive card in-place with raw JSON. */
export async function patchCard(rt: FeishuRuntime, messageId: string, card: Record<string, unknown>): Promise<boolean> {
const client = rt.client as unknown as {
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
};
try {
await client.im.v1.message.patch({
path: { message_id: messageId },
params: { msg_type: "interactive" },
data: { content: JSON.stringify(card) },
});
return true;
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
return false;
}
}
/** Send an approval card with buttons. Returns message_id on success. */
export async function sendApprovalCard(
rt: FeishuRuntime,
chatId: string,
title: string,
body: string,
buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>,
options?: SendMessageOptions,
): Promise<string | null> {
const card = {
config: { wide_screen_mode: true },
header: { title: { content: title, tag: "plain_text" }, template: "orange" },
elements: [
{ tag: "markdown", content: body },
{
tag: "action",
actions: buttons.map((button) => ({
tag: "button",
text: { tag: "plain_text", content: button.label },
type: button.style ?? "default",
value: { approval_action: button.value },
})),
},
],
};
try {
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch (e) {
rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed");
return null;
}
}
/** Update a card to show it has been resolved. */
export async function resolveCard(
rt: FeishuRuntime,
messageId: string,
icon: string,
label: string,
template: "green" | "red",
resolvedBy: string,
): Promise<void> {
const card = {
config: { wide_screen_mode: true },
header: { title: { content: `${icon} ${label}`, tag: "plain_text" }, template },
elements: [{ tag: "markdown", content: `${icon} **${label}** by ${resolvedBy}` }],
};
const client = rt.client as unknown as {
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
};
try {
await client.im.v1.message.patch({
path: { message_id: messageId },
params: { msg_type: "interactive" },
data: { content: JSON.stringify(card) },
});
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "resolveCard failed");
}
}
/** React to a message with an emoji. */
export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise<void> {
try {
await rt.client.request({
method: "POST",
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
data: { reaction_type: { emoji_type: emoji } },
});
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "reactToMessage failed");
}
}
/** Add a reaction to a message, returning the reaction_id (needed for later removal). */
export async function addReaction(rt: FeishuRuntime, messageId: string, emojiType: string): Promise<string | null> {
try {
const res = await rt.client.request({
method: "POST",
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
data: { reaction_type: { emoji_type: emojiType } },
}) as ReactionCreateResponse;
const reactionId = reactionIdFromResponse(res);
if (reactionId === null) {
rt.logger.warn({ messageId, emojiType }, "addReaction failed: response missing reaction_id");
}
return reactionId;
} catch (e) {
rt.logger.warn({ messageId, emojiType, err: e instanceof Error ? e.message : String(e) }, "addReaction failed");
return null;
}
}
/** Remove a previously added reaction by its reaction_id. */
export async function removeReaction(rt: FeishuRuntime, messageId: string, reactionId: string): Promise<boolean> {
try {
await rt.client.request({
method: "DELETE",
url: `/open-apis/im/v1/messages/${messageId}/reactions/${reactionId}`,
});
return true;
} catch (e) {
rt.logger.warn({ messageId, reactionId, err: e instanceof Error ? e.message : String(e) }, "removeReaction failed");
return false;
}
}
/** Resolve a Feishu sender display name from open_id, optionally using a cache. */
export async function resolveSenderName(
rt: FeishuRuntime,
openId: string,
cache?: SenderNameCache,
): Promise<string | null> {
if (openId === "") return null;
const fetchName = async (id: string): Promise<string | null> => {
try {
const res = await rt.client.contact.v3.user.basicBatch({
params: { user_id_type: "open_id" },
data: { user_ids: [id] },
}) as ContactUserBasicBatchResponse;
const user = res?.data?.users?.[0];
return user === undefined ? null : contactBasicUserName(user);
} catch (e) {
rt.logger.warn({ openId: id, err: e instanceof Error ? e.message : String(e) }, "resolveSenderName failed");
return null;
}
};
if (cache !== undefined) {
return cache.getOrFetch(openId, fetchName);
}
return fetchName(openId);
}
function contactBasicUserName(user: ContactBasicUser): string | null {
const candidates = [
user.name,
user.i18n_name?.zh_cn,
user.i18n_name?.en_us,
user.i18n_name?.ja_jp,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim() !== "") return candidate.trim();
}
return null;
}
// --- File helpers ---
/** Download a file/image from a Feishu message to a local path. */
export async function downloadMessageFile(
rt: FeishuRuntime,
messageId: string,
fileKey: string,
savePath: string,
): Promise<void> {
await mkdir(dirname(savePath), { recursive: true });
try {
const res = await withRetry(async () => {
return rt.client.request({
method: "GET",
url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`,
params: { type: "file" },
responseType: "arraybuffer",
}) as Promise<{ data: Buffer }>;
});
await writeFile(savePath, res.data);
} catch (e) {
rt.logger.warn({ messageId, fileKey, err: e instanceof Error ? e.message : String(e) }, "downloadMessageFile failed");
}
}
/** Upload and send a file to a chat. */
export async function sendFile(
rt: FeishuRuntime,
chatId: string,
filePath: string,
fileName: string,
options?: SendMessageOptions,
): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: {
file: { create: (p: unknown) => Promise<FileCreateResponse> }
} };
};
try {
const buf = await readFile(filePath);
const ext = fileName.split(".").pop() ?? "";
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
const uploadRes = await withRetry(async () =>
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } }),
);
const fileKey = fileKeyFromResponse(uploadRes);
if (fileKey === undefined) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
return null;
}
const messageId = await withRetry(async () =>
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
);
if (messageId === null) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
}
return messageId;
} catch (e) {
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
return null;
}
}
async function sendMessagePayload(
rt: FeishuRuntime,
chatId: string,
payload: OutboundPayload,
options?: SendMessageOptions,
): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: { message: {
create: (p: unknown) => Promise<MessageCreateResponse>;
reply: (p: unknown) => Promise<MessageCreateResponse>;
} } };
};
const replyToMessageId = normalizeReplyMessageId(options?.replyToMessageId);
if (replyToMessageId !== null) {
const res = await client.im.v1.message.reply({
path: { message_id: replyToMessageId },
data: {
msg_type: payload.msgType,
content: payload.content,
},
});
return messageIdFromResponse(res);
}
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
});
return messageIdFromResponse(res);
}
function normalizeReplyMessageId(messageId: string | undefined): string | null {
return messageId === undefined || messageId === "" ? null : messageId;
}
type MessageCreateResponse = {
readonly message_id?: string | undefined;
readonly data?: { readonly message_id?: string | undefined } | undefined;
} | null;
type FileCreateResponse = {
readonly file_key?: string | undefined;
readonly data?: { readonly file_key?: string | undefined } | undefined;
} | null;
type ReactionCreateResponse = {
readonly reaction_id?: string | undefined;
readonly data?: { readonly reaction_id?: string | undefined } | undefined;
} | null;
function messageIdFromResponse(res: MessageCreateResponse): string | null {
return res?.data?.message_id ?? res?.message_id ?? null;
}
function fileKeyFromResponse(res: FileCreateResponse): string | undefined {
return res?.file_key ?? res?.data?.file_key;
}
function reactionIdFromResponse(res: ReactionCreateResponse): string | null {
return res?.data?.reaction_id ?? res?.reaction_id ?? null;
}
function parseJsonObject(content: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(content) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function postRoot(parsed: Record<string, unknown>): Record<string, unknown> {
const post = parsed["post"];
return isRecord(post) ? post : parsed;
}
function selectPostLocale(root: Record<string, unknown>): Record<string, unknown> | null {
if (Array.isArray(root["content"])) return root;
for (const locale of ["zh_cn", "en_us"]) {
const candidate = root[locale];
if (isPostLocale(candidate)) return candidate;
}
for (const candidate of Object.values(root)) {
if (isPostLocale(candidate)) return candidate;
}
return null;
}
function isPostLocale(value: unknown): value is Record<string, unknown> {
return isRecord(value) && (Array.isArray(value["content"]) || typeof value["title"] === "string");
}
function renderPostElement(element: unknown, imageKeys: string[], fileKeys: string[]): string {
if (!isRecord(element)) return "";
switch (stringField(element, "tag")) {
case "text":
return applyTextStyles(stringField(element, "text") ?? "", element);
case "a":
return renderLink(element);
case "at":
return renderMention(element);
case "img":
case "image":
pushStringField(imageKeys, element, "image_key");
return "[Image]";
case "media":
case "file":
case "audio":
case "video":
pushStringField(fileKeys, element, "file_key");
return renderAttachmentPlaceholder(element);
case "code":
case "code_block":
return renderCodeBlock(element);
case "br":
return "\n";
case "hr":
case "divider":
return "\n\n---\n\n";
case "emotion":
case "emoji":
return renderEmoji(element);
case "md":
return stringField(element, "text") ?? "";
default:
return stringField(element, "text") ?? "";
}
}
function renderLink(element: Record<string, unknown>): string {
const text = stringField(element, "text") ?? "";
const href = stringField(element, "href") ?? "";
if (href === "") return text;
return `[${text}](${href})`;
}
function renderMention(element: Record<string, unknown>): string {
if (stringField(element, "user_id") === "@_all") return "@all";
const name =
stringField(element, "user_name") ??
stringField(element, "name") ??
stringField(element, "text") ??
stringField(element, "user_id") ??
"";
return name === "" ? "" : `@${name.replace(/^@/, "")}`;
}
function renderAttachmentPlaceholder(element: Record<string, unknown>): string {
const fileName = stringField(element, "file_name") ?? stringField(element, "name");
return fileName === undefined || fileName === "" ? "[Attachment]" : `[Attachment: ${fileName}]`;
}
function renderCodeBlock(element: Record<string, unknown>): string {
const language = stringField(element, "language") ?? "";
const text = stringField(element, "text") ?? "";
return `\`\`\`${language}\n${text}\n\`\`\``;
}
function renderEmoji(element: Record<string, unknown>): string {
const emojiType =
stringField(element, "emoji_type") ??
stringField(element, "emotion_key") ??
stringField(element, "name") ??
stringField(element, "text");
return emojiType === undefined || emojiType === "" ? "" : `:${emojiType}:`;
}
function applyTextStyles(text: string, element: Record<string, unknown>): string {
let rendered = text;
if (hasPostStyle(element, "bold")) rendered = `**${rendered}**`;
if (hasPostStyle(element, "italic")) rendered = `*${rendered}*`;
if (hasPostStyle(element, "strikethrough", "strike", "line_through", "lineThrough")) rendered = `~~${rendered}~~`;
if (hasPostStyle(element, "code", "inline_code", "inlineCode")) rendered = `\`${rendered}\``;
return rendered;
}
function hasPostStyle(element: Record<string, unknown>, ...names: string[]): boolean {
const style = element["style"] ?? element["styles"];
if (styleMatches(style, names)) return true;
return names.some((name) => element[name] === true);
}
function styleMatches(style: unknown, names: readonly string[]): boolean {
if (Array.isArray(style)) {
return style.some((entry) => typeof entry === "string" && names.includes(entry));
}
if (typeof style === "string") {
const tokens = style.split(/[\s,]+/).filter((token) => token !== "");
return tokens.some((token) => names.includes(token));
}
if (isRecord(style)) {
return names.some((name) => style[name] === true || style[name] === "true");
}
return false;
}
function pushStringField(target: string[], record: Record<string, unknown>, field: string): void {
const value = stringField(record, field);
if (value !== undefined && value !== "") target.push(value);
}
function stringField(record: Record<string, unknown>, field: string): string | undefined {
const value = record[field];
return typeof value === "string" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function containsMarkdownSyntax(content: string): boolean {
const patterns = [
/^ {0,3}#{1,6}\s+\S/m,
/^ {0,3}>\s+\S/m,
/^ {0,3}(?:[-*+]\s+\S|\d+[.)]\s+\S)/m,
/```/,
/`[^`\n]+`/,
/\*\*[^*\n]+\*\*/,
/__[^_\n]+__/,
/~~[^~\n]+~~/,
/(^|[^*])\*[^*\s][^*\n]*\*(?!\*)/,
/(^|[^_])_[^_\s][^_\n]*_(?!_)/,
/\[[^\]\n]+\]\([^)]+\)/,
];
return patterns.some((pattern) => pattern.test(content));
}
function containsMarkdownTable(content: string): boolean {
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length - 1; i += 1) {
const current = lines[i];
const next = lines[i + 1];
if (current !== undefined && next !== undefined && isTableHeaderLine(current) && isTableSeparatorLine(next)) {
return true;
}
}
return false;
}
function isTableHeaderLine(line: string): boolean {
const trimmed = line.trim();
return trimmed.startsWith("|") && trimmed.includes("|", 1);
}
function isTableSeparatorLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed.startsWith("|")) return false;
const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|");
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
}
function buildInteractiveCardFromOutboundPayload(payload: OutboundPayload): { elements: Array<{ tag: "div"; text: { tag: "lark_md"; content: string } }> } {
return {
elements: rowsFromOutboundPayload(payload).map((row) => ({
tag: "div",
text: { tag: "lark_md", content: row.map(markdownTextFromPostElement).join("") },
})),
};
}
function rowsFromOutboundPayload(payload: OutboundPayload): unknown[][] {
if (payload.msgType !== "post") {
return [[{ tag: "md", text: textFromTextPayload(payload.content) }]];
}
try {
const parsed = JSON.parse(payload.content) as { zh_cn?: { content?: unknown } };
return Array.isArray(parsed.zh_cn?.content) ? parsed.zh_cn.content as unknown[][] : [[{ tag: "md", text: "" }]];
} catch {
return [[{ tag: "md", text: "" }]];
}
}
function textFromTextPayload(content: string): string {
try {
const parsed = JSON.parse(content) as { text?: unknown };
return typeof parsed.text === "string" ? parsed.text : content;
} catch {
return content;
}
}
function markdownTextFromPostElement(element: unknown): string {
if (typeof element !== "object" || element === null) return "";
const text = (element as { text?: unknown }).text;
return typeof text === "string" ? text : "";
}
function isPostContentFormatError(error: unknown): boolean {
return errorText(error).includes("content format of the post type is incorrect");
}
function errorText(error: unknown): string {
if (error instanceof Error) {
return `${error.message} ${errorText((error as { response?: unknown }).response)}`;
}
if (typeof error === "string") return error;
if (typeof error === "object" && error !== null) {
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
return String(error);
}
// --- Lark client + ws listener ---
export function createLarkClient(config: FeishuConfig): lark.Client {
return new lark.Client({
appId: config.appId,
appSecret: config.appSecret,
appType: lark.AppType.SelfBuild,
});
}
export function startFeishuListenerWithClient(
config: FeishuConfig,
client: lark.Client,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
const wsClient = new lark.WSClient({
appId: config.appId,
appSecret: config.appSecret,
domain: lark.Domain.Feishu,
loggerLevel: lark.LoggerLevel.info,
});
const rt: FeishuRuntime = { client, logger };
const handlers: Record<string, (data: unknown) => Promise<void>> = {
"im.message.receive_v1": async (data) => {
try { await onMessage(data as MessageReceiveEvent, rt); }
catch (e) { logger.error({ err: e }, "feishu message handler threw"); }
},
};
if (onCardAction !== undefined) {
handlers["card.action.trigger"] = async (data) => {
void Promise.resolve()
.then(() => onCardAction(data as CardActionEvent, rt))
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
};
}
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
return rt;
}
export function startFeishuListener(
config: FeishuConfig,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
}
-95
View File
@@ -1,95 +0,0 @@
import { access, stat } from "node:fs/promises";
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
export interface DeliverableFile {
readonly path: string;
readonly name: string;
}
const DELIVERABLE_EXTENSIONS = new Set([
".csv",
".doc",
".docx",
".gif",
".jpeg",
".jpg",
".md",
".pdf",
".png",
".ppt",
".pptx",
".svg",
".txt",
".typ",
".xls",
".xlsx",
".zip",
]);
export async function resolveDeliverableFile(
requestedPath: string,
workspaceDir: string,
): Promise<DeliverableFile | null> {
const token = requestedPath.trim();
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) {
return null;
}
const roots = await allowedRoots(workspaceDir);
for (const candidate of candidatePaths(token, workspaceDir, roots)) {
if (!isAllowedPath(candidate, roots)) continue;
const existing = await existingFile(candidate);
if (existing !== null) return { path: existing, name: basename(existing) };
}
return null;
}
async function allowedRoots(workspaceDir: string): Promise<string[]> {
const roots = [resolve(workspaceDir)];
const gitRoot = await findGitRoot(workspaceDir);
if (gitRoot !== undefined) roots.push(gitRoot);
return [...new Set(roots)];
}
async function findGitRoot(startDir: string): Promise<string | undefined> {
let current = resolve(startDir);
while (true) {
try {
await access(join(current, ".git"));
return current;
} catch {
const parent = resolve(current, "..");
if (parent === current) return undefined;
current = parent;
}
}
}
function candidatePaths(token: string, workspaceDir: string, roots: readonly string[]): string[] {
if (isAbsolute(token)) return [resolve(token)];
const candidates: string[] = [];
for (const root of roots) {
candidates.push(resolve(root, token));
}
if (!token.includes("/") && !token.includes("\\")) {
candidates.push(resolve(workspaceDir, "build", token));
}
return [...new Set(candidates)];
}
function isAllowedPath(path: string, roots: readonly string[]): boolean {
return roots.some((root) => {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
});
}
async function existingFile(path: string): Promise<string | null> {
try {
const s = await stat(path);
return s.isFile() ? resolve(path) : null;
} catch {
return null;
}
}
-166
View File
@@ -1,166 +0,0 @@
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import { resolveDeliverableFile } from "./fileDelivery.js";
import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly projectId: string;
readonly runId: string;
readonly workspaceDir: string;
readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void;
readonly tools?: readonly CphHubMcpToolId[] | undefined;
}
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
const enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS);
const tools: Array<SdkMcpToolDefinition<any>> = [];
if (enabledTools.has("send_file")) {
tools.push(
tool(
"send_file",
"Upload an existing project/repository file to the current Feishu chat. The path must point to a concrete file, for example build/student.pdf or README.md.",
{
path: z.string().describe("Workspace-relative, repository-relative, or absolute path to the existing file."),
name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
},
async (args) => {
const file = await resolveDeliverableFile(args.path, options.workspaceDir);
if (file === null) {
return {
isError: true,
content: [{ type: "text", text: `File not found or not deliverable: ${args.path}` }],
};
}
const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name, options.sendOptions);
if (messageId === null) {
return {
isError: true,
content: [{ type: "text", text: `Failed to send file: ${file.path}` }],
};
}
options.onDelivered?.(file.path);
return {
content: [{ type: "text", text: `Sent file: ${file.path}` }],
};
},
{ alwaysLoad: true },
),
);
}
if (enabledTools.has("feishu_read_context")) {
tools.push(
tool(
"feishu_read_context",
"Read Feishu context for the current project's bound chat. Use anchor ids from the trigger context; this tool never reads arbitrary chats.",
{
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of Feishu anchor to read."),
id: z.string().describe("The message/thread/status anchor id to read."),
},
async (args) => {
const result = await readFeishuContext(
{ chat_id: options.chatId, anchor: args.anchor, id: args.id },
{
runId: options.runId,
projectId: options.projectId,
boundChatId: options.chatId,
workspaceDir: options.workspaceDir,
},
options.rt,
);
return { content: [{ type: "text", text: result }] };
},
{ alwaysLoad: true },
),
);
}
if (enabledTools.has("request_approval")) {
tools.push(
tool(
"request_approval",
"Send an interactive Feishu approval/confirmation card to the current chat and wait for a button click.",
{
title: z.string().describe("Card title."),
body: z.string().describe("Markdown card body explaining what needs approval or confirmation."),
options: z
.array(z.object({
label: z.string().describe("Button label shown to the user."),
value: z.string().describe("Action value returned to the agent when this button is clicked."),
style: z.enum(["primary", "default", "danger"]).optional().describe("Optional Feishu button style."),
}))
.min(1)
.describe("Button choices for the approval card."),
},
async (args) => {
const messageId = await sendApprovalCard(
options.rt,
options.chatId,
args.title,
args.body,
args.options.map((option) => ({
label: option.label,
value: option.value,
...(option.style === undefined ? {} : { style: option.style }),
})),
options.sendOptions,
);
if (messageId === null) {
return {
isError: true,
content: [{ type: "text", text: "Failed to send approval card." }],
};
}
try {
const result = await options.approvalManager.register(messageId, options.chatId);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (e) {
return {
isError: true,
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
};
}
},
{ alwaysLoad: true },
),
);
}
const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({
name: "cph_hub",
version: "0.0.0",
alwaysLoad: true,
...(instructions === "" ? {} : { instructions }),
tools,
});
}
function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
const instructions: string[] = [];
if (enabledTools.has("send_file")) {
instructions.push(
"Use send_file when the user asks to receive, resend, download, or attach a file.",
"Do not claim a file was sent unless send_file returns success.",
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
);
}
if (enabledTools.has("feishu_read_context")) {
instructions.push("Use feishu_read_context when the trigger context contains a reply, thread, or message anchor and more Feishu context is needed.");
}
if (enabledTools.has("request_approval")) {
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
}
return instructions.join(" ");
}
-125
View File
@@ -1,125 +0,0 @@
export interface MessageBatcherOptions {
/** Default: 600. */
readonly debounceMs?: number;
/** Default: 8. */
readonly maxMessages?: number;
/** Default: 4000. */
readonly maxChars?: number;
/** Default: 3500. */
readonly splitThreshold?: number;
/** Default: 1200. */
readonly extendedDebounceMs?: number;
}
export type MessageBatcherCallback = (mergedText: string, key?: string) => Promise<void>;
interface PendingBatch {
readonly texts: string[];
charCount: number;
timer: ReturnType<typeof setTimeout> | null;
}
const DEFAULT_OPTIONS = {
debounceMs: 600,
maxMessages: 8,
maxChars: 4000,
splitThreshold: 3500,
extendedDebounceMs: 1200,
} as const;
export class MessageBatcher {
private readonly debounceMs: number;
private readonly maxMessages: number;
private readonly maxChars: number;
private readonly splitThreshold: number;
private readonly extendedDebounceMs: number;
private readonly pending = new Map<string, PendingBatch>();
private readonly chains = new Map<string, Promise<void>>();
constructor(
private readonly onFlush: MessageBatcherCallback,
options: MessageBatcherOptions = {},
) {
this.debounceMs = options.debounceMs ?? DEFAULT_OPTIONS.debounceMs;
this.maxMessages = options.maxMessages ?? DEFAULT_OPTIONS.maxMessages;
this.maxChars = options.maxChars ?? DEFAULT_OPTIONS.maxChars;
this.splitThreshold = options.splitThreshold ?? DEFAULT_OPTIONS.splitThreshold;
this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs;
}
async enqueue(chatId: string, senderOpenId: string, text: string): Promise<void> {
const key = messageBatchKey(chatId, senderOpenId);
await this.runSerial(key, async () => {
const existing = this.pending.get(key);
const batch =
existing ??
{
texts: [],
charCount: 0,
timer: null,
};
if (existing === undefined) {
this.pending.set(key, batch);
}
batch.texts.push(text);
batch.charCount += text.length + (batch.texts.length === 1 ? 0 : 1);
if (batch.texts.length >= this.maxMessages || batch.charCount >= this.maxChars) {
await this.flushPendingBatch(key, batch);
return;
}
this.scheduleFlush(key, batch, text.length >= this.splitThreshold ? this.extendedDebounceMs : this.debounceMs);
});
}
async flushNow(key: string): Promise<void> {
await this.runSerial(key, async () => {
const batch = this.pending.get(key);
if (batch === undefined) return;
await this.flushPendingBatch(key, batch);
});
}
async flushAll(): Promise<void> {
const keys = [...this.pending.keys()];
await Promise.all(keys.map((key) => this.flushNow(key)));
}
private scheduleFlush(key: string, batch: PendingBatch, delayMs: number): void {
if (batch.timer !== null) {
clearTimeout(batch.timer);
}
batch.timer = setTimeout(() => {
void this.flushNow(key).catch(() => undefined);
}, delayMs);
(batch.timer as { unref?: () => void }).unref?.();
}
private async flushPendingBatch(key: string, batch: PendingBatch): Promise<void> {
if (batch.timer !== null) {
clearTimeout(batch.timer);
batch.timer = null;
}
if (this.pending.get(key) !== batch) return;
this.pending.delete(key);
await this.onFlush(batch.texts.join("\n"), key);
}
private runSerial(key: string, operation: () => Promise<void>): Promise<void> {
const previous = this.chains.get(key) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(operation);
this.chains.set(key, next);
return next.finally(() => {
if (this.chains.get(key) === next) {
this.chains.delete(key);
}
});
}
}
export function messageBatchKey(chatId: string, senderOpenId: string): string {
return `${chatId}:${senderOpenId}`;
}
-154
View File
@@ -1,154 +0,0 @@
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");
}
-103
View File
@@ -1,103 +0,0 @@
/**
* Feishu context reader — the real implementation behind `feishuContextTool`.
*
* ADR-0003: Hub stores only anchors, reads Feishu context on demand. The tool
* (`feishu_read_context`) is already chat-authorized by `tools.ts` (chat must
* match the project binding). Here we actually call lark's `im.v1.message`:
*
* - `trigger_message` / `reply`: `message.get` by message_id.
* - `status_card`: the run's status card message — same `message.get` by id.
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
* `parent_message_id` filter; we map "thread" to that.
*
* The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types);
* we cast through a known request/response shape and return a compact JSON for
* the model. All calls use the chat-authorized `client` from the runtime — no
* separate credential path, so the ADR-0003 invariant holds end-to-end.
*/
import type { FeishuRuntime } from "./client.js";
import type { FeishuContextArgs } from "../agent/tools.js";
import type { ToolContext } from "../agent/tools.js";
/** Pragmatic shape of the lark message object we consume. */
interface LarkMessage {
message_id?: string;
msg_type?: string;
content?: string;
body?: { content?: string };
create_time?: string;
sender?: { id?: string; sender_type?: string };
chat_id?: string;
parent_id?: string;
parent_message_id?: string;
root_id?: string;
thread_id?: string;
}
interface ImV1Message {
get: (p: { path: { message_id: string } }) => Promise<{ data?: { items?: LarkMessage[]; message?: LarkMessage } }>;
list: (p: {
params: { container_id_type: string; container_id: string; page_size?: number };
}) => Promise<{ data?: { items?: LarkMessage[] } }>;
}
function im(rt: FeishuRuntime): ImV1Message {
return (rt.client as unknown as { im: { v1: { message: ImV1Message } } }).im.v1.message;
}
/** Read on-demand Feishu context for the current run. Entry point for the tool. */
export async function readFeishuContext(
args: FeishuContextArgs,
_ctx: ToolContext,
rt: unknown,
): Promise<string> {
const runtime = rt as FeishuRuntime;
const api = im(runtime);
try {
switch (args.anchor) {
case "trigger_message":
case "status_card":
case "reply": {
// All three are single-message reads by id.
const res = await api.get({ path: { message_id: args.id } });
const msg = res.data?.message ?? res.data?.items?.[0];
if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id });
return JSON.stringify(compact(msg));
}
case "thread": {
// Thread = replies to a parent message. `container_id` is the parent's
// message_id; container_id_type=message_id scopes the list to that thread.
const res = await api.list({
params: {
container_id_type: "message_id",
container_id: args.id,
page_size: 50,
},
});
const items = res.data?.items ?? [];
return JSON.stringify(items.map(compact));
}
default:
return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` });
}
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
}
/** Project a lark message down to the fields the model actually needs. */
function compact(m: LarkMessage): Record<string, unknown> {
const parentId = m.parent_id ?? m.parent_message_id;
return {
message_id: m.message_id,
msg_type: m.msg_type,
content: m.body?.content ?? m.content,
create_time: m.create_time,
chat_id: m.chat_id,
sender_id: m.sender?.id,
parent_id: parentId,
parent_message_id: parentId,
root_id: m.root_id,
thread_id: m.thread_id,
};
}
-82
View File
@@ -1,82 +0,0 @@
export interface SenderCacheOptions {
readonly ttlMs?: number;
readonly maxSize?: number;
}
interface SenderCacheEntry {
readonly name: string;
readonly expiresAt: number;
}
const DEFAULT_TTL_MS = 600_000;
const DEFAULT_MAX_SIZE = 2_048;
export class SenderNameCache {
private readonly ttlMs: number;
private readonly maxSize: number;
private readonly entries = new Map<string, SenderCacheEntry>();
constructor(options: SenderCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
this.maxSize = Math.max(0, options.maxSize ?? DEFAULT_MAX_SIZE);
}
get(openId: string): string | null {
const entry = this.entries.get(openId);
if (entry === undefined) return null;
if (entry.expiresAt <= Date.now()) {
this.entries.delete(openId);
return null;
}
this.entries.delete(openId);
this.entries.set(openId, entry);
return entry.name;
}
set(openId: string, name: string): void {
if (this.maxSize === 0) return;
this.entries.delete(openId);
this.entries.set(openId, { name, expiresAt: Date.now() + this.ttlMs });
while (this.entries.size > this.maxSize) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
}
async getOrFetch(
openId: string,
fetchFn: (openId: string) => Promise<string | null>,
): Promise<string | null> {
const cached = this.get(openId);
if (cached !== null) return cached;
const fetched = await fetchFn(openId);
if (fetched !== null) {
this.set(openId, fetched);
}
return fetched;
}
clear(): void {
this.entries.clear();
}
size(): number {
this.pruneExpired();
return this.entries.size;
}
private pruneExpired(): void {
const now = Date.now();
for (const [openId, entry] of this.entries) {
if (entry.expiresAt <= now) {
this.entries.delete(openId);
}
}
}
}
-412
View File
@@ -1,412 +0,0 @@
import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
import type { ModelRegistry, RoleEntry } from "../agent/models.js";
import type { RuntimeSettings } from "../settings/runtime.js";
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import type { TriggerQueue } from "./triggerQueue.js";
export interface SlashInvocation {
readonly name: string;
readonly args: readonly string[];
}
export interface SlashCommandRunContext {
readonly invocation: SlashInvocation;
readonly projectId: string;
readonly chatId: string;
readonly rt: FeishuRuntime;
readonly sendOptions?: SendMessageOptions | undefined;
}
export interface SlashCommandDefinition {
readonly name: string;
readonly usage: string;
readonly summary: string;
readonly details: readonly string[];
run(context: SlashCommandRunContext): Promise<void>;
}
export interface SlashCommandRegistryDeps {
readonly prisma: PrismaClient;
readonly settings: RuntimeSettings;
readonly logger: FastifyBaseLogger;
readonly triggerQueue: TriggerQueue;
}
const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const;
export function parseSlashInvocation(prompt: string): SlashInvocation | null {
const trimmed = prompt.trim();
if (!trimmed.startsWith("/")) return null;
const tokens = trimmed.split(/\s+/);
const rawCommand = tokens[0];
if (rawCommand === undefined || rawCommand.length <= 1) return null;
return {
name: rawCommand.slice(1),
args: tokens.slice(1),
};
}
export function parseSlashHelpSubcommand(invocation: SlashInvocation): string | null {
if (invocation.name === "help") return null;
return invocation.args.length === 1 && invocation.args[0] === "help"
? invocation.name
: null;
}
export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): ReadonlyMap<string, SlashCommandDefinition> {
const commands = new Map<string, SlashCommandDefinition>();
const add = (command: SlashCommandDefinition): void => {
if (commands.has(command.name)) {
throw new Error(`duplicate slash command definition: /${command.name}`);
}
commands.set(command.name, command);
};
add({
name: "help",
usage: "/help [command]",
summary: "查看可用 slash 命令或单个命令说明。",
details: [
"不创建 agent run,也不改变当前会话。",
"支持 /help new 和 /new help 两种写法。",
],
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
const registry = await deps.settings.modelRegistry({ projectId });
await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands), sendOptions);
},
});
add({
name: "new",
usage: "/new",
summary: "开新会话,下次 @bot 将从头开始。",
details: [
"归档当前未归档的 agent session。",
"不会清空当前项目的等待队列。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions);
},
});
add({
name: "resume",
usage: "/resume",
summary: "恢复最近一次已归档的会话。",
details: [
"只恢复当前项目最近归档的 agent session。",
"没有可恢复会话时只回复提示,不会创建 agent run。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
const latest = await deps.prisma.agentSession.findFirst({
where: { projectId, archivedAt: { not: null } },
orderBy: { archivedAt: "desc" },
select: { id: true },
});
if (latest === null) {
await sendText(rt, chatId, "没有可恢复的会话。", sendOptions);
return;
}
await deps.prisma.agentSession.update({
where: { id: latest.id },
data: { archivedAt: null },
});
await sendText(rt, chatId, "已恢复上一个会话。", sendOptions);
},
});
add({
name: "cost",
usage: "/cost",
summary: "查看当前会话已记录的 agent 成本。",
details: [
"只读取当前项目未归档 agent session 下已经结束的 run,不创建 agent run。",
"只统计运行时真实记录到 AgentRun.costUsd 的成本;未记录成本的 run 会单独列出。",
],
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
if (invocation.args.length > 0) {
await sendText(rt, chatId, ["用法错误: /cost 暂不接受参数。", "", formatBuiltinSlashCommandHelp(commands.get("cost")!)].join("\n"), sendOptions);
return;
}
const sessions = await deps.prisma.agentSession.findMany({
where: { projectId, archivedAt: null },
orderBy: { updatedAt: "asc" },
select: { id: true },
});
if (sessions.length === 0) {
await sendText(rt, chatId, "当前会话还没有 agent session。", sendOptions);
return;
}
const runs = await deps.prisma.agentRun.findMany({
where: {
projectId,
sessionId: { in: sessions.map((session) => session.id) },
status: { in: [...TERMINAL_RUN_STATUSES] },
finishedAt: { not: null },
},
orderBy: { finishedAt: "asc" },
select: {
model: true,
provider: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
},
});
await sendText(rt, chatId, formatCostReport(runs), sendOptions);
},
});
add({
name: "reset",
usage: "/reset",
summary: "重置当前会话并清空等待队列。",
details: [
"归档当前未归档的 agent session。",
"清空当前项目已经排队、尚未开始的触发请求。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
const cleared = deps.triggerQueue.clear(projectId);
if (cleared > 0) {
deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset");
}
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。", sendOptions);
},
});
return commands;
}
interface CostReportRun {
readonly model: string;
readonly provider: string;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
readonly costUsd: unknown;
}
interface CostReportBucket {
readonly provider: string;
readonly model: string;
runs: number;
inputTokens: number;
outputTokens: number;
costUsd: number;
}
function formatCostReport(runs: readonly CostReportRun[]): string {
if (runs.length === 0) {
return "当前会话还没有已结束的 agent run。";
}
const buckets = new Map<string, CostReportBucket>();
let recordedRuns = 0;
let unrecordedRuns = 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCostUsd = 0;
for (const run of runs) {
const costUsd = decimalToNumberOrNull(run.costUsd);
if (costUsd === null) {
unrecordedRuns++;
continue;
}
recordedRuns++;
const inputTokens = run.inputTokens ?? 0;
const outputTokens = run.outputTokens ?? 0;
totalInputTokens += inputTokens;
totalOutputTokens += outputTokens;
totalCostUsd += costUsd;
const key = `${run.provider}\u0000${run.model}`;
let bucket = buckets.get(key);
if (bucket === undefined) {
bucket = {
provider: run.provider,
model: run.model,
runs: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
};
buckets.set(key, bucket);
}
bucket.runs++;
bucket.inputTokens += inputTokens;
bucket.outputTokens += outputTokens;
bucket.costUsd += costUsd;
}
if (recordedRuns === 0) {
return [
"当前会话已有已结束 agent run,但还没有任何 run 记录到真实成本。",
`未记录成本: ${formatInteger(unrecordedRuns)} runs。`,
"后续 run 需要 SDK 返回 total_cost_usd 才会进入 /cost 合计。",
].join("\n");
}
const lines = [
"当前会话已记录 agent 成本:",
`总计: ${formatUsd(totalCostUsd)}`,
`Runs: ${formatInteger(recordedRuns)} 已记录${unrecordedRuns > 0 ? ` / ${formatInteger(unrecordedRuns)} 未记录` : ""}`,
`Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`,
"",
"按模型:",
];
const sortedBuckets = [...buckets.values()].sort((a, b) => b.costUsd - a.costUsd);
for (const bucket of sortedBuckets) {
lines.push(
`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}, input ${formatInteger(bucket.inputTokens)} / output ${formatInteger(bucket.outputTokens)}`,
);
}
return lines.join("\n");
}
function formatHelpCommandInvocation(
invocation: SlashInvocation,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
if (invocation.args.length > 1) {
const helpCommand = slashCommands.get("help");
if (helpCommand === undefined) {
throw new Error("slash command registry is missing /help");
}
return [
"用法错误: /help 只接受一个命令名。",
"",
formatBuiltinSlashCommandHelp(helpCommand),
].join("\n");
}
const target = invocation.args[0];
if (target === undefined) return formatSlashOverview(registry, slashCommands);
const normalizedTarget = normalizeHelpTarget(target);
if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands);
return formatSlashHelpTarget(normalizedTarget, registry, slashCommands);
}
export function formatSlashHelpTarget(
target: string,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
const normalizedTarget = normalizeHelpTarget(target);
if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands);
return formatSlashCommandHelp(normalizedTarget, registry, slashCommands) ?? formatUnknownSlashHelp(normalizedTarget, registry, slashCommands);
}
function normalizeHelpTarget(target: string): string {
return target.trim().replace(/^\/+/, "");
}
function formatSlashOverview(
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
const lines = [
"可用 slash 命令:",
...[...slashCommands.values()].map((command) => `/${command.name} - ${command.summary}`),
];
const roles = visibleRoleCommands(registry, slashCommands);
if (roles.length > 0) {
lines.push("", "角色命令:");
for (const role of roles) {
lines.push(`/${role.id} <需求> - 使用“${role.label}”角色发起请求。`);
}
} else {
lines.push("", "当前没有配置角色命令。");
}
lines.push("", "查看单个命令: /help new 或 /new help。");
return lines.join("\n");
}
function formatSlashCommandHelp(
commandName: string,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string | null {
const normalizedName = normalizeHelpTarget(commandName);
const slashCommand = slashCommands.get(normalizedName);
if (slashCommand !== undefined) return formatBuiltinSlashCommandHelp(slashCommand);
const role = registry.role(normalizedName);
if (role === undefined) return null;
if (slashCommands.has(role.id)) return null;
return formatRoleSlashCommandHelp(role);
}
function formatBuiltinSlashCommandHelp(command: SlashCommandDefinition): string {
const helpUsage = command.name === "help"
? "帮助: /help help"
: `帮助: /help ${command.name} 或 /${command.name} help`;
return [
`/${command.name}`,
command.summary,
"",
`用法: ${command.usage}`,
...command.details.map((detail) => `- ${detail}`),
"",
helpUsage,
].join("\n");
}
function formatRoleSlashCommandHelp(role: RoleEntry): string {
const lines = [
`/${role.id}`,
`使用“${role.label}”角色发起一次 agent run。`,
"",
`用法: /${role.id} <需求>`,
"- 真正运行时仍会按当前项目的 role.trigger 授权检查。",
];
if (role.defaultModel !== undefined) {
lines.push(`- 默认模型: ${role.defaultModel}`);
}
lines.push(`- 工具范围: ${roleToolsDescription(role)}`, "", `帮助: /help ${role.id} 或 /${role.id} help`);
return lines.join("\n");
}
function roleToolsDescription(role: RoleEntry): string {
if (role.tools === undefined) return "全部已注册工具";
if (role.tools.length === 0) return "无";
return role.tools.join(", ");
}
function formatUnknownSlashHelp(
commandName: string,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
const normalizedName = normalizeHelpTarget(commandName);
return [
`未知 slash 命令 /${normalizedName}`,
"",
formatSlashOverview(registry, slashCommands),
].join("\n");
}
function visibleRoleCommands(
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): readonly RoleEntry[] {
return registry
.listRoles()
.filter((role) => !slashCommands.has(role.id));
}
-210
View File
@@ -1,210 +0,0 @@
export interface PatchableTextSink {
readonly create: (text: string) => Promise<string | null>;
readonly patch: (messageId: string, text: string) => Promise<void>;
}
export interface PatchableTextStreamOptions {
readonly patchIntervalMs?: number;
readonly maxMessageLength?: number;
}
const DEFAULT_PATCH_INTERVAL_MS = 400;
export const DEFAULT_MAX_MESSAGE_LENGTH = 8000;
interface CodeBlockRange {
readonly start: number;
readonly end: number;
}
export function splitAtBoundary(text: string, maxLength: number): string[] {
if (!Number.isInteger(maxLength) || maxLength <= 0) {
throw new RangeError("maxLength must be a positive integer");
}
if (text.length <= maxLength) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > maxLength) {
const splitPoint = chooseSplitPoint(remaining, maxLength);
if (splitPoint <= 0) {
const codeBlockEnd = codeBlockAtStartEnd(remaining);
const forcedSplitPoint = codeBlockEnd ?? maxLength;
chunks.push(remaining.slice(0, forcedSplitPoint));
remaining = remaining.slice(forcedSplitPoint);
continue;
}
chunks.push(remaining.slice(0, splitPoint));
remaining = remaining.slice(splitPoint);
}
if (remaining.length > 0) {
chunks.push(remaining);
}
return chunks;
}
function chooseSplitPoint(text: string, maxLength: number): number {
const codeBlocks = findFencedCodeBlocks(text);
const splitCodeBlock = codeBlocks.find((range) => maxLength > range.start && maxLength < range.end);
if (splitCodeBlock !== undefined) {
return splitCodeBlock.start;
}
return (
lastBoundary(text, maxLength, /\r?\n\r?\n/g, codeBlocks) ??
lastBoundary(text, maxLength, /\r?\n/g, codeBlocks) ??
lastBoundary(text, maxLength, / /g, codeBlocks) ??
maxLength
);
}
function lastBoundary(text: string, maxLength: number, pattern: RegExp, codeBlocks: ReadonlyArray<CodeBlockRange>): number | undefined {
let boundary: number | undefined;
let match: RegExpExecArray | null;
pattern.lastIndex = 0;
while ((match = pattern.exec(text)) !== null) {
const splitPoint = match.index + match[0].length;
if (splitPoint > maxLength) break;
if (splitPoint > 0 && !isInsideCodeBlock(splitPoint, codeBlocks)) {
boundary = splitPoint;
}
}
return boundary;
}
function isInsideCodeBlock(splitPoint: number, codeBlocks: ReadonlyArray<CodeBlockRange>): boolean {
return codeBlocks.some((range) => splitPoint > range.start && splitPoint < range.end);
}
function codeBlockAtStartEnd(text: string): number | undefined {
const firstCodeBlock = findFencedCodeBlocks(text)[0];
return firstCodeBlock?.start === 0 ? firstCodeBlock.end : undefined;
}
function findFencedCodeBlocks(text: string): CodeBlockRange[] {
const ranges: CodeBlockRange[] = [];
let openStart: number | null = null;
let lineStart = 0;
while (lineStart < text.length) {
const newlineIndex = text.indexOf("\n", lineStart);
const lineEnd = newlineIndex === -1 ? text.length : newlineIndex;
const nextLineStart = newlineIndex === -1 ? text.length : newlineIndex + 1;
const line = text.slice(lineStart, lineEnd);
if (/^ {0,3}```/.test(line)) {
if (openStart === null) {
openStart = lineStart;
} else {
ranges.push({ start: openStart, end: nextLineStart });
openStart = null;
}
}
lineStart = nextLineStart;
}
if (openStart !== null) {
ranges.push({ start: openStart, end: text.length });
}
return ranges;
}
/**
* Serializes text-card creation/patching for streaming output.
*
* Feishu returns the message id asynchronously. Without this queue, multiple
* early text deltas can all observe "no message yet" and create duplicate
* prefix messages before the first create call resolves.
*/
export class PatchableTextStream {
private currentMessageId: string | null = null;
private text = "";
private lastPatchAt = 0;
private flushChain: Promise<void> = Promise.resolve();
private flushScheduled = false;
private readonly patchIntervalMs: number;
private readonly maxMessageLength: number;
constructor(
private readonly sink: PatchableTextSink,
options: PatchableTextStreamOptions = {},
) {
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
}
append(delta: string): void {
if (delta === "") return;
this.text += delta;
this.scheduleFlush();
}
async finish(fallbackText: string): Promise<void> {
await this.flushChain;
// If there's pending text to flush, do it now.
if (this.text.length > 0) {
await this.flushTextToSink();
return;
}
// No pending text. If we never sent anything, send the fallback.
if (this.currentMessageId === null && fallbackText.length > 0) {
this.text = fallbackText;
await this.flushTextToSink();
}
}
private scheduleFlush(): void {
if (this.flushScheduled) return;
this.flushScheduled = true;
this.flushChain = this.flushChain.then(async () => {
this.flushScheduled = false;
await this.flush();
});
}
private async flush(): Promise<void> {
if (this.currentMessageId === null) {
await this.flushTextToSink();
this.lastPatchAt = Date.now();
return;
}
const now = Date.now();
if (now - this.lastPatchAt < this.patchIntervalMs) return;
this.lastPatchAt = now;
await this.flushTextToSink();
}
private async flushTextToSink(): Promise<void> {
const textToFlush = this.text;
const chunks = splitAtBoundary(textToFlush, this.maxMessageLength);
const firstChunk = chunks[0];
if (firstChunk === undefined) return;
if (this.currentMessageId === null) {
// First message: create it with the first chunk, then send overflow
// chunks as new messages.
this.currentMessageId = await this.sink.create(firstChunk);
for (const chunk of chunks.slice(1)) {
this.currentMessageId = await this.sink.create(chunk);
}
// Keep only the last chunk for future patches; remove already-sent prefix.
const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0);
this.text = this.text.slice(sentLength);
} else {
// Patch the current message with the first chunk, then create new
// messages for overflow chunks.
await this.sink.patch(this.currentMessageId, firstChunk);
for (const chunk of chunks.slice(1)) {
this.currentMessageId = await this.sink.create(chunk);
}
// Keep only the last chunk for future patches; remove already-sent prefix.
const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0);
this.text = this.text.slice(sentLength);
}
}
}
File diff suppressed because it is too large Load Diff
-100
View File
@@ -1,100 +0,0 @@
import type { MessageReceiveEvent } from "./client.js";
export interface QueuedTrigger {
readonly projectId: string;
readonly chatId: string;
readonly prompt: string;
readonly msg: MessageReceiveEvent["message"];
readonly senderOpenId: string;
readonly actor: { readonly feishuOpenId: string; readonly chatId: string };
readonly enqueuedAt: number;
}
export interface TriggerQueueOptions {
readonly maxQueueSize?: number;
readonly maxWaitMs?: number;
}
const DEFAULT_MAX_QUEUE_SIZE = 5;
const DEFAULT_MAX_WAIT_MS = 300_000;
export class TriggerQueue {
readonly maxQueueSize: number;
readonly maxWaitMs: number;
private readonly queues = new Map<string, QueuedTrigger[]>();
constructor(options: TriggerQueueOptions = {}) {
this.maxQueueSize = Math.max(0, Math.trunc(options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
this.maxWaitMs = Math.max(0, Math.trunc(options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS));
}
enqueue(projectId: string, trigger: Omit<QueuedTrigger, "projectId" | "enqueuedAt">): number {
const queue = this.queues.get(projectId) ?? [];
if (queue.length >= this.maxQueueSize) return 0;
queue.push({
...trigger,
projectId,
enqueuedAt: Date.now(),
});
if (!this.queues.has(projectId)) {
this.queues.set(projectId, queue);
}
return queue.length;
}
dequeue(projectId: string): QueuedTrigger | null {
const queue = this.queues.get(projectId);
if (queue === undefined || queue.length === 0) return null;
const next = queue.shift() ?? null;
if (queue.length === 0) {
this.queues.delete(projectId);
}
return next;
}
peek(projectId: string): QueuedTrigger | null {
return this.queues.get(projectId)?.[0] ?? null;
}
length(projectId: string): number {
return this.queues.get(projectId)?.length ?? 0;
}
hasPending(projectId: string): boolean {
return this.length(projectId) > 0;
}
purgeExpired(): number {
const now = Date.now();
let removed = 0;
for (const [projectId, queue] of this.queues) {
const fresh = queue.filter((trigger) => !this.isExpired(trigger, now));
removed += queue.length - fresh.length;
if (fresh.length === 0) {
this.queues.delete(projectId);
} else if (fresh.length !== queue.length) {
this.queues.set(projectId, fresh);
}
}
return removed;
}
clear(projectId: string): number {
const count = this.length(projectId);
this.queues.delete(projectId);
return count;
}
clearAll(): void {
this.queues.clear();
}
isExpired(trigger: QueuedTrigger, now = Date.now()): boolean {
return trigger.enqueuedAt + this.maxWaitMs < now;
}
}
export const triggerQueue = new TriggerQueue();
-79
View File
@@ -1,79 +0,0 @@
/**
* ProjectAgentLock — ADR-0002 invariant enforcement at the DB layer.
*
* `projectId @id` gives at-most-one lock per project; `runId @unique` gives a
* run holds at most one lock. Those are DB-level. The spec's `WellFormed`
* invariant ("holder is a non-terminal run") is app-level: it must hold on
* every read of the lock table. `assertLockHolderActive` enforces it on the
* read path — if a lock exists but its run is terminal, that's a bug (the run
* should have released it), surfaced as an error rather than silently trusted.
*/
import type { PrismaClient } from "@prisma/client";
import type { AgentRunStatus } from "@prisma/client";
/// spec RunState.Terminal: completed/failed/timedOut/canceled.
/// active/waitingForUser are NOT terminal — the lock must still be held.
const TERMINAL_STATUSES: ReadonlySet<AgentRunStatus> = new Set([
"COMPLETED",
"FAILED",
"TIMED_OUT",
"CANCELED",
]);
export function isTerminal(status: AgentRunStatus): boolean {
return TERMINAL_STATUSES.has(status);
}
/**
* Acquire the project lock for a run. Throws if the project is already locked
* by another run (ADR-0002 exclusivity). The unique runId constraint means a
* run that already holds a lock elsewhere will also fail — that's correct, a
* run scopes to one project.
*/
export async function acquireLock(
prisma: PrismaClient,
projectId: string,
runId: string,
holderUserId: string | null,
): Promise<void> {
await prisma.projectAgentLock.create({
data: { projectId, runId, holderUserId },
});
}
/**
* Release the lock owned by a run. Idempotent — a no-op if no lock exists.
* Called on run completion/failure/timeout/cancellation (ADR-0002).
*/
export async function releaseLock(prisma: PrismaClient, runId: string): Promise<void> {
await prisma.projectAgentLock.deleteMany({ where: { runId } });
}
/**
* The spec's `LockTable.WellFormed` on a single read: if `projectId` has a
* lock owned by `runId`, that run must be non-terminal. Throws if a terminal
* run still holds a lock — that's a release-path bug, not a recoverable state.
*
* Returns the lock's runId if a healthy lock exists, or null if unlocked.
*/
export async function currentLockRunId(
prisma: PrismaClient,
projectId: string,
): Promise<string | null> {
const lock = await prisma.projectAgentLock.findUnique({
where: { projectId },
select: { runId: true },
});
if (lock === null) return null;
const run = await prisma.agentRun.findUnique({
where: { id: lock.runId },
select: { status: true },
});
if (run !== null && isTerminal(run.status)) {
throw new Error(
`lock invariant violated: project ${projectId} locked by terminal run ${lock.runId} (status ${run.status}) — release path bug (ADR-0002 WellFormed)`,
);
}
return lock.runId;
}
-350
View File
@@ -1,350 +0,0 @@
/**
* 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
@@ -1,213 +0,0 @@
/**
* 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
@@ -1,114 +0,0 @@
/**
* 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
@@ -1,290 +0,0 @@
/**
* 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
@@ -1,203 +0,0 @@
/**
* 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,
},
};
}
-97
View File
@@ -1,97 +0,0 @@
/**
* Permission compatibility exports.
*
* ADR-0019 moves production authorization behind PrincipalResolver +
* PermissionAuthorizer. The wrappers below preserve the old helper names for
* tests and incremental callers while routing project trigger checks through
* the new authorizer.
*/
import type { PrismaClient } from "@prisma/client";
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js";
export { grantTeamProjectAccess, listProjectTeamAccess, revokeTeamProjectAccess } from "./permissions/projectTeamAccess.js";
export type {
AuthorizationAction,
AuthorizationDecision,
AuthorizationRequest,
AuthorizationResource,
PermissionAuthorizer,
} from "./permissions/authorizer.js";
export type {
ActorInput,
PrincipalRef,
PrincipalResolution,
PrincipalResolver,
ResolvedPrincipal,
} from "./permissions/principals.js";
export type {
ExternalPrincipalMembershipInput,
ExternalPrincipalType,
SyncExternalPrincipalMembershipsInput,
SyncExternalPrincipalMembershipsResult,
} from "./permissions/externalSync.js";
export type {
GrantTeamProjectAccessInput,
ProjectTeamAccessEntry,
RevokeTeamProjectAccessInput,
} from "./permissions/projectTeamAccess.js";
export interface PermissionResult {
readonly allowed: boolean;
readonly reason: string;
}
export async function canTriggerAgent(
prisma: PrismaClient,
projectId: string,
principal: string,
): Promise<PermissionResult> {
try {
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: principal },
action: "agent.trigger",
resource: { type: "PROJECT", id: projectId },
});
return { allowed: decision.allowed, reason: decision.reason };
} catch (e) {
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
}
}
/**
* Role-only legacy helper. New trigger code uses `role.trigger` on
* PermissionAuthorizer so project edit+ and role grant composition happen in
* one decision.
*/
export async function canTriggerRole(
prisma: PrismaClient,
projectId: string,
roleId: string,
principal: string,
): Promise<PermissionResult> {
try {
const anyGrant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId },
select: { id: true },
});
if (anyGrant === null) {
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
}
const grant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId, principalType: "USER", principalId: principal, revokedAt: null },
select: { id: true },
});
if (grant !== null) {
return { allowed: true, reason: `granted role ${roleId}` };
}
return {
allowed: false,
reason: `no active ${roleId} grant for ${principal} on project ${projectId}`,
};
} catch (e) {
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
}
}
-295
View File
@@ -1,295 +0,0 @@
import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
export type AuthorizationAction =
| "project.read"
| "project.edit"
| "collaborator.manage"
| "agent.trigger"
| "agent.cancel"
| "role.trigger";
export interface AuthorizationResource {
readonly type: PermissionResourceType;
readonly id: string;
}
export interface AuthorizationRequest {
readonly actor: ActorInput;
readonly action: AuthorizationAction;
readonly resource: AuthorizationResource;
readonly roleId?: string | undefined;
}
export interface MatchedPermissionGrant {
readonly id: string;
readonly principal: PrincipalRef;
readonly role: PermissionRole;
}
export interface MatchedRoleTriggerGrant {
readonly id: string;
readonly principal: PrincipalRef;
}
export interface AuthorizationDecision {
readonly allowed: boolean;
readonly reason: string;
readonly action: AuthorizationAction;
readonly resource: AuthorizationResource;
readonly actor: ActorInput;
readonly organizationId: string;
readonly actorUserId?: string | undefined;
readonly principals: readonly PrincipalRef[];
readonly requiredRole: PermissionRole;
readonly effectiveRole?: PermissionRole | undefined;
readonly matchedGrant?: MatchedPermissionGrant | undefined;
readonly matchedRoleGrant?: MatchedRoleTriggerGrant | undefined;
}
export interface PermissionAuthorizer {
can(req: AuthorizationRequest): Promise<AuthorizationDecision>;
}
export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
constructor(
private readonly prisma: PrismaClient,
private readonly resolver: PrincipalResolver = new PrismaPrincipalResolver(prisma),
) {}
async can(req: AuthorizationRequest): Promise<AuthorizationDecision> {
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
throw new Error("role.trigger authorization requires roleId");
}
const organizationId = await this.organizationForResource(req.resource);
const resolution = await this.resolver.resolveActor(req.actor, { organizationId });
const threshold = await this.requiredRole(req);
if (threshold === "DISABLED") {
return this.decision(req, resolution, {
allowed: false,
reason: "permission setting disables this action",
requiredRole: defaultRequiredRole(req.action),
});
}
const grant = await this.bestGrant(req.resource, resolution.principals, threshold);
if (grant === undefined) {
return this.decision(req, resolution, {
allowed: false,
reason: `no active ${threshold}+ grant for resolved principals`,
requiredRole: threshold,
});
}
if (req.action !== "role.trigger") {
return this.decision(req, resolution, {
allowed: true,
reason: `granted by ${grant.principal.type}:${grant.principal.id} ${grant.role}`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
});
}
const roleGate = await this.roleTriggerGrant(req.resource, req.roleId!, resolution.principals);
if (roleGate.status === "open") {
return this.decision(req, resolution, {
allowed: true,
reason: `role ${req.roleId} unconfigured on project (open)`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
});
}
if (roleGate.grant !== undefined) {
return this.decision(req, resolution, {
allowed: true,
reason: `role ${req.roleId} granted by ${roleGate.grant.principal.type}:${roleGate.grant.principal.id}`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
matchedRoleGrant: roleGate.grant,
});
}
return this.decision(req, resolution, {
allowed: false,
reason: `no active role ${req.roleId} grant for resolved principals`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
});
}
private async requiredRole(req: AuthorizationRequest): Promise<PermissionRole | "DISABLED"> {
let required = defaultRequiredRole(req.action);
// ADR-0019 + PermissionGrant spec: agentTrigger and agentCancel are
// distinct policy knobs ("谁能触发" ≠ "谁能取消"). Each can tighten the
// default role to MANAGE_ONLY or DISABLE the action entirely. The default
// role (trigger=EDIT, cancel=MANAGE) is the floor when no policy is set.
if (req.resource.type === "PROJECT" && (req.action === "agent.trigger" || req.action === "role.trigger")) {
const settings = await this.prisma.permissionSettings.findFirst({
where: { resourceType: req.resource.type, resourceId: req.resource.id },
select: { agentTrigger: true },
});
const policy = normalizePolicy(settings?.agentTrigger);
if (policy === "DISABLED") return "DISABLED";
if (policy === "MANAGE_ONLY") required = "MANAGE";
} else if (req.resource.type === "PROJECT" && req.action === "agent.cancel") {
const settings = await this.prisma.permissionSettings.findFirst({
where: { resourceType: req.resource.type, resourceId: req.resource.id },
select: { agentCancel: true },
});
const policy = normalizePolicy(settings?.agentCancel);
if (policy === "DISABLED") return "DISABLED";
if (policy === "MANAGE_ONLY") required = "MANAGE";
}
return required;
}
private async bestGrant(
resource: AuthorizationResource,
principals: readonly PrincipalRef[],
threshold: PermissionRole,
): Promise<MatchedPermissionGrant | undefined> {
const grants = await this.prisma.permissionGrant.findMany({
where: {
resourceType: resource.type,
resourceId: resource.id,
revokedAt: null,
OR: principalWhere(principals),
},
select: { id: true, principalType: true, principalId: true, role: true },
});
const sufficient = grants
.filter((grant) => roleRank(grant.role) >= roleRank(threshold))
.sort((a, b) => roleRank(b.role) - roleRank(a.role))[0];
if (sufficient === undefined) return undefined;
return {
id: sufficient.id,
principal: { type: sufficient.principalType, id: sufficient.principalId },
role: sufficient.role,
};
}
private async roleTriggerGrant(
resource: AuthorizationResource,
roleId: string,
principals: readonly PrincipalRef[],
): Promise<{ readonly status: "open" } | { readonly status: "configured"; readonly grant?: MatchedRoleTriggerGrant | undefined }> {
if (resource.type !== "PROJECT") {
return { status: "configured" };
}
const anyGrant = await this.prisma.roleTriggerGrant.findFirst({
where: { projectId: resource.id, roleId },
select: { id: true },
});
if (anyGrant === null) return { status: "open" };
const grant = await this.prisma.roleTriggerGrant.findFirst({
where: {
projectId: resource.id,
roleId,
revokedAt: null,
OR: principalWhere(principals),
},
select: { id: true, principalType: true, principalId: true },
});
if (grant === null) return { status: "configured" };
return {
status: "configured",
grant: {
id: grant.id,
principal: { type: grant.principalType, id: grant.principalId },
},
};
}
private decision(
req: AuthorizationRequest,
resolution: PrincipalResolution,
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "organizationId" | "actorUserId" | "principals">,
): AuthorizationDecision {
return {
action: req.action,
resource: req.resource,
actor: req.actor,
organizationId: resolution.organizationId,
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
principals: resolution.principals,
...result,
};
}
private async organizationForResource(resource: AuthorizationResource): Promise<string> {
switch (resource.type) {
case "PROJECT": {
const project = await this.prisma.project.findUnique({
where: { id: resource.id },
select: { organizationId: true },
});
if (project === null) {
throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
}
return project.organizationId;
}
case "PROJECT_GROUP": {
const binding = await this.prisma.projectGroupBinding.findFirst({
where: { chatId: resource.id, archivedAt: null },
select: { project: { select: { organizationId: true } } },
});
if (binding === null) {
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
}
return binding.project.organizationId;
}
case "ARTIFACT":
throw new Error("authorization for ARTIFACT resources requires an organization resolver");
}
}
}
export function createPermissionAuthorizer(
prisma: PrismaClient,
resolver?: PrincipalResolver,
): PermissionAuthorizer {
return new PrismaPermissionAuthorizer(prisma, resolver);
}
export function roleGrantsEdit(role: PermissionRole): boolean {
return roleRank(role) >= roleRank("EDIT");
}
export function roleRank(role: PermissionRole): number {
switch (role) {
case "READ": return 1;
case "EDIT": return 2;
case "MANAGE": return 3;
}
}
function defaultRequiredRole(action: AuthorizationAction): PermissionRole {
switch (action) {
case "project.read": return "READ";
case "project.edit": return "EDIT";
case "agent.trigger": return "EDIT";
case "role.trigger": return "EDIT";
case "collaborator.manage": return "MANAGE";
case "agent.cancel": return "MANAGE";
}
}
function normalizePolicy(policy: string | undefined): "ROLE" | "MANAGE_ONLY" | "DISABLED" {
if (policy === undefined || policy.trim() === "") return "ROLE";
const normalized = policy.trim().toUpperCase();
if (normalized === "DISABLED" || normalized === "DENY" || normalized === "OFF") return "DISABLED";
if (normalized === "MANAGE_ONLY") return "MANAGE_ONLY";
return "ROLE";
}
function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalType: PrincipalType; principalId: string }> {
return principals.map((principal) => ({
principalType: principal.type,
principalId: principal.id,
}));
}
-149
View File
@@ -1,149 +0,0 @@
import type { PrismaClient, PrincipalType } from "@prisma/client";
export type ExternalPrincipalType = Extract<
PrincipalType,
"FEISHU_CHAT" | "FEISHU_DEPARTMENT" | "FEISHU_USER_GROUP"
>;
export interface ExternalPrincipalMembershipInput {
readonly feishuOpenId: string;
readonly displayName: string;
readonly principalType: ExternalPrincipalType;
readonly principalId: string;
}
export interface SyncExternalPrincipalMembershipsInput {
readonly organizationId: string;
readonly provider?: string | undefined;
readonly providerTenantId?: string | undefined;
readonly source: string;
readonly replaceSource?: boolean | undefined;
readonly memberships: readonly ExternalPrincipalMembershipInput[];
}
export interface SyncExternalPrincipalMembershipsResult {
readonly synced: number;
readonly revoked: number;
}
export async function syncExternalPrincipalMemberships(
prisma: PrismaClient,
input: SyncExternalPrincipalMembershipsInput,
): Promise<SyncExternalPrincipalMembershipsResult> {
const syncedAt = new Date();
const incomingKeys = new Set<string>();
let synced = 0;
const connection = await prisma.externalDirectoryConnection.upsert({
where: {
organizationId_provider_source: {
organizationId: input.organizationId,
provider: input.provider ?? "FEISHU",
source: input.source,
},
},
update: {
revokedAt: null,
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
},
create: {
organizationId: input.organizationId,
provider: input.provider ?? "FEISHU",
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
source: input.source,
},
select: { id: true },
});
for (const item of input.memberships) {
assertExternalPrincipalType(item.principalType);
const user = await prisma.user.upsert({
where: { feishuOpenId: item.feishuOpenId },
update: { displayName: item.displayName },
create: {
feishuOpenId: item.feishuOpenId,
displayName: item.displayName,
platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
},
});
await ensureOrganizationMembership(prisma, input.organizationId, user.id);
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId, connection.id));
const existing = await prisma.externalPrincipalMembership.findFirst({
where: {
userId: user.id,
principalType: item.principalType,
principalId: item.principalId,
connectionId: connection.id,
revokedAt: null,
},
select: { id: true },
});
if (existing === null) {
await prisma.externalPrincipalMembership.create({
data: {
userId: user.id,
connectionId: connection.id,
principalType: item.principalType,
principalId: item.principalId,
syncedAt,
},
});
} else {
await prisma.externalPrincipalMembership.update({
where: { id: existing.id },
data: { syncedAt },
});
}
synced++;
}
let revoked = 0;
if (input.replaceSource === true) {
const active = await prisma.externalPrincipalMembership.findMany({
where: { connectionId: connection.id, revokedAt: null },
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
});
const revokeIds = active
.filter((membership) => !incomingKeys.has(externalMembershipKey(
membership.userId,
membership.principalType,
membership.principalId,
membership.connectionId,
)))
.map((membership) => membership.id);
if (revokeIds.length > 0) {
const result = await prisma.externalPrincipalMembership.updateMany({
where: { id: { in: revokeIds } },
data: { revokedAt: syncedAt },
});
revoked = result.count;
}
}
return { synced, revoked };
}
async function ensureOrganizationMembership(
prisma: PrismaClient,
organizationId: string,
userId: string,
): Promise<void> {
const active = await prisma.organizationMembership.findFirst({
where: { organizationId, userId, revokedAt: null },
select: { id: true },
});
if (active !== null) return;
await prisma.organizationMembership.create({
data: { organizationId, userId, role: "MEMBER" },
});
}
function externalMembershipKey(userId: string, type: PrincipalType, id: string, connectionId: string): string {
return `${connectionId}:${userId}:${type}:${id}`;
}
function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType {
if (type !== "FEISHU_CHAT" && type !== "FEISHU_DEPARTMENT" && type !== "FEISHU_USER_GROUP") {
throw new Error(`unsupported external principal type: ${type}`);
}
}
-152
View File
@@ -1,152 +0,0 @@
import type { PrismaClient, PrincipalType } from "@prisma/client";
export interface PrincipalRef {
readonly type: PrincipalType;
readonly id: string;
}
export interface ActorInput {
readonly feishuOpenId: string;
readonly chatId?: string | undefined;
}
export interface PrincipalResolutionScope {
readonly organizationId: string;
}
export type PrincipalSource =
| "actor-user"
| "context-chat"
| "team-membership"
| "external-membership"
| "team-external-binding";
export interface ResolvedPrincipal {
readonly principal: PrincipalRef;
readonly source: PrincipalSource;
readonly via?: PrincipalRef | undefined;
}
export interface PrincipalResolution {
readonly actor: ActorInput;
readonly organizationId: string;
readonly userId?: string | undefined;
readonly principals: readonly PrincipalRef[];
readonly resolved: readonly ResolvedPrincipal[];
}
export interface PrincipalResolver {
resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution>;
}
export class PrismaPrincipalResolver implements PrincipalResolver {
constructor(private readonly prisma: PrismaClient) {}
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
const resolved = new PrincipalCollector();
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
if (actor.chatId !== undefined && actor.chatId !== "") {
resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat");
}
const user = await this.prisma.user.findUnique({
where: { feishuOpenId: actor.feishuOpenId },
select: { id: true },
});
if (user !== null) {
const memberships = await this.prisma.teamMembership.findMany({
where: {
userId: user.id,
revokedAt: null,
team: { organizationId: scope.organizationId, archivedAt: null },
},
select: { teamId: true },
});
for (const membership of memberships) {
resolved.add({ type: "TEAM", id: membership.teamId }, "team-membership");
}
const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({
where: {
userId: user.id,
revokedAt: null,
connection: {
organizationId: scope.organizationId,
revokedAt: null,
},
},
select: { principalType: true, principalId: true },
});
for (const membership of externalMemberships) {
resolved.add(
{ type: membership.principalType, id: membership.principalId },
"external-membership",
);
}
}
const externalPrincipals = resolved.principals().filter((principal) => isExternalPrincipal(principal.type));
if (externalPrincipals.length > 0) {
const bindings = await this.prisma.teamExternalBinding.findMany({
where: {
revokedAt: null,
team: { organizationId: scope.organizationId, archivedAt: null },
OR: externalPrincipals.map((principal) => ({
principalType: principal.type,
principalId: principal.id,
})),
},
select: { teamId: true, principalType: true, principalId: true },
});
for (const binding of bindings) {
resolved.add(
{ type: "TEAM", id: binding.teamId },
"team-external-binding",
{ type: binding.principalType, id: binding.principalId },
);
}
}
return {
actor,
organizationId: scope.organizationId,
...(user !== null ? { userId: user.id } : {}),
principals: resolved.principals(),
resolved: resolved.entries(),
};
}
}
export function principalKey(principal: PrincipalRef): string {
return `${principal.type}:${principal.id}`;
}
function isExternalPrincipal(type: PrincipalType): boolean {
return type === "FEISHU_CHAT" || type === "FEISHU_DEPARTMENT" || type === "FEISHU_USER_GROUP";
}
class PrincipalCollector {
private readonly refs = new Map<string, PrincipalRef>();
private readonly provenance: ResolvedPrincipal[] = [];
add(principal: PrincipalRef, source: PrincipalSource, via?: PrincipalRef): void {
const key = principalKey(principal);
if (!this.refs.has(key)) {
this.refs.set(key, principal);
}
this.provenance.push({
principal,
source,
...(via !== undefined ? { via } : {}),
});
}
principals(): readonly PrincipalRef[] {
return [...this.refs.values()];
}
entries(): readonly ResolvedPrincipal[] {
return this.provenance;
}
}
-218
View File
@@ -1,218 +0,0 @@
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
export interface GrantTeamProjectAccessInput {
readonly projectId: string;
readonly teamId?: string | undefined;
readonly teamSlug?: string | undefined;
readonly role: PermissionRole;
readonly createdByUserId?: string | undefined;
}
export interface RevokeTeamProjectAccessInput {
readonly projectId: string;
readonly teamId?: string | undefined;
readonly teamSlug?: string | undefined;
}
export interface ProjectTeamAccessEntry {
readonly grantId: string;
readonly projectId: string;
readonly organizationId: string;
readonly teamId: string;
readonly teamSlug: string;
readonly teamName: string;
readonly role: PermissionRole;
}
export async function grantTeamProjectAccess(
prisma: PrismaClient,
input: GrantTeamProjectAccessInput,
): Promise<ProjectTeamAccessEntry> {
return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input);
const now = new Date();
const existing = await tx.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
select: { id: true, role: true },
});
if (existing?.role === input.role) {
return entryFromGrant({
grantId: existing.id,
projectId: project.id,
organizationId: project.organizationId,
team,
role: existing.role,
});
}
await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
data: { revokedAt: now },
});
const grant = await tx.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
role: input.role,
...(input.createdByUserId !== undefined ? { createdByUserId: input.createdByUserId } : {}),
},
select: { id: true, role: true },
});
return entryFromGrant({
grantId: grant.id,
projectId: project.id,
organizationId: project.organizationId,
team,
role: grant.role,
});
});
}
export async function revokeTeamProjectAccess(
prisma: PrismaClient,
input: RevokeTeamProjectAccessInput,
): Promise<number> {
return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input);
const result = await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
return result.count;
});
}
export async function listProjectTeamAccess(
prisma: PrismaClient,
projectId: string,
): Promise<readonly ProjectTeamAccessEntry[]> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true, organizationId: true },
});
if (project === null) {
throw new Error(`project not found: ${projectId}`);
}
const grants = await prisma.permissionGrant.findMany({
where: {
resourceType: "PROJECT",
resourceId: projectId,
principalType: "TEAM",
revokedAt: null,
},
select: { id: true, principalId: true, role: true },
orderBy: { createdAt: "asc" },
});
if (grants.length === 0) return [];
const teams = await prisma.team.findMany({
where: {
organizationId: project.organizationId,
id: { in: grants.map((grant) => grant.principalId) },
archivedAt: null,
},
select: { id: true, slug: true, name: true },
});
const teamsById = new Map(teams.map((team) => [team.id, team]));
const missing = grants.filter((grant) => !teamsById.has(grant.principalId));
if (missing.length > 0) {
throw new Error(
`project ${projectId} has active TEAM grants outside organization ${project.organizationId}: ${missing.map((grant) => grant.principalId).join(", ")}`,
);
}
return grants.map((grant) => entryFromGrant({
grantId: grant.id,
projectId: project.id,
organizationId: project.organizationId,
team: teamsById.get(grant.principalId)!,
role: grant.role,
}));
}
type ProjectForAccess = {
readonly id: string;
readonly organizationId: string;
};
type TeamForAccess = {
readonly id: string;
readonly slug: string;
readonly name: string;
};
async function resolveProjectAndTeam(
prisma: PrismaClient | Prisma.TransactionClient,
input: { readonly projectId: string; readonly teamId?: string | undefined; readonly teamSlug?: string | undefined },
): Promise<{ readonly project: ProjectForAccess; readonly team: TeamForAccess }> {
if ((input.teamId === undefined || input.teamId === "") && (input.teamSlug === undefined || input.teamSlug === "")) {
throw new Error("granting team project access requires teamId or teamSlug");
}
if (input.teamId !== undefined && input.teamId !== "" && input.teamSlug !== undefined && input.teamSlug !== "") {
throw new Error("granting team project access accepts only one of teamId or teamSlug");
}
const project = await prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, organizationId: true },
});
if (project === null) {
throw new Error(`project not found: ${input.projectId}`);
}
const team = await prisma.team.findFirst({
where:
input.teamId !== undefined && input.teamId !== ""
? { id: input.teamId, archivedAt: null }
: { organizationId: project.organizationId, slug: input.teamSlug!, archivedAt: null },
select: { id: true, slug: true, name: true, organizationId: true },
});
if (team === null) {
const label = input.teamId ?? input.teamSlug;
throw new Error(`active team not found for project ${input.projectId}: ${label}`);
}
if (team.organizationId !== project.organizationId) {
throw new Error(
`cross-organization team grant refused: project ${project.id} is in ${project.organizationId}, team ${team.id} is in ${team.organizationId}`,
);
}
return { project, team };
}
function entryFromGrant(input: {
readonly grantId: string;
readonly projectId: string;
readonly organizationId: string;
readonly team: TeamForAccess;
readonly role: PermissionRole;
}): ProjectTeamAccessEntry {
return {
grantId: input.grantId,
projectId: input.projectId,
organizationId: input.organizationId,
teamId: input.team.id,
teamSlug: input.team.slug,
teamName: input.team.name,
role: input.role,
};
}
-537
View File
@@ -1,537 +0,0 @@
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;
}
-106
View File
@@ -1,106 +0,0 @@
/**
* Hub entry — Fastify server + Feishu WebSocket listener + org admin HTTP.
*
* Wires the full trigger path: lark ws receives `im.message.receive_v1` →
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires
* the project lock (ADR-0002), runs the AI SDK-backed agent runner
* (ADR-0017), and releases the lock on finish. Feishu context reads inside the
* loop are ADR-0003-authorized (chat must match binding).
*
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
*
* Env (see .env.example):
* DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT,
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL
*/
import "dotenv/config";
import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js";
import { createEnvRuntimeSettings } from "./settings/runtime.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { makeTriggerHandler } from "./feishu/trigger.js";
import { triggerQueue } from "./feishu/triggerQueue.js";
function requireEnv(name: string): string {
const v = process.env[name];
if (v === undefined || v === "") {
console.error(`[hub] missing required env: ${name}`);
process.exit(1);
}
return v;
}
function booleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw === "") return fallback;
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
}
async function main(): Promise<void> {
const databaseUrl = requireEnv("DATABASE_URL");
void databaseUrl;
const runtimeSettings = createEnvRuntimeSettings();
await runtimeSettings.provider("openrouter");
const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const port = Number(process.env["PORT"] ?? "8788");
const app = Fastify({ logger: true });
// Startup reset: clear stale locks + mark dead runs as FAILED.
await prisma.projectAgentLock.deleteMany({});
await prisma.agentRun.updateMany({
where: { status: "ACTIVE" },
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
});
app.log.info("startup: cleared stale locks + dead runs");
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
await registerAdminPlugin(app, {
prisma,
sessionSecret,
publicBaseUrl,
feishuAppId,
feishuAppSecret,
projectWorkspaceRoot,
});
// --- Feishu listener ---
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (feishuListenerEnabled) {
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
const larkClient = createLarkClient(feishuConfig);
const triggerQueuePurgeTimer = setInterval(() => {
const removed = triggerQueue.purgeExpired();
if (removed > 0) {
app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
}
}, 60_000);
triggerQueuePurgeTimer.unref();
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
} else {
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
}
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
if (err !== null) {
app.log.error({ err }, "listen failed");
process.exit(1);
}
app.log.info({ address }, "hub listening");
});
}
main().catch((e) => {
console.error("[hub] fatal:", e);
process.exit(1);
});
-128
View File
@@ -1,128 +0,0 @@
import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js";
export type Env = Readonly<Record<string, string | undefined>>;
type EnvSource = Env | (() => Env);
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api";
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
const DEFAULT_AGENT_MAX_TURNS = 25;
export interface ProviderRuntimeSettings {
readonly id: string;
readonly baseUrl: string;
readonly authToken: string;
readonly anthropicApiKey: string;
readonly sdkEnv: Record<string, string | undefined>;
}
export interface RuntimeScope {
readonly projectId?: string;
}
export interface RunPolicyInput {
readonly projectId: string;
readonly roleId: string;
}
export interface RunPolicy {
readonly maxTurns: number;
}
export interface RuntimeSettings {
provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings>;
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry>;
runPolicy(input: RunPolicyInput): Promise<RunPolicy>;
}
export class EnvRuntimeSettings implements RuntimeSettings {
private readonly envSource: () => Env;
constructor(env: EnvSource = process.env) {
this.envSource = typeof env === "function" ? env : () => env;
}
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
void scope;
if (providerId !== "openrouter") {
throw new Error(`unknown provider runtime settings: ${providerId}`);
}
const env = this.envSource();
const baseUrl = readEnv(env, "ANTHROPIC_BASE_URL") ?? DEFAULT_OPENROUTER_BASE_URL;
const authToken = requireSetting(env, "ANTHROPIC_AUTH_TOKEN");
const anthropicApiKey = readEnv(env, "ANTHROPIC_API_KEY") ?? "";
return {
id: providerId,
baseUrl,
authToken,
anthropicApiKey,
sdkEnv: {
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: authToken,
ANTHROPIC_API_KEY: anthropicApiKey,
},
};
}
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
void scope;
return createDefaultModelRegistry(this.envSource());
}
async runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
void input;
return {
maxTurns: positiveIntegerEnv(this.envSource(), "HUB_AGENT_MAX_TURNS", DEFAULT_AGENT_MAX_TURNS),
};
}
}
export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings {
return new EnvRuntimeSettings(env);
}
export function defaultSonnetModel(env: Env = process.env): string {
return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL;
}
export function createDefaultModelRegistry(env: Env = process.env): InMemoryModelRegistry {
const sonnetModel = defaultSonnetModel(env);
const sonnetLabel =
readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME") ??
(sonnetModel === DEFAULT_SONNET_MODEL ? DEFAULT_SONNET_LABEL : sonnetModel);
return new InMemoryModelRegistry(
[
{ id: sonnetModel, label: sonnetLabel, toolCapable: true },
],
[
{ id: "draft", label: "草稿", defaultModel: sonnetModel },
{ id: "review", label: "审校", defaultModel: sonnetModel },
],
);
}
function readEnv(env: Env, name: string): string | undefined {
const value = env[name]?.trim();
return value === undefined || value === "" ? undefined : value;
}
function requireSetting(env: Env, name: string): string {
const value = readEnv(env, name);
if (value === undefined) {
throw new Error(`missing required runtime setting: ${name}`);
}
return value;
}
function positiveIntegerEnv(env: Env, name: string, fallback: number): number {
const raw = readEnv(env, name);
if (raw === undefined) return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`runtime setting ${name} must be a positive integer`);
}
return value;
}
-250
View File
@@ -1,250 +0,0 @@
/**
* 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
@@ -1,224 +0,0 @@
/**
* 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();
}
});
});
@@ -1,161 +0,0 @@
/**
* 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();
}
});
});
-448
View File
@@ -1,448 +0,0 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
import {
createPermissionAuthorizer,
grantTeamProjectAccess,
listProjectTeamAccess,
PrismaPrincipalResolver,
syncExternalPrincipalMemberships,
} from "../../src/permission.js";
describe("ADR-0019 authorization", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("resolves actor principals from user, chat, direct teams, external memberships, and external team bindings", async () => {
const user = await prisma.user.create({
data: { id: "u-auth-1", feishuOpenId: "ou_auth_1", displayName: "Teacher A" },
});
const directTeam = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "direct-auth", name: "Direct Team" },
});
const departmentTeam = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "department-auth", name: "Department Team" },
});
const chatTeam = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "chat-auth", name: "Chat Team" },
});
await prisma.teamMembership.create({
data: { teamId: directTeam.id, userId: user.id },
});
const connection = await prisma.externalDirectoryConnection.create({
data: {
organizationId: DEFAULT_ORG_ID,
provider: "FEISHU",
source: "test",
},
});
await prisma.externalPrincipalMembership.create({
data: {
userId: user.id,
connectionId: connection.id,
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-physics",
},
});
await prisma.teamExternalBinding.create({
data: { teamId: departmentTeam.id, principalType: "FEISHU_DEPARTMENT", principalId: "dep-physics" },
});
await prisma.teamExternalBinding.create({
data: { teamId: chatTeam.id, principalType: "FEISHU_CHAT", principalId: "chat-auth" },
});
const resolution = await new PrismaPrincipalResolver(prisma).resolveActor({
feishuOpenId: "ou_auth_1",
chatId: "chat-auth",
}, { organizationId: DEFAULT_ORG_ID });
expect(resolution.userId).toBe(user.id);
expect(resolution.principals).toEqual(expect.arrayContaining([
{ type: "USER", id: "ou_auth_1" },
{ type: "FEISHU_CHAT", id: "chat-auth" },
{ type: "FEISHU_DEPARTMENT", id: "dep-physics" },
{ type: "TEAM", id: directTeam.id },
{ type: "TEAM", id: departmentTeam.id },
{ type: "TEAM", id: chatTeam.id },
]));
});
it("allows agent trigger through a team project grant", async () => {
const { team } = await seedUserTeamProject("auth-team-grant");
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-team-grant",
principalType: "TEAM",
principalId: team.id,
role: "EDIT",
},
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_team_grant" },
action: "agent.trigger",
resource: { type: "PROJECT", id: "p-auth-team-grant" },
});
expect(decision.allowed).toBe(true);
expect(decision.matchedGrant).toMatchObject({
principal: { type: "TEAM", id: team.id },
role: "EDIT",
});
});
it("uses external Feishu principal memberships as grantable principals", async () => {
await prisma.project.create({
data: { id: "p-auth-ext", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" },
});
await syncExternalPrincipalMemberships(prisma, {
organizationId: DEFAULT_ORG_ID,
source: "feishu-test",
memberships: [
{
feishuOpenId: "ou_auth_ext",
displayName: "Teacher Ext",
principalType: "FEISHU_USER_GROUP",
principalId: "ug-lesson-design",
},
],
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-ext",
principalType: "FEISHU_USER_GROUP",
principalId: "ug-lesson-design",
role: "EDIT",
},
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_ext" },
action: "agent.trigger",
resource: { type: "PROJECT", id: "p-auth-ext" },
});
expect(decision.allowed).toBe(true);
expect(decision.matchedGrant?.principal).toEqual({
type: "FEISHU_USER_GROUP",
id: "ug-lesson-design",
});
});
it("revokes missing external memberships when replacing a sync source", async () => {
await syncExternalPrincipalMemberships(prisma, {
organizationId: DEFAULT_ORG_ID,
source: "feishu-directory",
memberships: [
{
feishuOpenId: "ou_sync_a",
displayName: "Teacher A",
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-a",
},
{
feishuOpenId: "ou_sync_b",
displayName: "Teacher B",
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-b",
},
],
});
const result = await syncExternalPrincipalMemberships(prisma, {
organizationId: DEFAULT_ORG_ID,
source: "feishu-directory",
replaceSource: true,
memberships: [
{
feishuOpenId: "ou_sync_a",
displayName: "Teacher A",
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-a",
},
],
});
expect(result).toEqual({ synced: 1, revoked: 1 });
const activeB = await new PrismaPrincipalResolver(prisma).resolveActor(
{ feishuOpenId: "ou_sync_b" },
{ organizationId: DEFAULT_ORG_ID },
);
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
});
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
await prisma.project.create({
data: { id: "p-auth-policy", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" },
});
await prisma.user.create({
data: { id: "u-auth-policy", feishuOpenId: "ou_auth_policy", displayName: "Teacher Policy" },
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-policy",
principalType: "USER",
principalId: "ou_auth_policy",
role: "EDIT",
},
});
await prisma.permissionSettings.create({
data: defaultProjectSettings("p-auth-policy", { agentTrigger: "MANAGE_ONLY" }),
});
const editDecision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_policy" },
action: "agent.trigger",
resource: { type: "PROJECT", id: "p-auth-policy" },
});
expect(editDecision.allowed).toBe(false);
await prisma.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: "p-auth-policy",
principalType: "USER",
principalId: "ou_auth_policy",
revokedAt: null,
},
data: {
role: "MANAGE",
},
});
const manageDecision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_policy" },
action: "agent.trigger",
resource: { type: "PROJECT", id: "p-auth-policy" },
});
expect(manageDecision.allowed).toBe(true);
expect(manageDecision.requiredRole).toBe("MANAGE");
});
it("requires project agent.trigger and configured role trigger grant for role.trigger", async () => {
const { team } = await seedUserTeamProject("auth-role");
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-role",
principalType: "TEAM",
principalId: team.id,
role: "EDIT",
},
});
await prisma.roleTriggerGrant.create({
data: {
projectId: "p-auth-role",
roleId: "review",
principalType: "USER",
principalId: "ou_someone_else",
},
});
const denied = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_role" },
action: "role.trigger",
resource: { type: "PROJECT", id: "p-auth-role" },
roleId: "review",
});
expect(denied.allowed).toBe(false);
await prisma.roleTriggerGrant.create({
data: {
projectId: "p-auth-role",
roleId: "review",
principalType: "TEAM",
principalId: team.id,
},
});
const allowed = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_role" },
action: "role.trigger",
resource: { type: "PROJECT", id: "p-auth-role" },
roleId: "review",
});
expect(allowed.allowed).toBe(true);
expect(allowed.matchedRoleGrant?.principal).toEqual({ type: "TEAM", id: team.id });
});
it("keeps a role configured when all role grants are revoked", async () => {
await prisma.project.create({
data: { id: "p-auth-revoked-role", organizationId: DEFAULT_ORG_ID, name: "Revoked", workspaceDir: "/tmp/auth-revoked" },
});
await prisma.user.create({
data: { id: "u-auth-revoked-role", feishuOpenId: "ou_auth_revoked", displayName: "Teacher Revoked" },
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-revoked-role",
principalType: "USER",
principalId: "ou_auth_revoked",
role: "EDIT",
},
});
await prisma.roleTriggerGrant.create({
data: {
projectId: "p-auth-revoked-role",
roleId: "review",
principalType: "USER",
principalId: "ou_auth_revoked",
revokedAt: new Date(),
},
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_revoked" },
action: "role.trigger",
resource: { type: "PROJECT", id: "p-auth-revoked-role" },
roleId: "review",
});
expect(decision.allowed).toBe(false);
expect(decision.reason).toContain("no active role review grant");
});
it("agent.cancel defaults to MANAGE and is allowed for a manage grant", async () => {
await prisma.project.create({ data: { id: "p-cancel", organizationId: DEFAULT_ORG_ID, name: "Cancel", workspaceDir: "/tmp/cancel" } });
await prisma.user.create({ data: { id: "u-cancel", feishuOpenId: "ou_cancel", displayName: "Manager" } });
await prisma.permissionGrant.create({
data: { resourceType: "PROJECT", resourceId: "p-cancel", principalType: "USER", principalId: "ou_cancel", role: "MANAGE" },
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_cancel" },
action: "agent.cancel",
resource: { type: "PROJECT", id: "p-cancel" },
});
expect(decision.allowed).toBe(true);
expect(decision.requiredRole).toBe("MANAGE");
});
it("agent.cancel is denied to an edit grant (below the MANAGE default)", async () => {
await prisma.project.create({ data: { id: "p-cancel-edit", organizationId: DEFAULT_ORG_ID, name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } });
await prisma.user.create({ data: { id: "u-cancel-edit", feishuOpenId: "ou_cancel_edit", displayName: "Editor" } });
await prisma.permissionGrant.create({
data: { resourceType: "PROJECT", resourceId: "p-cancel-edit", principalType: "USER", principalId: "ou_cancel_edit", role: "EDIT" },
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_cancel_edit" },
action: "agent.cancel",
resource: { type: "PROJECT", id: "p-cancel-edit" },
});
expect(decision.allowed).toBe(false);
});
it("agentCancel DISABLED policy denies cancel even for a manage grant", async () => {
await prisma.project.create({ data: { id: "p-cancel-off", organizationId: DEFAULT_ORG_ID, name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } });
await prisma.user.create({ data: { id: "u-cancel-off", feishuOpenId: "ou_cancel_off", displayName: "Manager Off" } });
await prisma.permissionGrant.create({
data: { resourceType: "PROJECT", resourceId: "p-cancel-off", principalType: "USER", principalId: "ou_cancel_off", role: "MANAGE" },
});
await prisma.permissionSettings.create({
data: defaultProjectSettings("p-cancel-off", { agentCancel: "DISABLED" }),
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_cancel_off" },
action: "agent.cancel",
resource: { type: "PROJECT", id: "p-cancel-off" },
});
expect(decision.allowed).toBe(false);
expect(decision.reason).toContain("disables this action");
});
it("scopes team slug and project team access by organization", async () => {
await seedTestOrganization("org-other", "other");
await prisma.team.create({
data: { organizationId: "org-other", slug: "curriculum", name: "Other Curriculum" },
});
const team = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "curriculum", name: "Default Curriculum" },
});
await prisma.project.create({
data: { id: "p-team-access", organizationId: DEFAULT_ORG_ID, name: "Team Access", workspaceDir: "/tmp/team-access" },
});
const entry = await grantTeamProjectAccess(prisma, {
projectId: "p-team-access",
teamSlug: "curriculum",
role: "EDIT",
});
expect(entry).toMatchObject({
projectId: "p-team-access",
organizationId: DEFAULT_ORG_ID,
teamId: team.id,
teamSlug: "curriculum",
role: "EDIT",
});
await grantTeamProjectAccess(prisma, {
projectId: "p-team-access",
teamSlug: "curriculum",
role: "MANAGE",
});
const entries = await listProjectTeamAccess(prisma, "p-team-access");
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ teamId: team.id, role: "MANAGE" });
});
it("refuses cross-organization team project grants", async () => {
await seedTestOrganization("org-other-deny", "other-deny");
const otherTeam = await prisma.team.create({
data: { organizationId: "org-other-deny", slug: "review", name: "Other Review" },
});
await prisma.project.create({
data: { id: "p-cross-org", organizationId: DEFAULT_ORG_ID, name: "Cross Org", workspaceDir: "/tmp/cross-org" },
});
await expect(grantTeamProjectAccess(prisma, {
projectId: "p-cross-org",
teamId: otherTeam.id,
role: "EDIT",
})).rejects.toThrow(/cross-organization team grant refused/);
});
});
async function seedUserTeamProject(suffix: string) {
const user = await prisma.user.create({
data: {
id: `u-${suffix}`,
feishuOpenId: `ou_${suffix.replaceAll("-", "_")}`,
displayName: "Teacher Team",
},
});
const team = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: `team-${suffix}`, name: `Team ${suffix}` },
});
await prisma.teamMembership.create({
data: { teamId: team.id, userId: user.id },
});
const project = await prisma.project.create({
data: { id: `p-${suffix}`, organizationId: DEFAULT_ORG_ID, name: `Project ${suffix}`, workspaceDir: `/tmp/${suffix}` },
});
return { user, team, project };
}
function defaultProjectSettings(
projectId: string,
overrides: { agentTrigger?: string; agentCancel?: string } = {},
) {
return {
resourceType: "PROJECT" as const,
resourceId: projectId,
externalShare: "ROLE",
comment: "ROLE",
copyDownload: "ROLE",
collaboratorMgmt: "ROLE",
agentTrigger: overrides.agentTrigger ?? "ROLE",
agentCancel: overrides.agentCancel ?? "ROLE",
};
}
-64
View File
@@ -1,64 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { cp, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
import { cphCheckTool, cphBuildTool } from "../../src/agent/cph.js";
const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141");
describe("cph subprocess tools (integration, real cph binary)", () => {
let ws: string;
let tools: ToolRegistry;
beforeEach(async () => {
// Use a writable copy of the real TH-141 example as the workspace.
ws = await mkdtemp(join(tmpdir(), "hub-cph-example-"));
await cp(EXAMPLES_DIR, ws, { recursive: true });
tools = new ToolRegistry();
tools.register("cph_check", cphCheckTool);
tools.register("cph_build", cphBuildTool);
});
afterEach(async () => {
await rm(ws, { recursive: true, force: true });
});
it("cph_check runs on the TH-141 example and returns diagnostics", async () => {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
const out = await executeTool(tools, "cph_check", {}, ctx);
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string };
// cph check exits 0 on a legal lesson (no error diagnostics, ADR-0010).
// If the example has warnings, exit is still 0.
expect(result.exitCode).toBe(0);
}, 30000);
it("cph_build renders the student target PDF", async () => {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
const out = await executeTool(tools, "cph_build", { target: "student", output: "build/test-student.pdf" }, ctx);
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string; output: string };
expect(result.exitCode).toBe(0);
}, 30000);
it("cph_check on a temp dir with no engineering file returns non-zero", async () => {
const empty = await mkdtemp(join(tmpdir(), "hub-cph-empty-"));
try {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: empty };
const out = await executeTool(tools, "cph_check", {}, ctx);
const result = JSON.parse(out) as { exitCode: number };
expect(result.exitCode).not.toBe(0);
} finally {
await rm(empty, { recursive: true, force: true });
}
}, 15000);
});
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
const def = tools.build(ctx)[name];
if (def?.execute === undefined) {
throw new Error(`missing executable tool: ${name}`);
}
return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise<string>;
}
-410
View File
@@ -1,410 +0,0 @@
/**
* Test helpers for integration tests.
*
* Each test gets a clean DB (tables truncated before the test), a mock
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
* AI SDK model factory (doGenerate() returns canned responses - no network).
*/
import { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import type {
LanguageModelV4,
LanguageModelV4CallOptions,
LanguageModelV4Content,
LanguageModelV4GenerateResult,
LanguageModelV4StreamPart,
LanguageModelV4StreamResult,
} from "@ai-sdk/provider";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
export const DEFAULT_ORG_ID = "org_test_default";
export const prisma = new PrismaClient({
datasources: { db: { url: TEST_DATABASE_URL } },
});
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"FeishuEventReceipt",
"AgentFileChange",
"AgentMessage",
"AuditEntry",
"PermissionSettings",
"PermissionGrant",
"RoleTriggerGrant",
"ExternalPrincipalMembership",
"ExternalDirectoryConnection",
"TeamExternalBinding",
"TeamMembership",
"Team",
"ProjectAgentLock",
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"Folder",
"OrganizationProjectSettings",
"OrganizationMembership",
"PlatformRoleAssignment",
"User",
"Project",
"Organization",
];
// Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
await seedTestOrganization();
}
export async function seedTestOrganization(
id: string = DEFAULT_ORG_ID,
slug: string = "test-default",
): Promise<void> {
await prisma.organization.upsert({
where: { id },
update: {},
create: {
id,
slug,
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). */
export const silentLogger: FastifyBaseLogger = {
info() {}, warn() {}, error() {}, debug() {}, fatal() {},
child() { return this; },
level: "silent",
} as unknown as FastifyBaseLogger;
/** Records sendText/sendCard calls so tests can assert on them. */
export interface MockFeishuRuntime extends FeishuRuntime {
readonly sentTexts: string[];
readonly sentCards: unknown[];
readonly sentPatches: unknown[];
readonly sentReplies: Array<{
readonly messageId: string;
readonly msgType: string;
readonly content: string;
readonly replyInThread: unknown;
}>;
readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>;
readonly readableMessages: Map<string, MockFeishuMessage>;
readonly threadMessages: Map<string, readonly MockFeishuMessage[]>;
}
export interface MockFeishuMessage {
readonly message_id?: string;
readonly root_id?: string;
readonly parent_id?: string;
readonly thread_id?: string;
readonly msg_type?: string;
readonly create_time?: string;
readonly chat_id?: string;
readonly sender?: { readonly id?: string; readonly sender_type?: string };
readonly body?: { readonly content?: string };
readonly content?: string;
}
export function mockFeishuRuntime(): MockFeishuRuntime {
const sentTexts: string[] = [];
const sentCards: unknown[] = [];
const sentPatches: unknown[] = [];
const sentReplies: MockFeishuRuntime["sentReplies"] = [];
const reactions: Array<{ messageId: string; emoji: string }> = [];
const readableMessages = new Map<string, MockFeishuMessage>();
const threadMessages = new Map<string, readonly MockFeishuMessage[]>();
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
// object directly. We patch by providing a runtime whose client is a stub;
// the actual send functions cast through shape, so a minimal stub works.
const rt: MockFeishuRuntime = {
client: {
request: async (p: unknown) => {
const payload = p as { url?: string; data?: { reaction_type?: { emoji_type?: string } } };
const match = payload.url?.match(/\/messages\/([^/]+)\/reactions$/);
if (match?.[1] !== undefined) {
reactions.push({
messageId: match[1],
emoji: payload.data?.reaction_type?.emoji_type ?? "",
});
}
return {};
},
im: {
v1: {
message: {
create: async (p: unknown) => {
const payload = p as { data?: { msg_type?: string; content?: string } };
recordSentMessage(payload.data, sentTexts, sentCards);
return { data: { message_id: "mock-msg-id" } };
},
reply: async (p: unknown) => {
const payload = p as {
path?: { message_id?: string };
data?: { msg_type?: string; content?: string; reply_in_thread?: unknown };
};
sentReplies.push({
messageId: payload.path?.message_id ?? "",
msgType: payload.data?.msg_type ?? "",
content: payload.data?.content ?? "",
replyInThread: payload.data?.reply_in_thread,
});
recordSentMessage(payload.data, sentTexts, sentCards);
return { data: { message_id: "mock-msg-id" } };
},
patch: async (p: unknown) => {
const payload = p as { data?: { content?: string } };
const card = payload.data?.content ? JSON.parse(payload.data.content) : null;
sentPatches.push(card);
const text = textFromCard(card);
if (text !== null) sentTexts.push(text);
return {};
},
get: async (p: unknown) => {
const payload = p as { path?: { message_id?: string } };
const messageId = payload.path?.message_id ?? "";
const message = readableMessages.get(messageId);
return { data: { items: message === undefined ? [] : [message] } };
},
list: async (p: unknown) => {
const payload = p as { params?: { container_id?: string } };
const containerId = payload.params?.container_id ?? "";
const items =
threadMessages.get(containerId) ??
[...readableMessages.values()].filter((message) => message.parent_id === containerId);
return { data: { items } };
},
},
},
},
} as unknown as FeishuRuntime["client"],
logger: silentLogger,
sentTexts,
sentCards,
sentPatches,
sentReplies,
reactions,
readableMessages,
threadMessages,
};
return rt;
}
function recordSentMessage(
data: { msg_type?: string; content?: string } | undefined,
sentTexts: string[],
sentCards: unknown[],
): void {
if (data?.msg_type === "interactive") {
const card = data.content ? JSON.parse(data.content) : null;
sentCards.push(card);
const text = textFromCard(card);
if (text !== null) sentTexts.push(text);
return;
}
try {
const c = JSON.parse(data?.content ?? "{}") as { text?: string };
sentTexts.push(c.text ?? data?.content ?? "");
} catch {
sentTexts.push(data?.content ?? "");
}
}
function textFromCard(card: unknown): string | null {
if (typeof card !== "object" || card === null) return null;
const elements = (card as { elements?: unknown }).elements;
if (!Array.isArray(elements)) return null;
const parts: string[] = [];
for (const element of elements) {
if (typeof element !== "object" || element === null) continue;
// New format: { tag: "markdown", content: string }
const tag = (element as { tag?: unknown }).tag;
const directContent = (element as { content?: unknown }).content;
if (tag === "markdown" && typeof directContent === "string") {
parts.push(directContent);
continue;
}
// Old format: { text: { content: string } }
const text = (element as { text?: unknown }).text;
if (typeof text === "object" && text !== null) {
const content = (text as { content?: unknown }).content;
if (typeof content === "string") parts.push(content);
}
}
return parts.length === 0 ? null : parts.join("\n");
}
export interface MockToolCall {
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
}
export interface MockModelResponse {
readonly text?: string;
readonly toolCalls?: readonly MockToolCall[];
readonly finishReason?: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
readonly inputTokens?: number;
readonly outputTokens?: number;
}
export class MockLanguageModel implements LanguageModelV4 {
readonly specificationVersion = "v4";
readonly provider = "mock";
readonly supportedUrls: Record<string, RegExp[]> = {};
readonly calls: LanguageModelV4CallOptions[] = [];
constructor(
readonly modelId: string,
private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }],
) {}
async doGenerate(options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult> {
this.calls.push(options);
const configured = this.responses[this.calls.length - 1];
const last = this.responses[this.responses.length - 1];
const response =
configured ??
((last?.toolCalls?.length ?? 0) > 0
? { text: "mock response" }
: last ?? { text: "mock response" });
const content: LanguageModelV4Content[] = [];
if (response.text !== undefined) {
content.push({ type: "text", text: response.text });
}
for (const call of response.toolCalls ?? []) {
content.push({
type: "tool-call",
toolCallId: call.toolCallId,
toolName: call.toolName,
input: JSON.stringify(call.input),
});
}
const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
return {
content,
finishReason: { unified: finishReason, raw: finishReason },
usage: {
inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
},
response: { id: `mock-${this.calls.length}`, timestamp: new Date(), modelId: this.modelId },
warnings: [],
};
}
async doStream(options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
this.calls.push(options);
const response = this.responses[this.calls.length - 1 % this.responses.length] ?? this.responses[0]!;
const text = response.text ?? "";
const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
const id = `mock-${this.calls.length}`;
const parts: LanguageModelV4StreamPart[] = [
{ type: "text-start", id },
];
if (text.length > 0) {
parts.push({ type: "text-delta", id, delta: text });
}
parts.push({ type: "text-end", id });
for (const call of response.toolCalls ?? []) {
const tcId = `tc-${this.calls.length}-${call.toolName}`;
parts.push({ type: "tool-input-start", id: tcId, toolName: call.toolName });
parts.push({ type: "tool-input-delta", id: tcId, delta: JSON.stringify(call.input) });
parts.push({ type: "tool-input-end", id: tcId });
}
parts.push({
type: "finish",
finishReason: { unified: finishReason, raw: finishReason },
usage: {
inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
},
});
return {
stream: new ReadableStream({
start(controller) {
for (const part of parts) controller.enqueue(part);
controller.close();
},
}),
response: { id, timestamp: new Date(), modelId: "mock-model" },
};
}
}
export function createMockModelFactory(
responses: readonly MockModelResponse[] = [{ text: "mock response" }],
): { readonly modelFactory: ModelFactory; readonly models: ReadonlyMap<string, MockLanguageModel> } {
const models = new Map<string, MockLanguageModel>();
return {
models,
modelFactory: (modelId) => {
const model = new MockLanguageModel(modelId, responses);
models.set(modelId, model);
return model;
},
};
}
/** Create a project + binding + user + grant for a test. */
export async function seedProject(
projectId: string,
chatId: string,
options: { principal?: string; role?: "READ" | "EDIT" | "MANAGE" } = {},
): Promise<void> {
const principal = options.principal ?? "ou_test_user";
const role = options.role ?? "EDIT";
await prisma.project.create({
data: {
id: projectId,
organizationId: DEFAULT_ORG_ID,
name: `Test ${projectId}`,
workspaceDir: `/tmp/test-${projectId}`,
},
});
await prisma.user.create({
data: {
id: "u_" + projectId,
feishuOpenId: principal,
displayName: "Test User",
platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
permissionGrants: {
create: {
resourceType: "PROJECT",
resourceId: projectId,
principalType: "USER",
principalId: principal,
role,
},
},
},
});
await prisma.projectGroupBinding.create({
data: { projectId, chatId, createdByUserId: "u_" + projectId },
});
}
-85
View File
@@ -1,85 +0,0 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
beforeEach(async () => {
await resetDb();
});
it("acquireLock + currentLockRunId round-trip", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-1", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
});
const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
});
await acquireLock(prisma, project.id, run.id, null);
const holder = await currentLockRunId(prisma, project.id);
expect(holder).toBe(run.id);
await releaseLock(prisma, run.id);
const after = await currentLockRunId(prisma, project.id);
expect(after).toBeNull();
});
it("acquireLock fails when project already locked (exclusivity)", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-2", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
});
const run1 = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
});
const run2 = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "y", model: "m", provider: "mock", metadata: {} },
});
await acquireLock(prisma, project.id, run1.id, null);
await expect(acquireLock(prisma, project.id, run2.id, null)).rejects.toThrow();
});
it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-3", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
});
// Create a run that is already COMPLETED (terminal), then manually insert a lock.
const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "COMPLETED", prompt: "x", model: "m", provider: "mock", metadata: {}, finishedAt: new Date() },
});
await prisma.projectAgentLock.create({
data: { projectId: project.id, runId: run.id },
});
// WellFormed invariant: reading a lock held by a terminal run must throw.
await expect(currentLockRunId(prisma, project.id)).rejects.toThrow(/lock invariant violated/);
});
it("releaseLock is idempotent", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-4", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
});
const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
});
await acquireLock(prisma, project.id, run.id, null);
await releaseLock(prisma, run.id);
// Second release is a no-op.
await releaseLock(prisma, run.id);
expect(await currentLockRunId(prisma, project.id)).toBeNull();
});
it("isTerminal matches spec RunState.Terminal", () => {
expect(isTerminal("ACTIVE")).toBe(false);
expect(isTerminal("WAITING_FOR_USER")).toBe(false);
expect(isTerminal("COMPLETED")).toBe(true);
expect(isTerminal("FAILED")).toBe(true);
expect(isTerminal("TIMED_OUT")).toBe(true);
expect(isTerminal("CANCELED")).toBe(true);
});
});
afterAll(async () => {
await prisma.$disconnect();
});
@@ -1,178 +0,0 @@
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;
}
-63
View File
@@ -1,63 +0,0 @@
/**
* Optional live-model smoke for the current Claude SDK runner.
*
* Skips unless explicitly enabled with RUN_REAL_MODEL_TESTS=true and an
* OpenRouter/Anthropic token is available. When enabled, it verifies that
* `runAgent` can complete one real SDK turn and project the user/assistant
* messages into the structured AgentMessage table shape.
*/
import { describe, it, expect } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import "dotenv/config";
import { runAgent } from "../../src/agent/runner.js";
import type { PrismaClient } from "@prisma/client";
const API_KEY = process.env["OPENROUTER_API_KEY"] ?? process.env["ANTHROPIC_AUTH_TOKEN"];
const HAS_KEY = API_KEY !== undefined && API_KEY !== "";
const ENABLED = HAS_KEY && process.env["RUN_REAL_MODEL_TESTS"] === "true";
const describeOrSkip = ENABLED ? describe : describe.skip;
if (ENABLED) {
process.env["ANTHROPIC_AUTH_TOKEN"] = API_KEY;
process.env["ANTHROPIC_BASE_URL"] ??= "https://openrouter.ai/api";
process.env["ANTHROPIC_API_KEY"] ??= "";
}
const MODEL = process.env["SMOKE_MODEL"] ?? process.env["ANTHROPIC_DEFAULT_SONNET_MODEL"] ?? "z-ai/glm-4.7";
describeOrSkip("real model integration (OpenRouter)", () => {
it("completes one live SDK turn and records structured messages", async () => {
const workspace = await mkdtemp(join(tmpdir(), "hub-real-model-"));
const messages: unknown[] = [];
const stubPrisma = {
projectAgentLock: { update: async () => ({}) },
agentMessage: {
create: async (input: unknown) => {
messages.push(input);
return {};
},
},
} as unknown as PrismaClient;
try {
const result = await runAgent({
runId: "real-test-run",
sessionId: "real-test-session",
prompt: "请只回复: OK",
model: MODEL,
project: { projectId: "real-test-project", boundChatId: "chat-test", workspaceDir: workspace },
prisma: stubPrisma,
systemPrompt: "你是一个极简 smoke test 助手。请严格按用户要求回复。",
maxTurns: 3,
});
expect(result.status, result.error).toBe("completed");
expect(result.text.length).toBeGreaterThan(0);
expect(messages.length).toBeGreaterThanOrEqual(2);
} finally {
await rm(workspace, { recursive: true, force: true });
}
}, 60_000);
});
@@ -1,71 +0,0 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { canTriggerRole } from "../../src/permission.js";
describe("canTriggerRole (integration, per-role gate)", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("allows when role is unconfigured on the project (back-compat: open)", async () => {
const project = await prisma.project.create({
data: { id: "p-role-1", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
expect(r.allowed).toBe(true);
});
it("allows when principal holds an active grant for the role", async () => {
const project = await prisma.project.create({
data: { id: "p-role-2", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
expect(r.allowed).toBe(true);
});
it("denies when grants exist for the role but principal has none", async () => {
const project = await prisma.project.create({
data: { id: "p-role-3", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
// Someone else has the role; ou_b does not.
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
expect(r.allowed).toBe(false);
});
it("denies when the principal's grant was revoked", async () => {
const project = await prisma.project.create({
data: { id: "p-role-4", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
expect(r.allowed).toBe(false);
});
it("scopes grants per-project (grant on p1 does not allow on p2)", async () => {
const p1 = await prisma.project.create({ data: { id: "p-role-5a", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
const p2 = await prisma.project.create({ data: { id: "p-role-5b", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
await prisma.roleTriggerGrant.create({
data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
});
// p2 has the role configured for someone else; ou_a has no grant there.
await prisma.roleTriggerGrant.create({
data: { projectId: p2.id, roleId: "review", principalType: "USER", principalId: "ou_other" },
});
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
expect(onP1.allowed).toBe(true);
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
expect(onP2.allowed).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
-73
View File
@@ -1,73 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@prisma/client";
import { writeAudit } from "../../src/audit.js";
/// Minimal mock: only the surface `writeAudit` touches. Cast through unknown
/// so we don't have to implement the rest of the PrismaClient surface — this
/// is a unit test, not a DB test.
interface AuditEntryMock {
create: ReturnType<typeof vi.fn>;
}
function mockPrisma(): PrismaClient & { auditEntry: AuditEntryMock } {
return { auditEntry: { create: vi.fn() } } as unknown as PrismaClient & {
auditEntry: AuditEntryMock;
};
}
describe("writeAudit (A-8 audit write path)", () => {
it("creates a row with all fields set", async () => {
const prisma = mockPrisma();
prisma.auditEntry.create.mockResolvedValue({ id: "aud-1" });
await writeAudit(prisma, {
runId: "run-1",
projectId: "proj-1",
actorUserId: "user-1",
action: "run.triggered",
metadata: { source: "feishu" },
});
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
expect(prisma.auditEntry.create).toHaveBeenCalledWith({
data: {
runId: "run-1",
projectId: "proj-1",
actorUserId: "user-1",
action: "run.triggered",
metadata: { source: "feishu" },
},
});
});
it("creates a row with only required fields (action + metadata)", async () => {
const prisma = mockPrisma();
prisma.auditEntry.create.mockResolvedValue({ id: "aud-2" });
await writeAudit(prisma, {
action: "permission.denied",
metadata: { reason: "no-edit-role" },
});
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
expect(prisma.auditEntry.create).toHaveBeenCalledWith({
data: {
action: "permission.denied",
metadata: { reason: "no-edit-role" },
},
});
});
it("swallows a prisma error (best-effort, must not throw)", async () => {
const prisma = mockPrisma();
prisma.auditEntry.create.mockRejectedValue(new Error("db down"));
// Must not throw — audit is observability, not control.
await expect(
writeAudit(prisma, {
action: "run.triggered",
metadata: {},
}),
).resolves.toBeUndefined();
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
});
});
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { createDefaultModelRegistry } from "../../src/agent/defaultModels.js";
describe("default model registry", () => {
it("uses OpenRouter model slugs for Sonnet defaults", () => {
const models = createDefaultModelRegistry({});
expect(models.listModels()[0]).toMatchObject({
id: "anthropic/claude-sonnet-5",
label: "Claude Sonnet 5",
toolCapable: true,
});
expect(models.resolve(undefined, "draft")).toBe("anthropic/claude-sonnet-5");
expect(models.resolve(undefined, "review")).toBe("anthropic/claude-sonnet-5");
});
it("honors ANTHROPIC_DEFAULT_SONNET_MODEL when configured", () => {
const models = createDefaultModelRegistry({
ANTHROPIC_DEFAULT_SONNET_MODEL: "z-ai/glm-4.7",
});
expect(models.listModels()[0]?.id).toBe("z-ai/glm-4.7");
expect(models.resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
});
});
-189
View File
@@ -1,189 +0,0 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import type { PrismaClient } from "@prisma/client";
import type { PermissionAuthorizer } from "../../src/permission.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
import { sendApprovalCard, resolveCard, type CardActionEvent, type FeishuRuntime } from "../../src/feishu/client.js";
import { ApprovalManager, makeTriggerHandler } from "../../src/feishu/trigger.js";
describe("Feishu approval cards", () => {
afterEach(() => {
vi.useRealTimers();
});
it("sendApprovalCard builds correct card JSON", async () => {
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
const rt = mockRuntime({ messageCreate });
await expect(sendApprovalCard(rt, "chat-1", "Approve?", "Please approve.", [
{ label: "Approve", value: "approve", style: "primary" },
{ label: "Reject", value: "reject", style: "danger" },
{ label: "Later", value: "later" },
])).resolves.toBe("message-1");
expect(messageCreate).toHaveBeenCalledTimes(1);
const payload = messageCreate.mock.calls[0]?.[0] as { data: { content: string } };
expect(payload).toMatchObject({
params: { receive_id_type: "chat_id" },
data: { receive_id: "chat-1", msg_type: "interactive" },
});
expect(JSON.parse(payload.data.content)).toEqual({
config: { wide_screen_mode: true },
header: { title: { content: "Approve?", tag: "plain_text" }, template: "orange" },
elements: [
{ tag: "markdown", content: "Please approve." },
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "Approve" },
type: "primary",
value: { approval_action: "approve" },
},
{
tag: "button",
text: { tag: "plain_text", content: "Reject" },
type: "danger",
value: { approval_action: "reject" },
},
{
tag: "button",
text: { tag: "plain_text", content: "Later" },
type: "default",
value: { approval_action: "later" },
},
],
},
],
});
});
it("resolveCard patches with resolved state", async () => {
const messagePatch = vi.fn(async () => ({}));
const rt = mockRuntime({ messagePatch });
await resolveCard(rt, "message-1", "OK", "Approved", "green", "ou_user");
expect(messagePatch).toHaveBeenCalledTimes(1);
expect(messagePatch).toHaveBeenCalledWith({
path: { message_id: "message-1" },
params: { msg_type: "interactive" },
data: {
content: JSON.stringify({
config: { wide_screen_mode: true },
header: { title: { content: "OK Approved", tag: "plain_text" }, template: "green" },
elements: [{ tag: "markdown", content: "OK **Approved** by ou_user" }],
}),
},
});
});
it("ApprovalManager register+resolve lifecycle", async () => {
const manager = new ApprovalManager({ timeoutMs: 10_000 });
const result = manager.register("message-1", "chat-1");
expect(manager.hasPending("message-1")).toBe(true);
manager.resolve("message-1", "approve", "ou_user");
await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" });
expect(manager.hasPending("message-1")).toBe(false);
});
it("ApprovalManager timeout", async () => {
vi.useFakeTimers();
const manager = new ApprovalManager({ timeoutMs: 100 });
const result = manager.register("message-1", "chat-1");
const assertion = expect(result).rejects.toThrow("approval timed out after 100ms");
await vi.advanceTimersByTimeAsync(100);
await assertion;
expect(manager.hasPending("message-1")).toBe(false);
});
it("routes card actions through the trigger handler", async () => {
const messagePatch = vi.fn(async () => ({}));
const rt = mockRuntime({ messagePatch });
const trigger = makeTriggerHandler({
prisma: {} as PrismaClient,
settings: {} as RuntimeSettings,
logger: silentLogger(),
authorizer: allowAllAuthorizer(),
});
const result = trigger.approvalManager.register("message-1", "chat-1");
await trigger.onCardAction(makeCardActionEvent("message-1", "approve", "ou_user"), rt);
await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" });
expect(messagePatch).toHaveBeenCalledTimes(1);
const payload = messagePatch.mock.calls[0]?.[0] as { data: { content: string } };
expect(payload).toMatchObject({
path: { message_id: "message-1" },
params: { msg_type: "interactive" },
});
expect(JSON.parse(payload.data.content)).toEqual({
config: { wide_screen_mode: true },
header: { title: { content: "✅ Approved", tag: "plain_text" }, template: "green" },
elements: [{ tag: "markdown", content: "✅ **Approved** by ou_user" }],
});
expect(trigger.approvalManager.hasPending("message-1")).toBe(false);
});
});
function makeCardActionEvent(messageId: string, action: string, openId: string): CardActionEvent {
return {
operator: { open_id: openId },
action: { value: { approval_action: action }, tag: "button" },
context: { open_message_id: messageId, open_chat_id: "chat-1" },
};
}
function mockRuntime(options: {
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
readonly messagePatch?: (payload: unknown) => Promise<unknown>;
} = {}): FeishuRuntime {
return {
client: {
im: {
v1: {
message: {
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
patch: options.messagePatch ?? vi.fn(async () => ({})),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: silentLogger(),
};
}
function allowAllAuthorizer(): PermissionAuthorizer {
return {
async can(req) {
return {
allowed: true,
reason: "test allow",
action: req.action,
resource: req.resource,
actor: req.actor,
organizationId: "org_test_default",
actorUserId: "user-1",
principals: [{ type: "USER", id: "ou_user" }],
requiredRole: "EDIT",
effectiveRole: "EDIT",
};
},
};
}
function silentLogger(): FeishuRuntime["logger"] {
return {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"];
}
@@ -1,131 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { FastifyBaseLogger } from "fastify";
import type { CardActionEvent, FeishuRuntime } from "../../src/feishu/client.js";
import { startFeishuListenerWithClient } from "../../src/feishu/client.js";
const larkMock = vi.hoisted(() => {
const starts: Array<{ readonly eventDispatcher?: unknown }> = [];
class MockEventDispatcher {
handlers: Record<string, (data: unknown) => Promise<void>> = {};
register(handlers: Record<string, (data: unknown) => Promise<void>>): this {
this.handlers = handlers;
return this;
}
}
class MockWSClient {
start(params: { readonly eventDispatcher?: unknown }): void {
starts.push(params);
}
}
return { starts, MockEventDispatcher, MockWSClient };
});
vi.mock("@larksuiteoapi/node-sdk", () => ({
AppType: { SelfBuild: "SelfBuild" },
Domain: { Feishu: "Feishu" },
LoggerLevel: { info: "info" },
Client: vi.fn(),
EventDispatcher: larkMock.MockEventDispatcher,
WSClient: larkMock.MockWSClient,
}));
describe("Feishu card action callbacks", () => {
beforeEach(() => {
larkMock.starts.length = 0;
});
it("acks card action callbacks before the business handler settles", async () => {
const action = deferred<void>();
const onCardAction = vi.fn(async () => action.promise);
const logger = silentLogger();
startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
logger,
async () => {},
onCardAction,
);
const handler = cardActionHandler();
const handlerDone = handler(makeCardActionEvent()).then(() => "done" as const);
const earlyResult = await Promise.race([handlerDone, delay(25).then(() => "timeout" as const)]);
action.resolve();
await handlerDone;
expect(earlyResult).toBe("done");
expect(onCardAction).toHaveBeenCalledTimes(1);
});
it("logs background card action failures after acking", async () => {
const logger = silentLogger();
const err = new Error("handler failed");
startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
logger,
async () => {},
async () => {
throw err;
},
);
await cardActionHandler()(makeCardActionEvent());
await vi.waitFor(() => {
expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw");
});
});
});
function cardActionHandler(): (data: unknown) => Promise<void> {
const start = larkMock.starts[0];
if (start === undefined) throw new Error("WSClient.start was not called");
const dispatcher = start.eventDispatcher as { readonly handlers?: Record<string, (data: unknown) => Promise<void>> };
const handler = dispatcher.handlers?.["card.action.trigger"];
if (handler === undefined) throw new Error("card.action.trigger handler was not registered");
return handler;
}
function makeCardActionEvent(): CardActionEvent {
return {
operator: { open_id: "ou_user" },
action: { value: { interrupt_run: "run-1" }, tag: "button" },
context: { open_chat_id: "chat-1", open_message_id: "message-1" },
};
}
function fakeClient(): FeishuRuntime["client"] {
return {} as FeishuRuntime["client"];
}
function silentLogger(): FastifyBaseLogger {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
child() { return this; },
level: "silent",
} as unknown as FastifyBaseLogger;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
-124
View File
@@ -1,124 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveSenderName, sendFile, sendLongText, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js";
import { SenderNameCache } from "../../src/feishu/senderCache.js";
describe("Feishu client helpers", () => {
it("accepts top-level file_key from the Lark SDK file upload response", async () => {
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
try {
const file = join(dir, "student.pdf");
await writeFile(file, "pdf bytes");
const fileCreate = vi.fn(async () => ({ file_key: "file-key-1" }));
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
const rt = mockRuntime({ fileCreate, messageCreate });
await expect(sendFile(rt, "chat-1", file, "student.pdf")).resolves.toBe("message-1");
expect(messageCreate).toHaveBeenCalledWith({
params: { receive_id_type: "chat_id" },
data: { receive_id: "chat-1", msg_type: "file", content: JSON.stringify({ file_key: "file-key-1" }) },
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("accepts top-level message_id from message create responses", async () => {
const rt = mockRuntime({
fileCreate: vi.fn(),
messageCreate: vi.fn(async () => ({ message_id: "message-1" })),
});
await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1");
});
it("replies to a message without enabling Feishu topic mode", async () => {
const messageCreate = vi.fn();
const messageReply = vi.fn(async () => ({ data: { message_id: "reply-1" } }));
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate, messageReply });
await expect(sendTextMessage(rt, "chat-1", "hello", { replyToMessageId: "m-trigger" })).resolves.toBe("reply-1");
expect(messageCreate).not.toHaveBeenCalled();
expect(messageReply).toHaveBeenCalledWith({
path: { message_id: "m-trigger" },
data: {
msg_type: "text",
content: JSON.stringify({ text: "hello" }),
},
});
});
it("sends long text in chunks and returns the last successful message id", async () => {
let messageNumber = 0;
const messageCreate = vi.fn(async () => ({ data: { message_id: `message-${++messageNumber}` } }));
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate });
await expect(sendLongText(rt, "chat-1", "x".repeat(16_005))).resolves.toBe("message-3");
expect(messageCreate).toHaveBeenCalledTimes(3);
const sentTexts = messageCreate.mock.calls.map(([payload]) => {
const content = (payload as { data: { content: string } }).data.content;
return (JSON.parse(content) as { text: string }).text;
});
expect(sentTexts.map((text) => text.length)).toEqual([8000, 8000, 5]);
});
it("resolves sender names through the basic contact API and caches hits", async () => {
const contactBasicBatch = vi.fn(async () => ({ data: { users: [{ name: "Jiarong" }] } }));
const request = vi.fn();
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), contactBasicBatch, request });
const cache = new SenderNameCache();
await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong");
await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong");
expect(contactBasicBatch).toHaveBeenCalledTimes(1);
expect(contactBasicBatch).toHaveBeenCalledWith({
params: { user_id_type: "open_id" },
data: { user_ids: ["ou_user_1"] },
});
expect(request).not.toHaveBeenCalled();
});
});
function mockRuntime(options: {
readonly fileCreate: (payload: unknown) => Promise<unknown>;
readonly messageCreate: (payload: unknown) => Promise<unknown>;
readonly messageReply?: (payload: unknown) => Promise<unknown>;
readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>;
readonly request?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
request: options.request,
contact: {
v3: {
user: {
basicBatch: options.contactBasicBatch,
},
},
},
im: {
v1: {
file: { create: options.fileCreate },
message: {
create: options.messageCreate,
reply: options.messageReply ?? vi.fn(async () => ({ data: { message_id: "reply-1" } })),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"],
};
}
-141
View File
@@ -1,141 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
_buildMarkdownPostRows,
buildOutboundPayload,
patchTextMessage,
sendTextMessage,
type FeishuRuntime,
} from "../../src/feishu/client.js";
describe("Feishu markdown outbound payloads", () => {
it("uses text messages for plain text", () => {
const payload = buildOutboundPayload("hello from cph");
expect(payload.msgType).toBe("text");
expect(JSON.parse(payload.content)).toEqual({ text: "hello from cph" });
});
it("keeps fenced code blocks in separate post rows", () => {
const content = "Before\n```ts\nconst x = 1;\n```\nAfter";
expect(_buildMarkdownPostRows(content)).toEqual([
[{ tag: "md", text: "Before\n" }],
[{ tag: "md", text: "```ts\nconst x = 1;\n```" }],
[{ tag: "md", text: "\nAfter" }],
]);
});
it("forces markdown tables to plain text", () => {
const content = "| A | B |\n|---|---|\n| 1 | 2 |";
const payload = buildOutboundPayload(content);
expect(payload.msgType).toBe("text");
expect(JSON.parse(payload.content)).toEqual({ text: content });
});
it("uses post messages for markdown headers, bold text, and lists", () => {
const content = "# Title\n\nThis is **important**.\n\n- first\n- second";
const payload = buildOutboundPayload(content);
expect(payload.msgType).toBe("post");
expect(JSON.parse(payload.content)).toEqual({
zh_cn: {
content: [[{ tag: "md", text: content }]],
},
});
});
it.each([
["header", "# Heading"],
["list", "- item"],
["code block", "```ts\nconst x = 1;\n```"],
["inline code", "Use `code` here"],
["bold", "**bold**"],
["italic", "_italic_"],
["strikethrough", "~~removed~~"],
["link", "[docs](https://example.com)"],
["blockquote", "> quoted"],
])("detects markdown %s syntax", (_name, content) => {
expect(buildOutboundPayload(content).msgType).toBe("post");
});
it("falls back from rejected post payloads to stripped plain text", async () => {
const messageCreate = vi
.fn()
.mockRejectedValueOnce(new Error("content format of the post type is incorrect"))
.mockResolvedValueOnce({ data: { message_id: "message-1" } });
const rt = mockRuntime({ messageCreate });
await expect(sendTextMessage(rt, "chat-1", "# Title\n\n**bold** and `code`")).resolves.toBe("message-1");
expect(messageCreate).toHaveBeenCalledTimes(2);
expect(messageCreate).toHaveBeenNthCalledWith(1, {
params: { receive_id_type: "chat_id" },
data: {
receive_id: "chat-1",
msg_type: "post",
content: JSON.stringify({
zh_cn: {
content: [[{ tag: "md", text: "# Title\n\n**bold** and `code`" }]],
},
}),
},
});
expect(messageCreate).toHaveBeenNthCalledWith(2, {
params: { receive_id_type: "chat_id" },
data: {
receive_id: "chat-1",
msg_type: "text",
content: JSON.stringify({ text: "Title\n\nbold and code" }),
},
});
});
it("patches markdown post rows as separate interactive card elements", async () => {
const patch = vi.fn(async () => ({}));
const rt = mockRuntime({ patch });
await patchTextMessage(rt, "message-1", "Before\n```ts\nconst x = 1;\n```\nAfter");
expect(patch).toHaveBeenCalledWith({
path: { message_id: "message-1" },
params: { msg_type: "interactive" },
data: {
content: JSON.stringify({
elements: [
{ tag: "div", text: { tag: "lark_md", content: "Before\n" } },
{ tag: "div", text: { tag: "lark_md", content: "```ts\nconst x = 1;\n```" } },
{ tag: "div", text: { tag: "lark_md", content: "\nAfter" } },
],
}),
},
});
});
});
function mockRuntime(options: {
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
readonly patch?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
im: {
v1: {
message: {
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
patch: options.patch ?? vi.fn(async () => ({})),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"],
};
}
-96
View File
@@ -1,96 +0,0 @@
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/);
});
});

Some files were not shown because too many files have changed in this diff Show More